diff --git a/src/cli/App.tsx b/src/cli/App.tsx index b09ae3a..76ac42c 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -41,6 +41,7 @@ import { ModelSelector } from "./components/ModelSelector"; import { PlanModeDialog } from "./components/PlanModeDialog"; import { QuestionDialog } from "./components/QuestionDialog"; import { ReasoningMessage } from "./components/ReasoningMessageRich"; +import { ResumeSelector } from "./components/ResumeSelector"; import { SessionStats as SessionStatsComponent } from "./components/SessionStats"; import { StatusMessage } from "./components/StatusMessage"; import { SystemPromptSelector } from "./components/SystemPromptSelector"; @@ -327,6 +328,9 @@ export default function App({ // Agent selector state const [agentSelectorOpen, setAgentSelectorOpen] = useState(false); + // Resume selector state + const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false); + // Token streaming preference (can be toggled at runtime) const [tokenStreamingEnabled, setTokenStreamingEnabled] = useState(tokenStreaming); @@ -1411,14 +1415,20 @@ export default function App({ return { submitted: true }; } - // Special handling for /swap command - switch to a different agent + // Special handling for /resume command - show session resume selector + if (msg.trim() === "/resume") { + setResumeSelectorOpen(true); + return { submitted: true }; + } + + // Special handling for /swap command - alias for /resume if (msg.trim().startsWith("/swap")) { const parts = msg.trim().split(/\s+/); const targetAgentId = parts.slice(1).join(" "); - // If no agent ID provided, open agent selector + // If no agent ID provided, open resume selector (same as /resume) if (!targetAgentId) { - setAgentSelectorOpen(true); + setResumeSelectorOpen(true); return { submitted: true }; } @@ -3098,7 +3108,8 @@ Plan file path: ${planFilePath}`; !modelSelectorOpen && !toolsetSelectorOpen && !systemPromptSelectorOpen && - !agentSelectorOpen + !agentSelectorOpen && + !resumeSelectorOpen } streaming={ streaming && !abortControllerRef.current?.signal.aborted @@ -3158,6 +3169,18 @@ Plan file path: ${planFilePath}`; /> )} + {/* Resume Selector - conditionally mounted as overlay */} + {resumeSelectorOpen && ( + { + setResumeSelectorOpen(false); + handleAgentSelect(id); + }} + onCancel={() => setResumeSelectorOpen(false)} + /> + )} + {/* Plan Mode Dialog - for ExitPlanMode tool */} {currentApproval?.toolName === "ExitPlanMode" && ( <> diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 6b4694d..e9d04db 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -6,6 +6,7 @@ type CommandHandler = (args: string[]) => Promise | string; interface Command { desc: string; handler: CommandHandler; + hidden?: boolean; // Hidden commands don't show in autocomplete but still work } export const commands: Record = { @@ -72,10 +73,11 @@ export const commands: Record = { }, }, "/swap": { - desc: "Switch to a different agent", + desc: "Alias for /resume", + hidden: true, // Hidden - use /resume instead handler: () => { - // Handled specially in App.tsx to access agent list and client - return "Swapping agent..."; + // Handled specially in App.tsx - redirects to /resume + return "Opening session selector..."; }, }, "/toolset": { @@ -127,6 +129,13 @@ export const commands: Record = { return "Processing memory request..."; }, }, + "/resume": { + desc: "Resume a previous agent session", + handler: () => { + // Handled specially in App.tsx to show resume selector + return "Opening session selector..."; + }, + }, }; /** diff --git a/src/cli/components/CommandPreview.tsx b/src/cli/components/CommandPreview.tsx index 8d54ff4..45fc300 100644 --- a/src/cli/components/CommandPreview.tsx +++ b/src/cli/components/CommandPreview.tsx @@ -4,7 +4,9 @@ import { commands } from "../commands/registry"; import { colors } from "./colors"; // Compute command list once at module level since it never changes +// Filter out hidden commands (like /swap which is an alias for /resume) const commandList = Object.entries(commands) + .filter(([, { hidden }]) => !hidden) .map(([cmd, { desc }]) => ({ cmd, desc, diff --git a/src/cli/components/ResumeSelector.tsx b/src/cli/components/ResumeSelector.tsx new file mode 100644 index 0000000..330226e --- /dev/null +++ b/src/cli/components/ResumeSelector.tsx @@ -0,0 +1,307 @@ +import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; +import { Box, Text, useInput } from "ink"; +import { useEffect, useState } from "react"; +import { getClient } from "../../agent/client"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +interface ResumeSelectorProps { + currentAgentId: string; + onSelect: (agentId: string) => void; + onCancel: () => void; +} + +const PAGE_SIZE = 10; + +/** + * 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 + * e.g., "agent-6b383e6f-f2df-43ed-ad88-8c832f1129d0" -> "agent-6b3...9d0" + */ +function truncateAgentId(id: string, availableWidth: number): string { + if (id.length <= availableWidth) return id; + if (availableWidth < 15) return id.slice(0, availableWidth); // Too narrow for ellipsis + const prefixLen = Math.floor((availableWidth - 3) / 2); // -3 for "..." + 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 { + // Prefer the new model field + if (agent.model) { + return agent.model; + } + // Fall back to llm_config + if (agent.llm_config?.model) { + const provider = agent.llm_config.model_endpoint_type || "unknown"; + return `${provider}/${agent.llm_config.model}`; + } + return "unknown"; +} + +export function ResumeSelector({ + currentAgentId, + onSelect, + onCancel, +}: ResumeSelectorProps) { + const terminalWidth = useTerminalWidth(); + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const fetchAgents = async () => { + try { + const client = await getClient(); + // Fetch agents with higher limit to ensure we get the current agent + // Include blocks to get memory block count + const agentList = await client.agents.list({ + limit: 200, + include: ["agent.blocks"], + order: "desc", + order_by: "last_run_completion", + }); + + // Sort client-side: most recent first, nulls last + const sorted = [...agentList.items].sort((a, b) => { + const aTime = a.last_run_completion + ? new Date(a.last_run_completion).getTime() + : 0; + const bTime = b.last_run_completion + ? new Date(b.last_run_completion).getTime() + : 0; + // Put nulls (0) at the end + if (aTime === 0 && bTime === 0) return 0; + if (aTime === 0) return 1; + if (bTime === 0) return -1; + // Most recent first + return bTime - aTime; + }); + + setAgents(sorted); + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }; + fetchAgents(); + }, []); + + // Debounce search query (300ms delay) + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Filter agents based on debounced search query + const filteredAgents = agents.filter((agent) => { + if (!debouncedQuery) return true; + const query = debouncedQuery.toLowerCase(); + const name = (agent.name || "").toLowerCase(); + const id = (agent.id || "").toLowerCase(); + return name.includes(query) || id.includes(query); + }); + + // Pin current agent to top of list (if it matches the filter) + const matchingAgents = [...filteredAgents].sort((a, b) => { + if (a.id === currentAgentId) return -1; + if (b.id === currentAgentId) return 1; + return 0; // Keep sort order for everything else + }); + + const totalPages = Math.ceil(matchingAgents.length / PAGE_SIZE); + const startIndex = currentPage * PAGE_SIZE; + const pageAgents = matchingAgents.slice(startIndex, startIndex + PAGE_SIZE); + + // Reset selected index and page when filtered list changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset when query changes + useEffect(() => { + setSelectedIndex(0); + setCurrentPage(0); + }, [debouncedQuery]); + + useInput((input, key) => { + if (loading || error) return; + + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(pageAgents.length - 1, prev + 1)); + } else if (key.return) { + const selectedAgent = pageAgents[selectedIndex]; + if (selectedAgent?.id) { + onSelect(selectedAgent.id); + } + } else if (key.escape) { + onCancel(); + } else if (key.backspace || key.delete) { + setSearchQuery((prev) => prev.slice(0, -1)); + } else if (input === "j" || input === "J") { + // Previous page (j = up/back) + if (currentPage > 0) { + setCurrentPage((prev) => prev - 1); + setSelectedIndex(0); + } + } else if (input === "k" || input === "K") { + // Next page (k = down/forward) + if (currentPage < totalPages - 1) { + setCurrentPage((prev) => prev + 1); + setSelectedIndex(0); + } + } else if (input === "/") { + // Ignore "/" - it's shown in help but just starts typing search + // Don't add it to the search query + } else if (input && !key.ctrl && !key.meta) { + // Add regular characters to search query (searches name and ID) + setSearchQuery((prev) => prev + input); + } + }); + + if (loading) { + return ( + + Loading agents... + + ); + } + + if (error) { + return ( + + Error loading agents: {error} + Press ESC to cancel + + ); + } + + if (agents.length === 0) { + return ( + + No agents found + Press ESC to cancel + + ); + } + + return ( + + + + Resume Session + + + + {searchQuery && ( + + Search (name/ID): + {searchQuery} + + )} + + + {pageAgents.map((agent, index) => { + const isSelected = index === selectedIndex; + const isCurrent = agent.id === currentAgentId; + + const relativeTime = formatRelativeTime(agent.last_run_completion); + const blockCount = agent.blocks?.length ?? 0; + const modelStr = formatModel(agent); + + // Calculate available width for agent ID + // Row format: "> Name · agent-id (current)" + const nameLen = (agent.name || "Unnamed").length; + const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); // "> " + " · " + " (current)" + const availableForId = Math.max( + 15, + terminalWidth - nameLen - fixedChars, + ); + const displayId = truncateAgentId(agent.id, availableForId); + + return ( + + {/* Row 1: Selection indicator, agent name, and ID */} + + + {isSelected ? ">" : " "} + + + + {agent.name || "Unnamed"} + + · {displayId} + {isCurrent && ( + (current) + )} + + {/* Row 2: Metadata (dimmed) */} + + + {relativeTime} · {blockCount} memory block + {blockCount === 1 ? "" : "s"} · {modelStr} + + + + ); + })} + + + {/* Footer with pagination and controls */} + + + + Page {currentPage + 1}/{totalPages || 1} + {matchingAgents.length > 0 && + ` (${matchingAgents.length} agent${matchingAgents.length === 1 ? "" : "s"})`} + + + + + ↑↓ navigate · Enter select · J/K prev/next page · Type to search · + Esc cancel + + + + + ); +}