diff --git a/src/cli/App.tsx b/src/cli/App.tsx index a3799a2..1494fa5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -33,7 +33,6 @@ import { import { addCommandResult, handleProfileDelete, - handleProfileList, handleProfileSave, handleProfileUsage, type ProfileCommandContext, @@ -49,6 +48,7 @@ import { Input } from "./components/InputRich"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; import { PlanModeDialog } from "./components/PlanModeDialog"; +import { ProfileSelector } from "./components/ProfileSelector"; import { QuestionDialog } from "./components/QuestionDialog"; import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { ResumeSelector } from "./components/ResumeSelector"; @@ -364,6 +364,9 @@ export default function App({ const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false); const [messageSearchOpen, setMessageSearchOpen] = useState(false); + // Profile selector state + const [profileSelectorOpen, setProfileSelectorOpen] = useState(false); + // Token streaming preference (can be toggled at runtime) const [tokenStreamingEnabled, setTokenStreamingEnabled] = useState(tokenStreaming); @@ -1264,6 +1267,26 @@ export default function App({ async (targetAgentId: string, opts?: { profileName?: string }) => { setAgentSelectorOpen(false); + // Skip if already on this agent + if (targetAgentId === agentId) { + const isProfileLoad = !!opts?.profileName; + const label = isProfileLoad ? opts.profileName : targetAgentId; + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: isProfileLoad + ? `/profile load ${opts.profileName}` + : `/resume ${targetAgentId}`, + output: `Already on "${agentName || label}"`, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return; + } + const isProfileLoad = !!opts?.profileName; const inputCmd = isProfileLoad ? `/profile load ${opts.profileName}` @@ -1352,7 +1375,7 @@ export default function App({ setCommandRunning(false); } }, - [refreshDerived, agentId], + [refreshDerived, agentId, agentName], ); const onSubmit = useCallback( @@ -1879,9 +1902,9 @@ export default function App({ setAgentName, }; - // /profile - list all profiles + // /profile - open profile selector if (!subcommand) { - handleProfileList(profileCtx, msg); + setProfileSelectorOpen(true); return { submitted: true }; } @@ -3515,6 +3538,7 @@ Plan file path: ${planFilePath}`; !systemPromptSelectorOpen && !agentSelectorOpen && !resumeSelectorOpen && + !profileSelectorOpen && !messageSearchOpen } streaming={ @@ -3591,6 +3615,48 @@ Plan file path: ${planFilePath}`; /> )} + {/* Profile Selector - conditionally mounted as overlay */} + {profileSelectorOpen && ( + { + setProfileSelectorOpen(false); + await handleAgentSelect(id, { profileName }); + }} + onSave={async (profileName) => { + setProfileSelectorOpen(false); + const profileCtx: ProfileCommandContext = { + buffersRef, + refreshDerived, + agentId, + setCommandRunning, + setAgentName, + }; + await handleProfileSave( + profileCtx, + `/profile save ${profileName}`, + profileName, + ); + }} + onDelete={(profileName) => { + setProfileSelectorOpen(false); + const profileCtx: ProfileCommandContext = { + buffersRef, + refreshDerived, + agentId, + setCommandRunning, + setAgentName, + }; + handleProfileDelete( + profileCtx, + `/profile delete ${profileName}`, + profileName, + ); + }} + onCancel={() => setProfileSelectorOpen(false)} + /> + )} + {/* Message Search - conditionally mounted as overlay */} {messageSearchOpen && ( setMessageSearchOpen(false)} /> diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 01caf8f..6f586ab 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -73,7 +73,7 @@ export const commands: Record = { }, }, "/toolset": { - desc: "Switch toolset (codex/default)", + desc: "Switch toolset", handler: () => { // Handled specially in App.tsx to access agent ID and client return "Opening toolset selector..."; @@ -87,7 +87,7 @@ export const commands: Record = { }, }, "/download": { - desc: "Download agent file locally", + desc: "Download AgentFile (.af)", handler: () => { // Handled specially in App.tsx to access agent ID and client return "Downloading agent file..."; @@ -115,7 +115,7 @@ export const commands: Record = { }, }, "/remember": { - desc: "Remember something from the conversation (optionally: /remember )", + desc: "Remember something from the conversation", handler: () => { // Handled specially in App.tsx to trigger memory update return "Processing memory request..."; diff --git a/src/cli/components/CommandPreview.tsx b/src/cli/components/CommandPreview.tsx index fe7b517..5cf594e 100644 --- a/src/cli/components/CommandPreview.tsx +++ b/src/cli/components/CommandPreview.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink"; import Link from "ink-link"; +import { useMemo } from "react"; +import { getProfiles } from "../commands/profile"; import { commands } from "../commands/registry"; import { colors } from "./colors"; @@ -24,6 +26,16 @@ export function CommandPreview({ agentName?: string | null; serverUrl?: string; }) { + // Look up if current agent is saved as a profile + const profileName = useMemo(() => { + if (!agentId) return null; + const profiles = getProfiles(); + for (const [name, id] of Object.entries(profiles)) { + if (id === agentId) return name; + } + return null; + }, [agentId]); + if (!currentInput.startsWith("/")) { return null; } @@ -46,21 +58,32 @@ export function CommandPreview({ ))} {showBottomBar && ( - - {agentName && Agent: {agentName}} - {isCloudUser ? ( - - View agent in ADE - - ) : ( - Connected to agent located at {serverUrl} - )} + + + Current agent: + {agentName || "Unnamed"} + {profileName ? ( + (profile: {profileName} ✓) + ) : ( + (type /profile to pin agent) + )} + + + {agentId} + {isCloudUser && ( + <> + · + + Open in ADE ↗ + + · + + View usage ↗ + + + )} + {!isCloudUser && · {serverUrl}} + )} diff --git a/src/cli/components/ProfileSelector.tsx b/src/cli/components/ProfileSelector.tsx new file mode 100644 index 0000000..59ea5c3 --- /dev/null +++ b/src/cli/components/ProfileSelector.tsx @@ -0,0 +1,396 @@ +import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; +import { Box, Text, useInput } from "ink"; +import { memo, useCallback, useEffect, useState } from "react"; +import { getClient } from "../../agent/client"; +import { getProfiles } from "../commands/profile"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +interface ProfileSelectorProps { + currentAgentId: string; + onSelect: (agentId: string, profileName: string) => void; + onSave: (profileName: string) => void; + onDelete: (profileName: string) => void; + onCancel: () => void; +} + +interface ProfileData { + name: string; + agentId: string; + agent: AgentState | null; + error: string | null; +} + +const DISPLAY_PAGE_SIZE = 5; + +/** + * Format a relative time string from a date + */ +function formatRelativeTime(dateStr: string | null | undefined): string { + if (!dateStr) return "Never"; + + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + const diffWeeks = Math.floor(diffDays / 7); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) + return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`; + if (diffHours < 24) + return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`; +} + +/** + * Truncate agent ID with middle ellipsis if it exceeds available width + */ +function truncateAgentId(id: string, availableWidth: number): string { + if (id.length <= availableWidth) return id; + if (availableWidth < 15) return id.slice(0, availableWidth); + const prefixLen = Math.floor((availableWidth - 3) / 2); + const suffixLen = availableWidth - 3 - prefixLen; + return `${id.slice(0, prefixLen)}...${id.slice(-suffixLen)}`; +} + +/** + * Format model string to show provider/model-name + */ +function formatModel(agent: AgentState): string { + if (agent.model) { + return agent.model; + } + if (agent.llm_config?.model) { + const provider = agent.llm_config.model_endpoint_type || "unknown"; + return `${provider}/${agent.llm_config.model}`; + } + return "unknown"; +} + +type Mode = "browsing" | "saving" | "confirming-delete"; + +export const ProfileSelector = memo(function ProfileSelector({ + currentAgentId, + onSelect, + onSave, + onDelete, + onCancel, +}: ProfileSelectorProps) { + const terminalWidth = useTerminalWidth(); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [mode, setMode] = useState("browsing"); + const [saveInput, setSaveInput] = useState(""); + const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(0); + + // Load profiles and fetch agent data + const loadProfiles = useCallback(async () => { + setLoading(true); + try { + const profilesMap = getProfiles(); + const profileNames = Object.keys(profilesMap).sort(); + + if (profileNames.length === 0) { + setProfiles([]); + setLoading(false); + return; + } + + const client = await getClient(); + + // Fetch agent data for each profile + const profileDataPromises = profileNames.map(async (name) => { + const agentId = profilesMap[name] as string; + try { + const agent = await client.agents.retrieve(agentId, { + include: ["agent.blocks"], + }); + return { name, agentId, agent, error: null }; + } catch (_err) { + return { name, agentId, agent: null, error: "Agent not found" }; + } + }); + + const profileData = await Promise.all(profileDataPromises); + setProfiles(profileData); + } catch (_err) { + setProfiles([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadProfiles(); + }, [loadProfiles]); + + // Pagination + const totalPages = Math.ceil(profiles.length / DISPLAY_PAGE_SIZE); + const startIndex = currentPage * DISPLAY_PAGE_SIZE; + const pageProfiles = profiles.slice( + startIndex, + startIndex + DISPLAY_PAGE_SIZE, + ); + + // Get currently selected profile + const selectedProfile = pageProfiles[selectedIndex]; + + useInput((input, key) => { + if (loading) return; + + // Handle save mode - capture text input inline (like ResumeSelector) + if (mode === "saving") { + if (key.return && saveInput.trim()) { + // onSave closes the selector + onSave(saveInput.trim()); + return; + } else if (key.escape) { + setMode("browsing"); + setSaveInput(""); + } else if (key.backspace || key.delete) { + setSaveInput((prev) => prev.slice(0, -1)); + } else if (input && !key.ctrl && !key.meta) { + setSaveInput((prev) => prev + input); + } + return; + } + + // Handle delete confirmation mode + if (mode === "confirming-delete") { + if (key.upArrow || key.downArrow) { + setDeleteConfirmIndex((prev) => (prev === 0 ? 1 : 0)); + } else if (key.return) { + if (deleteConfirmIndex === 0 && selectedProfile) { + // Yes - delete (onDelete closes the selector) + onDelete(selectedProfile.name); + return; + } else { + // No - cancel + setMode("browsing"); + } + } else if (key.escape) { + setMode("browsing"); + } + return; + } + + // Browsing mode + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(pageProfiles.length - 1, prev + 1)); + } else if (key.return) { + if (selectedProfile?.agent) { + onSelect(selectedProfile.agentId, selectedProfile.name); + } + } else if (key.escape) { + onCancel(); + } else if (input === "s" || input === "S") { + setMode("saving"); + setSaveInput(""); + } else if (input === "d" || input === "D") { + if (selectedProfile) { + setMode("confirming-delete"); + setDeleteConfirmIndex(1); // Default to "No" + } + } else if (input === "j" || input === "J") { + // Previous page + if (currentPage > 0) { + setCurrentPage((prev) => prev - 1); + setSelectedIndex(0); + } + } else if (input === "k" || input === "K") { + // Next page + if (currentPage < totalPages - 1) { + setCurrentPage((prev) => prev + 1); + setSelectedIndex(0); + } + } + }); + + // Save mode UI + if (mode === "saving") { + return ( + + + + Save Current Agent as Profile + + + + Enter profile name (Esc to cancel): + + > + {saveInput} + + + + + ); + } + + // Delete confirmation UI + if (mode === "confirming-delete" && selectedProfile) { + const options = ["Yes, delete", "No, cancel"]; + return ( + + + + Delete Profile + + + + + Are you sure you want to delete profile "{selectedProfile.name}"? + + + + {options.map((option, index) => { + const isSelected = index === deleteConfirmIndex; + return ( + + + {isSelected ? ">" : " "} {option} + + + ); + })} + + + ); + } + + // Main browsing UI + return ( + + + + Profiles + + + + {/* Loading state */} + {loading && ( + + Loading profiles... + + )} + + {/* Empty state */} + {!loading && profiles.length === 0 && ( + + No profiles saved. + Press S to save the current agent as a profile. + + Esc to close + + + )} + + {/* Profile list */} + {!loading && profiles.length > 0 && ( + + {pageProfiles.map((profile, index) => { + const isSelected = index === selectedIndex; + const isCurrent = profile.agentId === currentAgentId; + const hasAgent = profile.agent !== null; + + // Calculate available width for agent ID + const nameLen = profile.name.length; + const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); // "> " + " · " + " (current)" + const availableForId = Math.max( + 15, + terminalWidth - nameLen - fixedChars, + ); + const displayId = truncateAgentId(profile.agentId, availableForId); + + return ( + + {/* Row 1: Selection indicator, profile name, and ID */} + + + {isSelected ? ">" : " "} + + + + {profile.name} + + · {displayId} + {isCurrent && ( + (current) + )} + + {/* Row 2: Description or error */} + + {hasAgent ? ( + + {profile.agent?.description || "No description"} + + ) : ( + + {profile.error} + + )} + + {/* Row 3: Metadata (only if agent exists) */} + {hasAgent && profile.agent && ( + + + {formatRelativeTime(profile.agent.last_run_completion)} ·{" "} + {profile.agent.blocks?.length ?? 0} memory block + {(profile.agent.blocks?.length ?? 0) === 1 ? "" : "s"} ·{" "} + {formatModel(profile.agent)} + + + )} + + ); + })} + + )} + + {/* Footer with pagination and controls */} + {!loading && profiles.length > 0 && ( + + {totalPages > 1 && ( + + + Page {currentPage + 1}/{totalPages} + + + )} + + + ↑↓ navigate · Enter load · S save · D delete · J/K page · Esc + close + + + + )} + + {/* Footer for empty state already handled above */} + + ); +}); + +ProfileSelector.displayName = "ProfileSelector"; diff --git a/src/cli/components/ResumeSelector.tsx b/src/cli/components/ResumeSelector.tsx index 92a1c80..1d9722d 100644 --- a/src/cli/components/ResumeSelector.tsx +++ b/src/cli/components/ResumeSelector.tsx @@ -92,6 +92,7 @@ export function ResumeSelector({ const agentList = await client.agents.list({ limit: FETCH_PAGE_SIZE, + tags: ["origin:letta-code"], include: ["agent.blocks"], order: "desc", order_by: "last_run_completion", @@ -236,7 +237,7 @@ export function ResumeSelector({ - Resume Session + Resume Session (showing most recent agents) @@ -356,7 +357,8 @@ export function ResumeSelector({ - ↑↓ navigate · Enter select · J/K page · Type + Enter to search + ↑↓ navigate · Enter to switch agents · J/K page · Type + Enter to + search diff --git a/src/cli/components/WelcomeScreen.tsx b/src/cli/components/WelcomeScreen.tsx index 97df7df..380fd4a 100644 --- a/src/cli/components/WelcomeScreen.tsx +++ b/src/cli/components/WelcomeScreen.tsx @@ -119,7 +119,7 @@ export function WelcomeScreen({ !!continueSession, agentId, ); - const pathLine = isMedium ? `Running in ${cwd}` : cwd; + const pathLine = isMedium ? `${cwd}` : cwd; const agentUrl = agentId ? `https://app.letta.com/agents/${agentId}` : null; const hints = loadingState === "ready" diff --git a/src/cli/helpers/errorFormatter.ts b/src/cli/helpers/errorFormatter.ts index 7656005..5c0fe43 100644 --- a/src/cli/helpers/errorFormatter.ts +++ b/src/cli/helpers/errorFormatter.ts @@ -1,5 +1,42 @@ import { APIError } from "@letta-ai/letta-client/core/error"; +const LETTA_USAGE_URL = "https://app.letta.com/settings/organization/usage"; + +/** + * Check if the error is a credit exhaustion error (402 with not-enough-credits) + */ +function isCreditExhaustedError(e: APIError): boolean { + // Check status code + if (e.status !== 402) return false; + + // Check for "not-enough-credits" in various places it could appear + const errorBody = e.error; + if (errorBody && typeof errorBody === "object") { + // Check reasons array: {"error":"Rate limited","reasons":["not-enough-credits"]} + if ("reasons" in errorBody && Array.isArray(errorBody.reasons)) { + if (errorBody.reasons.includes("not-enough-credits")) { + return true; + } + } + // Check nested error.reasons + if ("error" in errorBody && typeof errorBody.error === "object") { + const nested = errorBody.error as Record; + if ("reasons" in nested && Array.isArray(nested.reasons)) { + if (nested.reasons.includes("not-enough-credits")) { + return true; + } + } + } + } + + // Also check the message for "not-enough-credits" as a fallback + if (e.message?.includes("not-enough-credits")) { + return true; + } + + return false; +} + /** * Extract comprehensive error details from any error object * Handles APIError, Error, and other error types consistently @@ -11,6 +48,10 @@ export function formatErrorDetails(e: unknown, agentId?: string): string { // Handle APIError from streaming (event: error) if (e instanceof APIError) { + // Check for credit exhaustion error first - provide a friendly message + if (isCreditExhaustedError(e)) { + return `Your account is out of credits. Redeem additional credits or configure auto-recharge on your account page: ${LETTA_USAGE_URL}`; + } // Check for nested error structure: e.error.error if (e.error && typeof e.error === "object" && "error" in e.error) { const errorData = e.error.error;