/** * Profile selection flow - runs before main app starts * Similar pattern to auth/setup.ts */ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import { Box, useInput } from "ink"; import React, { useCallback, useEffect, useState } from "react"; import { getClient } from "../agent/client"; import { settingsManager } from "../settings-manager"; import { colors } from "./components/colors"; import { Text } from "./components/Text"; import { WelcomeScreen } from "./components/WelcomeScreen"; interface ProfileOption { name: string | null; agentId: string; isLocal: boolean; isLru: boolean; agent: AgentState | null; } interface ProfileSelectionResult { type: "select" | "new" | "new_with_model" | "exit"; agentId?: string; profileName?: string | null; model?: string; } const MAX_DISPLAY = 3; const MAX_VISIBLE_MODELS = 8; const MODEL_SEARCH_THRESHOLD = 10; // Show search input when more than this many models 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); 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 `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? "" : "s"} ago`; } function formatModel(agent: AgentState): string { if (agent.model) { const parts = agent.model.split("/"); return parts[parts.length - 1] || agent.model; } return agent.llm_config?.model || "unknown"; } function getLabel(option: ProfileOption, freshRepoMode?: boolean): string { const parts: string[] = []; if (option.isLru) parts.push("last used"); if (option.isLocal) parts.push("pinned"); else if (!option.isLru && !freshRepoMode) parts.push("global"); // Pinned globally but not locally return parts.length > 0 ? ` (${parts.join(", ")})` : ""; } function ProfileSelectionUI({ lruAgentId, externalLoading, externalFreshRepoMode, failedAgentMessage, serverModelsForNewAgent, defaultModelHandle: _defaultModelHandle, serverBaseUrl, onComplete, }: { lruAgentId: string | null; externalLoading?: boolean; externalFreshRepoMode?: boolean; failedAgentMessage?: string; serverModelsForNewAgent?: string[]; defaultModelHandle?: string; serverBaseUrl?: string; onComplete: (result: ProfileSelectionResult) => void; }) { const [options, setOptions] = useState([]); const [internalLoading, setInternalLoading] = useState(true); const loading = externalLoading || internalLoading; const [selectedIndex, setSelectedIndex] = useState(0); const [showAll, setShowAll] = useState(false); // Model selection mode for self-hosted servers // Start in model selection mode if serverModelsForNewAgent is provided and no agents to show const [selectingModel, setSelectingModel] = useState( !!(serverModelsForNewAgent && serverModelsForNewAgent.length > 0), ); const [modelSelectedIndex, setModelSelectedIndex] = useState(0); const [modelSearchQuery, setModelSearchQuery] = useState(""); const loadOptions = useCallback(async () => { setInternalLoading(true); try { const mergedPinned = settingsManager.getMergedPinnedAgents(); const client = await getClient(); const optionsToFetch: ProfileOption[] = []; const seenAgentIds = new Set(); // First: LRU agent if (lruAgentId) { const matchingPinned = mergedPinned.find( (p) => p.agentId === lruAgentId, ); optionsToFetch.push({ name: null, // Will be fetched from server agentId: lruAgentId, isLocal: matchingPinned?.isLocal || false, isLru: true, agent: null, }); seenAgentIds.add(lruAgentId); } // Then: Other pinned agents for (const pinned of mergedPinned) { if (!seenAgentIds.has(pinned.agentId)) { optionsToFetch.push({ name: null, // Will be fetched from server agentId: pinned.agentId, isLocal: pinned.isLocal, isLru: false, agent: null, }); seenAgentIds.add(pinned.agentId); } } // Fetch agent data const fetchedOptions = await Promise.all( optionsToFetch.map(async (opt) => { try { const agent = await client.agents.retrieve(opt.agentId, { include: ["agent.blocks"], }); return { ...opt, agent }; } catch { return { ...opt, agent: null }; } }), ); setOptions(fetchedOptions.filter((opt) => opt.agent !== null)); } catch { setOptions([]); } finally { setInternalLoading(false); } }, [lruAgentId]); useEffect(() => { loadOptions(); }, [loadOptions]); const displayOptions = showAll ? options : options.slice(0, MAX_DISPLAY); const hasMore = options.length > MAX_DISPLAY; const totalItems = displayOptions.length + 1 + (hasMore && !showAll ? 1 : 0); // Model selection - filter out legacy models and apply search const allServerModels = serverModelsForNewAgent?.filter((h) => h !== "letta/letta-free") ?? []; const showModelSearch = allServerModels.length > MODEL_SEARCH_THRESHOLD; const filteredModels = modelSearchQuery ? allServerModels.filter((h) => h.toLowerCase().includes(modelSearchQuery.toLowerCase()), ) : allServerModels; const modelCount = filteredModels.length; // Model selection scrolling const modelStartIndex = Math.max( 0, Math.min( modelSelectedIndex - MAX_VISIBLE_MODELS + 1, modelCount - MAX_VISIBLE_MODELS, ), ); const visibleModels = filteredModels.slice( modelStartIndex, modelStartIndex + MAX_VISIBLE_MODELS, ); const showModelScrollDown = modelStartIndex + MAX_VISIBLE_MODELS < modelCount; const modelsBelow = modelCount - modelStartIndex - MAX_VISIBLE_MODELS; useInput((_input, key) => { if (loading) return; // Model selection mode if (selectingModel && serverModelsForNewAgent) { if (key.upArrow) { setModelSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { setModelSelectedIndex((prev) => Math.min(filteredModels.length - 1, prev + 1), ); } else if (key.return) { const selected = filteredModels[modelSelectedIndex]; if (selected) { onComplete({ type: "new_with_model", model: selected }); } } else if (key.escape || (key.ctrl && _input === "c")) { // Go back to agent selection or exit if (options.length > 0) { setSelectingModel(false); setModelSearchQuery(""); setModelSelectedIndex(0); } else { onComplete({ type: "exit" }); } } else if (key.backspace || key.delete) { // Handle backspace for search if (showModelSearch && modelSearchQuery.length > 0) { setModelSearchQuery((prev) => prev.slice(0, -1)); setModelSelectedIndex(0); } } else if ( showModelSearch && _input && _input.length === 1 && !key.ctrl && !key.meta ) { // Handle typing for search setModelSearchQuery((prev) => prev + _input); setModelSelectedIndex(0); } return; } // Agent selection mode if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1)); } else if (key.return) { if (selectedIndex < displayOptions.length) { const selected = displayOptions[selectedIndex]; if (selected) { onComplete({ type: "select", agentId: selected.agentId, profileName: selected.name, }); } } else if ( hasMore && !showAll && selectedIndex === displayOptions.length ) { setShowAll(true); setSelectedIndex(0); } else { // "Create new agent" selected if (serverModelsForNewAgent && serverModelsForNewAgent.length > 0) { // Need to pick a model first setSelectingModel(true); setModelSelectedIndex(0); } else { onComplete({ type: "new" }); } } } else if (key.escape || (key.ctrl && _input === "c")) { onComplete({ type: "exit" }); } }); const hasLocalDir = settingsManager.hasLocalLettaDir(); const contextMessage = externalFreshRepoMode ? `${options.length} pinned agent${options.length !== 1 ? "s" : ""} available.` : hasLocalDir ? "Existing `.letta` folder detected." : `${options.length} agent profile${options.length !== 1 ? "s" : ""} detected.`; return ( {/* Welcome Screen */} {failedAgentMessage && ( <> {failedAgentMessage} )} {loading ? ( Loading pinned agents... ) : selectingModel && serverModelsForNewAgent ? ( // Model selection mode Select a model {showModelSearch && ( Search: {modelSearchQuery || ""} )} {allServerModels.length === 0 ? ( No models found on server. Server: {serverBaseUrl || "unknown"} Did you remember to start the server with your LLM API keys? ) : filteredModels.length === 0 ? ( No models matching "{modelSearchQuery}" ) : ( {visibleModels.map((handle, index) => { const actualIndex = modelStartIndex + index; const isSelected = actualIndex === modelSelectedIndex; return ( {isSelected ? "> " : " "} {handle} ); })} {/* Phantom space or scroll indicator - always reserve the line */} {showModelScrollDown ? ( ↓ {modelsBelow} more ) : modelCount > MAX_VISIBLE_MODELS ? ( ) : null} )} ↑↓ navigate · Enter select {showModelSearch ? " · Type to search" : ""} · Esc{" "} {options.length > 0 ? "back" : "exit"} ) : ( // Agent selection mode {contextMessage} {options.length > 0 && ( Which agent would you like to use? )} {displayOptions.map((option, index) => { const isSelected = index === selectedIndex; const displayName = option.agent?.name || option.agentId.slice(0, 20); const label = getLabel(option, externalFreshRepoMode); return ( {isSelected ? "> " : " "} Resume{" "} {displayName} {label} {option.agent && ( {formatRelativeTime(option.agent.last_run_completion)} ·{" "} {option.agent.memory?.blocks?.length || 0} memory blocks · {formatModel(option.agent)} )} ); })} {hasMore && !showAll && ( {selectedIndex === displayOptions.length ? "> " : " "} View all {options.length} profiles )} {selectedIndex === totalItems - 1 ? "> " : " "} Create a new agent (--new) ↑↓ navigate · Enter select · Esc exit )} ); } /** * Inline profile selection component - used within LoadingApp */ export function ProfileSelectionInline({ lruAgentId, loading: externalLoading, freshRepoMode, failedAgentMessage, serverModelsForNewAgent, defaultModelHandle, serverBaseUrl, onSelect, onCreateNew, onCreateNewWithModel, onExit, }: { lruAgentId: string | null; loading?: boolean; freshRepoMode?: boolean; failedAgentMessage?: string; /** If provided, show model selector when user clicks "Create new" */ serverModelsForNewAgent?: string[]; /** The default model handle that wasn't available */ defaultModelHandle?: string; /** The server base URL for error messages */ serverBaseUrl?: string; onSelect: (agentId: string) => void; onCreateNew: () => void; /** Called when user selects a model from serverModelsForNewAgent */ onCreateNewWithModel?: (model: string) => void; onExit: () => void; }) { const handleComplete = (result: ProfileSelectionResult) => { if (result.type === "exit") { onExit(); } else if (result.type === "select" && result.agentId) { onSelect(result.agentId); } else if (result.type === "new_with_model" && result.model) { onCreateNewWithModel?.(result.model); } else { onCreateNew(); } }; return React.createElement(ProfileSelectionUI, { lruAgentId, externalLoading, externalFreshRepoMode: freshRepoMode, failedAgentMessage, serverModelsForNewAgent, defaultModelHandle, serverBaseUrl, onComplete: handleComplete, }); } /** * Check if profile selection is needed */ export async function shouldShowProfileSelection( forceNew: boolean, agentIdArg: string | null, fromAfFile: string | undefined, ): Promise<{ show: boolean; lruAgentId: string | null }> { // Skip for explicit flags if (forceNew || agentIdArg || fromAfFile) { return { show: false, lruAgentId: null }; } // Load settings await settingsManager.loadLocalProjectSettings(); const localSettings = settingsManager.getLocalProjectSettings(); const globalProfiles = settingsManager.getGlobalProfiles(); const localProfiles = localSettings.profiles || {}; const hasProfiles = Object.keys(globalProfiles).length > 0 || Object.keys(localProfiles).length > 0; const lru = localSettings.lastAgent || null; // Show selector if there are choices return { show: hasProfiles || !!lru, lruAgentId: lru, }; }