diff --git a/src/cli/App.tsx b/src/cli/App.tsx index e39afa2..f1d0277 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -3910,8 +3910,9 @@ export default function App({ return { submitted: true }; } - // Special handling for /clear command - start new conversation - if (msg.trim() === "/clear") { + // Special handling for /clear and /new commands - start new conversation + // (/new used to create a new agent, now it's just an alias for /clear) + if (msg.trim() === "/clear" || msg.trim() === "/new") { const cmdId = uid("cmd"); buffersRef.current.byId.set(cmdId, { kind: "command", @@ -4553,11 +4554,6 @@ export default function App({ } // Special handling for /new command - create new agent dialog - if (msg.trim() === "/new") { - setActiveOverlay("new"); - return { submitted: true }; - } - // Special handling for /pin command - pin current agent to project (or globally with -g) if (msg.trim() === "/pin" || msg.trim().startsWith("/pin ")) { const argsStr = msg.trim().slice(4).trim(); @@ -7407,6 +7403,10 @@ Plan file path: ${planFilePath}`; await handleAgentSelect(id); }} onCancel={closeOverlay} + onCreateNewAgent={() => { + closeOverlay(); + setActiveOverlay("new"); + }} /> )} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 9b47baf..7dd05af 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -96,11 +96,11 @@ export const commands: Record = { // === Page 2: Agent management (order 20-29) === "/new": { - desc: "Create a new agent and switch to it", + desc: "Start a new conversation (same as /clear)", order: 20, handler: () => { - // Handled specially in App.tsx - return "Creating new agent..."; + // Handled specially in App.tsx - same as /clear + return "Starting new conversation..."; }, }, "/pin": { diff --git a/src/cli/components/AgentSelector.tsx b/src/cli/components/AgentSelector.tsx index d8e125a..f1a1737 100644 --- a/src/cli/components/AgentSelector.tsx +++ b/src/cli/components/AgentSelector.tsx @@ -16,6 +16,8 @@ interface AgentSelectorProps { currentAgentId: string; onSelect: (agentId: string) => void; onCancel: () => void; + /** Called when user presses N to create a new agent */ + onCreateNewAgent?: () => void; /** The command that triggered this selector (e.g., "/agents" or "/resume") */ command?: string; } @@ -111,6 +113,7 @@ export function AgentSelector({ currentAgentId, onSelect, onCancel, + onCreateNewAgent, command = "/agents", }: AgentSelectorProps) { const terminalWidth = useTerminalWidth(); @@ -557,6 +560,9 @@ export function AgentSelector({ } loadPinnedAgents(); } + } else if (input === "n" || input === "N") { + // Create new agent + onCreateNewAgent?.(); } else if (activeTab !== "pinned" && input && !key.ctrl && !key.meta) { // Type to search (list tabs only) setSearchInput((prev) => prev + input); @@ -794,7 +800,7 @@ export function AgentSelector({ : activeTab === "letta-code" ? `Page ${lettaCodePage + 1}${lettaCodeHasMore ? "+" : `/${lettaCodeTotalPages || 1}`}${lettaCodeLoadingMore ? " (loading...)" : ""}` : `Page ${allPage + 1}${allHasMore ? "+" : `/${allTotalPages || 1}`}${allLoadingMore ? " (loading...)" : ""}`; - const hintsText = `Enter select · ↑↓ navigate · ←→ page · Tab switch${activeTab === "pinned" ? " · P unpin" : " · Type to search"} · Esc cancel`; + const hintsText = `Enter select · ↑↓ navigate · ←→ page · Tab switch${activeTab === "pinned" ? " · P unpin" : " · Type to search"}${onCreateNewAgent ? " · N new" : ""} · Esc cancel`; return ( diff --git a/src/cli/components/NewAgentDialog.tsx b/src/cli/components/NewAgentDialog.tsx index 504838f..4fb27ce 100644 --- a/src/cli/components/NewAgentDialog.tsx +++ b/src/cli/components/NewAgentDialog.tsx @@ -1,16 +1,22 @@ import { Box, Text, useInput } from "ink"; import { useState } from "react"; import { DEFAULT_AGENT_NAME } from "../../constants"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; import { validateAgentName } from "./PinDialog"; +// Horizontal line character (matches other selectors) +const SOLID_LINE = "─"; + interface NewAgentDialogProps { onSubmit: (name: string) => void; onCancel: () => void; } export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const [nameInput, setNameInput] = useState(""); const [error, setError] = useState(""); @@ -45,25 +51,37 @@ export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) { }; return ( - - - - Create new agent - - + + {/* Command header */} + {"> /agents"} + {solidLine} - - + + + {/* Title */} + + Create new agent + + + + + {/* Description */} + + Enter a name for your new agent, or press Enter for default. - - + + + {/* Input field */} + + Agent name: - > + {">"} + { @@ -77,13 +95,16 @@ export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) { {error && ( - + {error} )} - - Press Enter to create • Esc to cancel + + + {/* Footer hints */} + + Enter create · Esc cancel ); diff --git a/src/headless.ts b/src/headless.ts index 0a8aa11..2c124d3 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -66,6 +66,8 @@ export async function handleHeadlessCommand( options: { // Flags used in headless mode continue: { type: "boolean", short: "c" }, + resume: { type: "boolean", short: "r" }, + conversation: { type: "string" }, new: { type: "boolean" }, agent: { type: "string", short: "a" }, model: { type: "string", short: "m" }, @@ -167,9 +169,21 @@ export async function handleHeadlessCommand( const client = await getClient(); + // Check for --resume flag (interactive only) + if (values.resume) { + console.error( + "Error: --resume is for interactive mode only (opens conversation selector).\n" + + "In headless mode, use:\n" + + " --continue Resume the last session (agent + conversation)\n" + + " --conversation Resume a specific conversation by ID", + ); + process.exit(1); + } + // Resolve agent (same logic as interactive mode) let agent: AgentState | null = null; const specifiedAgentId = values.agent as string | undefined; + const specifiedConversationId = values.conversation as string | undefined; const shouldContinue = values.continue as boolean | undefined; const forceNew = values.new as boolean | undefined; const systemPromptPreset = values.system as string | undefined; @@ -444,13 +458,55 @@ export async function handleHeadlessCommand( } } - // Always create a new conversation on startup for headless mode too - // This ensures isolated message history per CLI invocation - const conversation = await client.conversations.create({ - agent_id: agent.id, - isolated_block_labels: [...ISOLATED_BLOCK_LABELS], - }); - const conversationId = conversation.id; + // Determine which conversation to use + let conversationId: string; + + if (specifiedConversationId) { + // User specified a conversation to resume + try { + await client.conversations.retrieve(specifiedConversationId); + conversationId = specifiedConversationId; + } catch { + console.error(`Error: Conversation ${specifiedConversationId} not found`); + process.exit(1); + } + } else if (shouldContinue) { + // Try to resume the last conversation for this agent + await settingsManager.loadLocalProjectSettings(); + const lastSession = + settingsManager.getLocalLastSession(process.cwd()) ?? + settingsManager.getGlobalLastSession(); + + if (lastSession && lastSession.agentId === agent.id) { + // Verify the conversation still exists + try { + await client.conversations.retrieve(lastSession.conversationId); + conversationId = lastSession.conversationId; + } catch { + // Conversation no longer exists, create new + const conversation = await client.conversations.create({ + agent_id: agent.id, + isolated_block_labels: [...ISOLATED_BLOCK_LABELS], + }); + conversationId = conversation.id; + } + } else { + // No matching session, create new conversation + const conversation = await client.conversations.create({ + agent_id: agent.id, + isolated_block_labels: [...ISOLATED_BLOCK_LABELS], + }); + conversationId = conversation.id; + } + } else { + // Default: create a new conversation + // This ensures isolated message history per CLI invocation + const conversation = await client.conversations.create({ + agent_id: agent.id, + isolated_block_labels: [...ISOLATED_BLOCK_LABELS], + }); + conversationId = conversation.id; + } // Save session (agent + conversation) to both project and global settings await settingsManager.loadLocalProjectSettings(); @@ -546,6 +602,7 @@ export async function handleHeadlessCommand( subtype: "init", session_id: sessionId, agent_id: agent.id, + conversation_id: conversationId, model: agent.llm_config?.model ?? "", tools: agent.tools?.map((t) => t.name).filter((n): n is string => !!n) || [], @@ -1360,6 +1417,7 @@ export async function handleHeadlessCommand( num_turns: stats.usage.stepCount, result: resultText, agent_id: agent.id, + conversation_id: conversationId, usage: { prompt_tokens: stats.usage.promptTokens, completion_tokens: stats.usage.completionTokens, @@ -1395,6 +1453,7 @@ export async function handleHeadlessCommand( num_turns: stats.usage.stepCount, result: resultText, agent_id: agent.id, + conversation_id: conversationId, run_ids: Array.from(allRunIds), usage: { prompt_tokens: stats.usage.promptTokens, @@ -1435,6 +1494,7 @@ async function runBidirectionalMode( subtype: "init", session_id: sessionId, agent_id: agent.id, + conversation_id: conversationId, model: agent.llm_config?.model, tools: agent.tools?.map((t) => t.name) || [], cwd: process.cwd(), @@ -1898,6 +1958,7 @@ async function runBidirectionalMode( num_turns: numTurns, result: resultText, agent_id: agent.id, + conversation_id: conversationId, run_ids: [], usage: null, uuid: `result-${agent.id}-${Date.now()}`, diff --git a/src/index.ts b/src/index.ts index f1733af..638d0a5 100755 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context"; import type { AgentProvenance } from "./agent/create"; import { ISOLATED_BLOCK_LABELS } from "./agent/memory"; import { LETTA_CLOUD_API_URL } from "./auth/oauth"; +import { ConversationSelector } from "./cli/components/ConversationSelector"; import type { ApprovalRequest } from "./cli/helpers/stream"; import { ProfileSelectionInline } from "./cli/profile-selection"; import { permissionMode } from "./permissions/mode"; @@ -30,6 +31,8 @@ Letta Code is a general purpose CLI for interacting with Letta agents USAGE # interactive TUI letta Resume from profile or create new agent (shows selector) + letta --continue Resume last session (agent + conversation) directly + letta --resume Open agent selector UI to pick agent/conversation letta --new Create a new agent directly (skip profile selector) letta --agent Open a specific agent by ID @@ -43,6 +46,8 @@ OPTIONS -h, --help Show this help and exit -v, --version Print version and exit --info Show current directory, skills, and pinned agents + --continue Resume last session (agent + conversation) directly + -r, --resume Open agent selector UI after loading --new Create new agent directly (skip profile selection) --init-blocks Comma-separated memory blocks to initialize when using --new (e.g., "persona,skills") --base-tools Comma-separated base tools to attach when using --new (e.g., "memory,web_search,conversation_search") @@ -415,16 +420,10 @@ async function main(): Promise { process.exit(result.success ? 0 : 1); } - // Check for deprecated --continue flag - if (values.continue) { - console.error( - "Error: --continue is deprecated. Did you mean --resume (-r)?\n" + - " --resume resumes your last session (agent + conversation)", - ); - process.exit(1); - } - - const shouldResume = (values.resume as boolean | undefined) ?? false; // Resume last session + // --continue: Resume last session (agent + conversation) automatically + const shouldContinue = (values.continue as boolean | undefined) ?? false; + // --resume: Open agent selector UI after loading + const shouldResume = (values.resume as boolean | undefined) ?? false; const specifiedConversationId = (values.conversation as string | undefined) ?? null; // Specific conversation to resume const forceNew = (values.new as boolean | undefined) ?? false; @@ -818,6 +817,7 @@ async function main(): Promise { const [loadingState, setLoadingState] = useState< | "selecting" | "selecting_global" + | "selecting_conversation" | "assembling" | "importing" | "initializing" @@ -836,6 +836,11 @@ async function main(): Promise { const [selectedGlobalAgentId, setSelectedGlobalAgentId] = useState< string | null >(null); + // Track agent and conversation for conversation selector (--resume flag) + const [resumeAgentId, setResumeAgentId] = useState(null); + const [selectedConversationId, setSelectedConversationId] = useState< + string | null + >(null); // Track when user explicitly requested new agent from selector (not via --new flag) const [userRequestedNewAgent, setUserRequestedNewAgent] = useState(false); @@ -953,6 +958,28 @@ async function main(): Promise { ); } + // Handle --resume flag: show conversation selector directly + if (shouldResume) { + // Find the last-used agent for this project + const lastSession = + settingsManager.getLocalLastSession(process.cwd()) ?? + settingsManager.getGlobalLastSession(); + const lastAgentId = lastSession?.agentId ?? localSettings.lastAgent; + + if (lastAgentId) { + // Verify agent exists + try { + await client.agents.retrieve(lastAgentId); + setResumeAgentId(lastAgentId); + setLoadingState("selecting_conversation"); + return; + } catch { + // Agent doesn't exist, fall through to normal flow + } + } + // No valid agent found, fall through to normal startup + } + // Show selector if: // 1. No lastAgent in this project (fresh directory) // 2. No explicit flags that bypass selection (--new, --agent, --from-af, --continue) @@ -967,7 +994,7 @@ async function main(): Promise { setLoadingState("assembling"); } checkAndStart(); - }, [forceNew, agentIdArg, fromAfFile, continueSession]); + }, [forceNew, agentIdArg, fromAfFile, continueSession, shouldResume]); // Main initialization effect - runs after profile selection useEffect(() => { @@ -989,6 +1016,11 @@ async function main(): Promise { } } + // Priority 1.5: Use agent from conversation selector (--resume flag) + if (!resumingAgentId && resumeAgentId) { + resumingAgentId = resumeAgentId; + } + // Priority 2: Use agent selected from global selector (user just picked one) // This takes precedence over stale LRU since user explicitly chose it const shouldCreateNew = forceNew || userRequestedNewAgent; @@ -1283,6 +1315,7 @@ async function main(): Promise { // Debug: log resume flag status if (process.env.DEBUG) { + console.log(`[DEBUG] shouldContinue=${shouldContinue}`); console.log(`[DEBUG] shouldResume=${shouldResume}`); console.log( `[DEBUG] specifiedConversationId=${specifiedConversationId}`, @@ -1318,7 +1351,7 @@ async function main(): Promise { } throw error; } - } else if (shouldResume) { + } else if (shouldContinue) { // Try to load the last session for this agent const lastSession = settingsManager.getLocalLastSession(process.cwd()) ?? @@ -1372,6 +1405,29 @@ async function main(): Promise { }); conversationIdToUse = conversation.id; } + } else if (selectedConversationId) { + // User selected a specific conversation from the --resume selector + try { + setLoadingState("checking"); + const freshAgent = await client.agents.retrieve(agent.id); + const data = await getResumeData( + client, + freshAgent, + selectedConversationId, + ); + conversationIdToUse = selectedConversationId; + setResumedExistingConversation(true); + setResumeData(data); + } catch (error) { + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + console.error(`Conversation ${selectedConversationId} not found`); + process.exit(1); + } + throw error; + } } else { // Default: create a new conversation on startup // This ensures each CLI session has isolated message history @@ -1418,7 +1474,9 @@ async function main(): Promise { fromAfFile, loadingState, selectedGlobalAgentId, - shouldResume, + shouldContinue, + resumeAgentId, + selectedConversationId, ]); // Wait for keybinding auto-install to complete before showing UI @@ -1431,6 +1489,25 @@ async function main(): Promise { return null; } + // Show conversation selector for --resume flag + if (loadingState === "selecting_conversation" && resumeAgentId) { + return React.createElement(ConversationSelector, { + agentId: resumeAgentId, + currentConversationId: "", // No current conversation yet + onSelect: (conversationId: string) => { + setSelectedConversationId(conversationId); + setLoadingState("assembling"); + }, + onNewConversation: () => { + // Start with a new conversation for this agent + setLoadingState("assembling"); + }, + onCancel: () => { + process.exit(0); + }, + }); + } + // Show global agent selector in fresh repos with global pinned agents if (loadingState === "selecting_global") { return React.createElement(ProfileSelectionInline, { @@ -1451,11 +1528,18 @@ async function main(): Promise { }); } + // At this point, loadingState is not "selecting", "selecting_global", or "selecting_conversation" + // (those are handled above), so it's safe to pass to App + const appLoadingState = loadingState as Exclude< + typeof loadingState, + "selecting" | "selecting_global" | "selecting_conversation" + >; + if (!agentId || !conversationId) { return React.createElement(App, { agentId: "loading", conversationId: "loading", - loadingState, + loadingState: appLoadingState, continueSession: isResumingSession, startupApproval: resumeData?.pendingApproval ?? null, startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY, @@ -1470,7 +1554,7 @@ async function main(): Promise { agentId, agentState, conversationId, - loadingState, + loadingState: appLoadingState, continueSession: isResumingSession, startupApproval: resumeData?.pendingApproval ?? null, startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY, @@ -1483,7 +1567,7 @@ async function main(): Promise { render( React.createElement(LoadingApp, { - continueSession: shouldResume, + continueSession: shouldContinue, forceNew: forceNew, initBlocks: initBlocks, baseTools: baseTools, diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 3d60ae5..2b9da39 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -81,6 +81,7 @@ export interface SystemInitMessage extends MessageEnvelope { type: "system"; subtype: "init"; agent_id: string; + conversation_id: string; model: string; tools: string[]; cwd: string; @@ -217,6 +218,7 @@ export interface ResultMessage extends MessageEnvelope { type: "result"; subtype: ResultSubtype; agent_id: string; + conversation_id: string; duration_ms: number; duration_api_ms: number; num_turns: number;