From 2120a4787ba95d04e75c002c75ce20cbac44053e Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 15 Jan 2026 15:27:41 -0800 Subject: [PATCH] feat: improve interactive menu styling (#553) Co-authored-by: Letta --- src/cli/App.tsx | 20 +- src/cli/components/AgentInfoBar.tsx | 45 +- src/cli/components/AgentSelector.tsx | 896 +++++++++++++++--- src/cli/components/Autocomplete.tsx | 18 +- src/cli/components/ConversationSelector.tsx | 79 +- src/cli/components/InputRich.tsx | 8 + src/cli/components/McpSelector.tsx | 150 +-- src/cli/components/MemoryTabViewer.tsx | 216 +++++ src/cli/components/MemoryViewer.tsx | 316 ------ src/cli/components/ModelSelector.tsx | 218 +++-- src/cli/components/ProfileSelector.tsx | 384 -------- src/cli/components/ResumeSelector.tsx | 789 --------------- .../components/SlashCommandAutocomplete.tsx | 24 +- src/cli/components/SystemPromptSelector.tsx | 56 +- src/cli/components/ToolsetSelector.tsx | 68 +- 15 files changed, 1372 insertions(+), 1915 deletions(-) create mode 100644 src/cli/components/MemoryTabViewer.tsx delete mode 100644 src/cli/components/MemoryViewer.tsx delete mode 100644 src/cli/components/ProfileSelector.tsx delete mode 100644 src/cli/components/ResumeSelector.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 2bbe277..803014f 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -91,7 +91,7 @@ import { FeedbackDialog } from "./components/FeedbackDialog"; import { HelpDialog } from "./components/HelpDialog"; import { Input } from "./components/InputRich"; import { McpSelector } from "./components/McpSelector"; -import { MemoryViewer } from "./components/MemoryViewer"; +import { MemoryTabViewer } from "./components/MemoryTabViewer"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; import { NewAgentDialog } from "./components/NewAgentDialog"; @@ -99,7 +99,7 @@ import { PendingApprovalStub } from "./components/PendingApprovalStub"; import { PinDialog, validateAgentName } from "./components/PinDialog"; // QuestionDialog removed - now using InlineQuestionApproval import { ReasoningMessage } from "./components/ReasoningMessageRich"; -import { ResumeSelector } from "./components/ResumeSelector"; + import { formatUsageStats } from "./components/SessionStats"; // InlinePlanApproval kept for easy rollback if needed // import { InlinePlanApproval } from "./components/InlinePlanApproval"; @@ -7393,23 +7393,14 @@ Plan file path: ${planFilePath}`; /> )} - {/* Agent Selector - conditionally mounted as overlay */} - {activeOverlay === "agent" && ( - - )} - {/* Subagent Manager - for managing custom subagents */} {activeOverlay === "subagent" && ( )} - {/* Resume Selector - conditionally mounted as overlay */} + {/* Agent Selector - for browsing/selecting agents */} {activeOverlay === "resume" && ( - { closeOverlay(); @@ -7709,10 +7700,9 @@ Plan file path: ${planFilePath}`; {/* Memory Viewer - conditionally mounted as overlay */} {activeOverlay === "memory" && ( - diff --git a/src/cli/components/AgentInfoBar.tsx b/src/cli/components/AgentInfoBar.tsx index ba10434..ee2f571 100644 --- a/src/cli/components/AgentInfoBar.tsx +++ b/src/cli/components/AgentInfoBar.tsx @@ -3,6 +3,7 @@ import Link from "ink-link"; import { memo, useMemo } from "react"; import { DEFAULT_AGENT_NAME } from "../../constants"; import { settingsManager } from "../../settings-manager"; +import { getVersion } from "../../version"; import { colors } from "./colors"; interface AgentInfoBarProps { @@ -37,14 +38,34 @@ export const AgentInfoBar = memo(function AgentInfoBar({ } return ( - + + {/* Blank line after commands */} + + + {/* Discord/version info */} - {agentName || "Unnamed"} + + {" "}Having issues? Report bugs with /feedback or{" "} + + join our Discord ↗ + + + + + + {" "}Version: Letta Code v{getVersion()} + + + + {/* Blank line before agent info */} + + + {/* Agent name and links */} + + {" "} + + {agentName || "Unnamed"} + {isPinned ? ( (pinned ✓) ) : agentName === DEFAULT_AGENT_NAME || !agentName ? ( @@ -55,21 +76,21 @@ export const AgentInfoBar = memo(function AgentInfoBar({ · {agentId} + {" "} {isCloudUser && ( - Open in ADE ↗ + Open in ADE ↗ )} - - + {isCloudUser && {" · "}} {isCloudUser && ( - View usage ↗ + View usage ↗ )} - {!isCloudUser && · {serverUrl}} + {!isCloudUser && {serverUrl}} ); diff --git a/src/cli/components/AgentSelector.tsx b/src/cli/components/AgentSelector.tsx index 31742e4..d8e125a 100644 --- a/src/cli/components/AgentSelector.tsx +++ b/src/cli/components/AgentSelector.tsx @@ -1,190 +1,818 @@ +import type { Letta } from "@letta-ai/letta-client"; import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import { Box, Text, useInput } from "ink"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getClient } from "../../agent/client"; +import { getModelDisplayName } from "../../agent/model"; +import { settingsManager } from "../../settings-manager"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; +import { MarkdownDisplay } from "./MarkdownDisplay"; + +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; interface AgentSelectorProps { currentAgentId: string; onSelect: (agentId: string) => void; onCancel: () => void; + /** The command that triggered this selector (e.g., "/agents" or "/resume") */ + command?: string; +} + +type TabId = "pinned" | "letta-code" | "all"; + +interface PinnedAgentData { + agentId: string; + agent: AgentState | null; + error: string | null; + isLocal: boolean; +} + +const TABS: { id: TabId; label: string }[] = [ + { id: "pinned", label: "Pinned" }, + { id: "letta-code", label: "Letta Code" }, + { id: "all", label: "All" }, +]; + +const TAB_DESCRIPTIONS: Record = { + pinned: "Save agents for easy access by pinning them with /pin", + "letta-code": "Displaying agents created inside of Letta Code", + all: "Displaying all available agents", +}; + +const TAB_EMPTY_STATES: Record = { + pinned: "No pinned agents, use /pin to save", + "letta-code": "No agents with tag 'origin:letta-code'", + all: "No agents found", +}; + +const DISPLAY_PAGE_SIZE = 5; +const FETCH_PAGE_SIZE = 20; + +/** + * 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 friendly display name (e.g., "Sonnet 4.5") + */ +function formatModel(agent: AgentState): string { + // Build handle from agent config + let handle: string | null = null; + if (agent.model) { + handle = agent.model; + } else if (agent.llm_config?.model) { + const provider = agent.llm_config.model_endpoint_type || "unknown"; + handle = `${provider}/${agent.llm_config.model}`; + } + + if (handle) { + // Try to get friendly display name + const displayName = getModelDisplayName(handle); + if (displayName) return displayName; + // Fallback to handle + return handle; + } + return "unknown"; } export function AgentSelector({ currentAgentId, onSelect, onCancel, + command = "/agents", }: AgentSelectorProps) { - const [agents, setAgents] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedIndex, setSelectedIndex] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(""); + const terminalWidth = useTerminalWidth(); + const clientRef = useRef(null); - useEffect(() => { - const fetchAgents = async () => { - try { - const client = await getClient(); - const agentList = await client.agents.list(); - setAgents(agentList.items); - setLoading(false); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); + // Tab state + const [activeTab, setActiveTab] = useState("pinned"); + + // Pinned tab state + const [pinnedAgents, setPinnedAgents] = useState([]); + const [pinnedLoading, setPinnedLoading] = useState(true); + const [pinnedSelectedIndex, setPinnedSelectedIndex] = useState(0); + const [pinnedPage, setPinnedPage] = useState(0); + + // Letta Code tab state (cached separately) + const [lettaCodeAgents, setLettaCodeAgents] = useState([]); + const [lettaCodeCursor, setLettaCodeCursor] = useState(null); + const [lettaCodeLoading, setLettaCodeLoading] = useState(false); + const [lettaCodeLoadingMore, setLettaCodeLoadingMore] = useState(false); + const [lettaCodeHasMore, setLettaCodeHasMore] = useState(true); + const [lettaCodeSelectedIndex, setLettaCodeSelectedIndex] = useState(0); + const [lettaCodePage, setLettaCodePage] = useState(0); + const [lettaCodeError, setLettaCodeError] = useState(null); + const [lettaCodeLoaded, setLettaCodeLoaded] = useState(false); + const [lettaCodeQuery, setLettaCodeQuery] = useState(""); // Query used to load current data + + // All tab state (cached separately) + const [allAgents, setAllAgents] = useState([]); + const [allCursor, setAllCursor] = useState(null); + const [allLoading, setAllLoading] = useState(false); + const [allLoadingMore, setAllLoadingMore] = useState(false); + const [allHasMore, setAllHasMore] = useState(true); + const [allSelectedIndex, setAllSelectedIndex] = useState(0); + const [allPage, setAllPage] = useState(0); + const [allError, setAllError] = useState(null); + const [allLoaded, setAllLoaded] = useState(false); + const [allQuery, setAllQuery] = useState(""); // Query used to load current data + + // Search state (shared across list tabs) + const [searchInput, setSearchInput] = useState(""); + const [activeQuery, setActiveQuery] = useState(""); + + // Load pinned agents + const loadPinnedAgents = useCallback(async () => { + setPinnedLoading(true); + try { + const mergedPinned = settingsManager.getMergedPinnedAgents(); + + if (mergedPinned.length === 0) { + setPinnedAgents([]); + setPinnedLoading(false); + return; } - }; - fetchAgents(); + + const client = clientRef.current || (await getClient()); + clientRef.current = client; + + const pinnedData = await Promise.all( + mergedPinned.map(async ({ agentId, isLocal }) => { + try { + const agent = await client.agents.retrieve(agentId, { + include: ["agent.blocks"], + }); + return { agentId, agent, error: null, isLocal }; + } catch { + return { agentId, agent: null, error: "Agent not found", isLocal }; + } + }), + ); + + setPinnedAgents(pinnedData); + } catch { + setPinnedAgents([]); + } finally { + setPinnedLoading(false); + } }, []); - // Debounce search query (300ms delay) + // Fetch agents for list tabs (Letta Code / All) + const fetchListAgents = useCallback( + async ( + filterLettaCode: boolean, + afterCursor?: string | null, + query?: string, + ) => { + const client = clientRef.current || (await getClient()); + clientRef.current = client; + + const agentList = await client.agents.list({ + limit: FETCH_PAGE_SIZE, + ...(filterLettaCode && { tags: ["origin:letta-code"] }), + include: ["agent.blocks"], + order: "desc", + order_by: "last_run_completion", + ...(afterCursor && { after: afterCursor }), + ...(query && { query_text: query }), + }); + + const cursor = + agentList.items.length === FETCH_PAGE_SIZE + ? (agentList.items[agentList.items.length - 1]?.id ?? null) + : null; + + return { agents: agentList.items, nextCursor: cursor }; + }, + [], + ); + + // Load Letta Code agents + const loadLettaCodeAgents = useCallback( + async (query?: string) => { + setLettaCodeLoading(true); + setLettaCodeError(null); + try { + const result = await fetchListAgents(true, null, query); + setLettaCodeAgents(result.agents); + setLettaCodeCursor(result.nextCursor); + setLettaCodeHasMore(result.nextCursor !== null); + setLettaCodePage(0); + setLettaCodeSelectedIndex(0); + setLettaCodeLoaded(true); + setLettaCodeQuery(query || ""); // Track query used for this load + } catch (err) { + setLettaCodeError(err instanceof Error ? err.message : String(err)); + } finally { + setLettaCodeLoading(false); + } + }, + [fetchListAgents], + ); + + // Load All agents + const loadAllAgents = useCallback( + async (query?: string) => { + setAllLoading(true); + setAllError(null); + try { + const result = await fetchListAgents(false, null, query); + setAllAgents(result.agents); + setAllCursor(result.nextCursor); + setAllHasMore(result.nextCursor !== null); + setAllPage(0); + setAllSelectedIndex(0); + setAllLoaded(true); + setAllQuery(query || ""); // Track query used for this load + } catch (err) { + setAllError(err instanceof Error ? err.message : String(err)); + } finally { + setAllLoading(false); + } + }, + [fetchListAgents], + ); + + // Load pinned agents on mount useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQuery(searchQuery); - }, 300); + loadPinnedAgents(); + }, [loadPinnedAgents]); - return () => clearTimeout(timer); - }, [searchQuery]); - - // Filter agents based on debounced search query - const matchingAgents = 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); - }); - - const filteredAgents = matchingAgents.slice(0, 10); - - // Reset selected index when filtered list changes + // Load tab data when switching tabs (only if not already loaded) useEffect(() => { - setSelectedIndex(0); - }, []); + if (activeTab === "letta-code" && !lettaCodeLoaded && !lettaCodeLoading) { + loadLettaCodeAgents(); + } else if (activeTab === "all" && !allLoaded && !allLoading) { + loadAllAgents(); + } + }, [ + activeTab, + lettaCodeLoaded, + lettaCodeLoading, + loadLettaCodeAgents, + allLoaded, + allLoading, + loadAllAgents, + ]); + + // Reload current tab when search query changes (only if query differs from cached) + useEffect(() => { + if (activeTab === "letta-code" && activeQuery !== lettaCodeQuery) { + loadLettaCodeAgents(activeQuery || undefined); + } else if (activeTab === "all" && activeQuery !== allQuery) { + loadAllAgents(activeQuery || undefined); + } + }, [ + activeQuery, + activeTab, + lettaCodeQuery, + allQuery, + loadLettaCodeAgents, + loadAllAgents, + ]); + + // Fetch more Letta Code agents + const fetchMoreLettaCodeAgents = useCallback(async () => { + if (lettaCodeLoadingMore || !lettaCodeHasMore || !lettaCodeCursor) return; + + setLettaCodeLoadingMore(true); + try { + const result = await fetchListAgents( + true, + lettaCodeCursor, + activeQuery || undefined, + ); + setLettaCodeAgents((prev) => [...prev, ...result.agents]); + setLettaCodeCursor(result.nextCursor); + setLettaCodeHasMore(result.nextCursor !== null); + } catch { + // Silently fail on pagination errors + } finally { + setLettaCodeLoadingMore(false); + } + }, [ + lettaCodeLoadingMore, + lettaCodeHasMore, + lettaCodeCursor, + fetchListAgents, + activeQuery, + ]); + + // Fetch more All agents + const fetchMoreAllAgents = useCallback(async () => { + if (allLoadingMore || !allHasMore || !allCursor) return; + + setAllLoadingMore(true); + try { + const result = await fetchListAgents( + false, + allCursor, + activeQuery || undefined, + ); + setAllAgents((prev) => [...prev, ...result.agents]); + setAllCursor(result.nextCursor); + setAllHasMore(result.nextCursor !== null); + } catch { + // Silently fail on pagination errors + } finally { + setAllLoadingMore(false); + } + }, [allLoadingMore, allHasMore, allCursor, fetchListAgents, activeQuery]); + + // Pagination calculations - Pinned + const pinnedTotalPages = Math.ceil(pinnedAgents.length / DISPLAY_PAGE_SIZE); + const pinnedStartIndex = pinnedPage * DISPLAY_PAGE_SIZE; + const pinnedPageAgents = pinnedAgents.slice( + pinnedStartIndex, + pinnedStartIndex + DISPLAY_PAGE_SIZE, + ); + + // Pagination calculations - Letta Code + const lettaCodeTotalPages = Math.ceil( + lettaCodeAgents.length / DISPLAY_PAGE_SIZE, + ); + const lettaCodeStartIndex = lettaCodePage * DISPLAY_PAGE_SIZE; + const lettaCodePageAgents = lettaCodeAgents.slice( + lettaCodeStartIndex, + lettaCodeStartIndex + DISPLAY_PAGE_SIZE, + ); + const lettaCodeCanGoNext = + lettaCodePage < lettaCodeTotalPages - 1 || lettaCodeHasMore; + + // Pagination calculations - All + const allTotalPages = Math.ceil(allAgents.length / DISPLAY_PAGE_SIZE); + const allStartIndex = allPage * DISPLAY_PAGE_SIZE; + const allPageAgents = allAgents.slice( + allStartIndex, + allStartIndex + DISPLAY_PAGE_SIZE, + ); + const allCanGoNext = allPage < allTotalPages - 1 || allHasMore; + + // Current tab's state (computed) + const currentLoading = + activeTab === "pinned" + ? pinnedLoading + : activeTab === "letta-code" + ? lettaCodeLoading + : allLoading; + const currentError = + activeTab === "letta-code" + ? lettaCodeError + : activeTab === "all" + ? allError + : null; + const currentAgents = + activeTab === "pinned" + ? pinnedPageAgents.map((p) => p.agent).filter(Boolean) + : activeTab === "letta-code" + ? lettaCodePageAgents + : allPageAgents; + const setCurrentSelectedIndex = + activeTab === "pinned" + ? setPinnedSelectedIndex + : activeTab === "letta-code" + ? setLettaCodeSelectedIndex + : setAllSelectedIndex; + + // Submit search + const submitSearch = useCallback(() => { + if (searchInput !== activeQuery) { + setActiveQuery(searchInput); + } + }, [searchInput, activeQuery]); + + // Clear search (effect will handle reload when query changes) + const clearSearch = useCallback(() => { + setSearchInput(""); + if (activeQuery) { + setActiveQuery(""); + } + }, [activeQuery]); useInput((input, key) => { - // CTRL-C: immediately cancel (works even during loading/error) + // CTRL-C: immediately cancel if (key.ctrl && input === "c") { onCancel(); return; } - if (loading || error) return; + // Tab key cycles through tabs + if (key.tab) { + const currentIndex = TABS.findIndex((t) => t.id === activeTab); + const nextIndex = (currentIndex + 1) % TABS.length; + setActiveTab(TABS[nextIndex]?.id ?? "pinned"); + return; + } + + if (currentLoading) return; + + // For pinned tab, use pinnedPageAgents.length to include "not found" entries + // For other tabs, use currentAgents.length + const maxIndex = + activeTab === "pinned" + ? pinnedPageAgents.length - 1 + : (currentAgents as AgentState[]).length - 1; if (key.upArrow) { - setSelectedIndex((prev) => Math.max(0, prev - 1)); + setCurrentSelectedIndex((prev: number) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex((prev) => Math.min(filteredAgents.length - 1, prev + 1)); + setCurrentSelectedIndex((prev: number) => Math.min(maxIndex, prev + 1)); } else if (key.return) { - const selectedAgent = filteredAgents[selectedIndex]; - if (selectedAgent?.id) { - onSelect(selectedAgent.id); + // If typing a search query (list tabs only), submit it + if ( + activeTab !== "pinned" && + searchInput && + searchInput !== activeQuery + ) { + submitSearch(); + return; + } + + // Select agent + if (activeTab === "pinned") { + const selected = pinnedPageAgents[pinnedSelectedIndex]; + if (selected?.agent) { + onSelect(selected.agentId); + } + } else if (activeTab === "letta-code") { + const selected = lettaCodePageAgents[lettaCodeSelectedIndex]; + if (selected?.id) { + onSelect(selected.id); + } + } else { + const selected = allPageAgents[allSelectedIndex]; + if (selected?.id) { + onSelect(selected.id); + } } } else if (key.escape) { + // If typing search (list tabs), clear it first + if (activeTab !== "pinned" && searchInput) { + clearSearch(); + return; + } onCancel(); } else if (key.backspace || key.delete) { - setSearchQuery((prev) => prev.slice(0, -1)); - } else if (input && !key.ctrl && !key.meta) { - // Add regular characters to search query - setSearchQuery((prev) => prev + input); + if (activeTab !== "pinned") { + setSearchInput((prev) => prev.slice(0, -1)); + } + } else if (key.leftArrow) { + // Previous page + if (activeTab === "pinned") { + if (pinnedPage > 0) { + setPinnedPage((prev) => prev - 1); + setPinnedSelectedIndex(0); + } + } else if (activeTab === "letta-code") { + if (lettaCodePage > 0) { + setLettaCodePage((prev) => prev - 1); + setLettaCodeSelectedIndex(0); + } + } else { + if (allPage > 0) { + setAllPage((prev) => prev - 1); + setAllSelectedIndex(0); + } + } + } else if (key.rightArrow) { + // Next page + if (activeTab === "pinned") { + if (pinnedPage < pinnedTotalPages - 1) { + setPinnedPage((prev) => prev + 1); + setPinnedSelectedIndex(0); + } + } else if (activeTab === "letta-code" && lettaCodeCanGoNext) { + const nextPageIndex = lettaCodePage + 1; + const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE; + + if (nextStartIndex >= lettaCodeAgents.length && lettaCodeHasMore) { + fetchMoreLettaCodeAgents(); + } + + if (nextStartIndex < lettaCodeAgents.length) { + setLettaCodePage(nextPageIndex); + setLettaCodeSelectedIndex(0); + } + } else if (activeTab === "all" && allCanGoNext) { + const nextPageIndex = allPage + 1; + const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE; + + if (nextStartIndex >= allAgents.length && allHasMore) { + fetchMoreAllAgents(); + } + + if (nextStartIndex < allAgents.length) { + setAllPage(nextPageIndex); + setAllSelectedIndex(0); + } + } + // NOTE: "D" for unpin all disabled - too destructive without confirmation + // } else if (activeTab === "pinned" && (input === "d" || input === "D")) { + // const selected = pinnedPageAgents[pinnedSelectedIndex]; + // if (selected) { + // settingsManager.unpinBoth(selected.agentId); + // loadPinnedAgents(); + // } + // } + } else if (activeTab === "pinned" && (input === "p" || input === "P")) { + // Unpin from current scope (pinned tab only) + const selected = pinnedPageAgents[pinnedSelectedIndex]; + if (selected) { + if (selected.isLocal) { + settingsManager.unpinLocal(selected.agentId); + } else { + settingsManager.unpinGlobal(selected.agentId); + } + loadPinnedAgents(); + } + } else if (activeTab !== "pinned" && input && !key.ctrl && !key.meta) { + // Type to search (list tabs only) + setSearchInput((prev) => prev + input); } }); - if (loading) { + // Render tab bar + const renderTabBar = () => ( + + {TABS.map((tab) => { + const isActive = tab.id === activeTab; + // Always use same width (with padding) to prevent jitter when switching tabs + return ( + + {` ${tab.label} `} + + ); + })} + + ); + + // Render agent item (shared between tabs) + const renderAgentItem = ( + agent: AgentState, + _index: number, + isSelected: boolean, + extra?: { isLocal?: boolean }, + ) => { + const isCurrent = agent.id === currentAgentId; + const relativeTime = formatRelativeTime(agent.last_run_completion); + const blockCount = agent.blocks?.length ?? 0; + const modelStr = formatModel(agent); + + const nameLen = (agent.name || "Unnamed").length; + const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); + const availableForId = Math.max(15, terminalWidth - nameLen - fixedChars); + const displayId = truncateAgentId(agent.id, availableForId); + 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 ( - - - - Select Agent (↑↓ to navigate, Enter to select, ESC to cancel) - - - - - Search: - {searchQuery || "_"} - - - {filteredAgents.length === 0 && ( - - No agents match your search - - )} - - {filteredAgents.length > 0 && ( - + + + + {isSelected ? ">" : " "} + + + + {agent.name || "Unnamed"} + - Showing {filteredAgents.length} - {matchingAgents.length > 10 ? ` of ${matchingAgents.length}` : ""} - {debouncedQuery ? " matching" : ""} agents + {" · "} + {extra?.isLocal !== undefined + ? `${extra.isLocal ? "project" : "global"} · ` + : ""} + {displayId} + + {isCurrent && ( + (current) + )} + + + + {agent.description || "No description"} + + + {relativeTime} · {blockCount} memory block + {blockCount === 1 ? "" : "s"} · {modelStr} + + + + ); + }; + + // Render pinned agent item (may have error) + const renderPinnedItem = ( + data: PinnedAgentData, + index: number, + isSelected: boolean, + ) => { + if (data.agent) { + return renderAgentItem(data.agent, index, isSelected, { + isLocal: data.isLocal, + }); + } + + // Error state for missing agent + return ( + + + + {isSelected ? ">" : " "} + + + + {data.agentId.slice(0, 12)} + + · {data.isLocal ? "project" : "global"} + + + + {data.error} + + + + ); + }; + + // Calculate horizontal line width + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + + return ( + + {/* Command header */} + {`> ${command}`} + {solidLine} + + + + {/* Header */} + + + Swap to a different agent + + + {renderTabBar()} + {TAB_DESCRIPTIONS[activeTab]} + + + + {/* Search input - list tabs only */} + {activeTab !== "pinned" && (searchInput || activeQuery) && ( + + Search: + {searchInput} + {searchInput && searchInput !== activeQuery && ( + (press Enter to search) + )} + {activeQuery && searchInput === activeQuery && ( + (Esc to clear) + )} + )} - - {filteredAgents.map((agent, index) => { - const isSelected = index === selectedIndex; - const isCurrent = agent.id === currentAgentId; + {/* Error state - list tabs */} + {activeTab !== "pinned" && currentError && ( + + Error: {currentError} + Press ESC to cancel + + )} - const lastInteractedAt = agent.last_run_completion - ? new Date(agent.last_run_completion).toLocaleString() - : "Never"; + {/* Loading state */} + {currentLoading && ( + + {" "}Loading agents... + + )} + + {/* Empty state */} + {!currentLoading && + ((activeTab === "pinned" && pinnedAgents.length === 0) || + (activeTab === "letta-code" && + !lettaCodeError && + lettaCodeAgents.length === 0) || + (activeTab === "all" && !allError && allAgents.length === 0)) && ( + + {TAB_EMPTY_STATES[activeTab]} + Press ESC to cancel + + )} + + {/* Pinned tab content */} + {activeTab === "pinned" && !pinnedLoading && pinnedAgents.length > 0 && ( + + {pinnedPageAgents.map((data, index) => + renderPinnedItem(data, index, index === pinnedSelectedIndex), + )} + + )} + + {/* Letta Code tab content */} + {activeTab === "letta-code" && + !lettaCodeLoading && + !lettaCodeError && + lettaCodeAgents.length > 0 && ( + + {lettaCodePageAgents.map((agent, index) => + renderAgentItem(agent, index, index === lettaCodeSelectedIndex), + )} + + )} + + {/* All tab content */} + {activeTab === "all" && + !allLoading && + !allError && + allAgents.length > 0 && ( + + {allPageAgents.map((agent, index) => + renderAgentItem(agent, index, index === allSelectedIndex), + )} + + )} + + {/* Footer */} + {!currentLoading && + ((activeTab === "pinned" && pinnedAgents.length > 0) || + (activeTab === "letta-code" && + !lettaCodeError && + lettaCodeAgents.length > 0) || + (activeTab === "all" && !allError && allAgents.length > 0)) && + (() => { + const footerWidth = Math.max(0, terminalWidth - 2); + const pageText = + activeTab === "pinned" + ? `Page ${pinnedPage + 1}/${pinnedTotalPages || 1}` + : 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`; return ( - - - {isSelected ? "›" : " "} - - - - {agent.name || "Unnamed"} - {isCurrent && ( - (current) - )} - - - {agent.id} - - - {lastInteractedAt} - + + + + + + + + + + + + ); - })} - + })()} ); } diff --git a/src/cli/components/Autocomplete.tsx b/src/cli/components/Autocomplete.tsx index b093d0f..99311de 100644 --- a/src/cli/components/Autocomplete.tsx +++ b/src/cli/components/Autocomplete.tsx @@ -3,8 +3,8 @@ import type { ReactNode } from "react"; import { colors } from "./colors"; interface AutocompleteBoxProps { - /** Header text shown at top of autocomplete */ - header: ReactNode; + /** Optional header text shown at top of autocomplete */ + header?: ReactNode; children: ReactNode; } @@ -14,13 +14,8 @@ interface AutocompleteBoxProps { */ export function AutocompleteBox({ header, children }: AutocompleteBoxProps) { return ( - - {header} + + {header && {header}} {children} ); @@ -35,7 +30,8 @@ interface AutocompleteItemProps { /** * Shared item component for autocomplete lists. - * Handles selection indicator and styling. + * Handles selection styling (color-based, no arrow indicator). + * 2-char gutter aligns with input box prompt. */ export function AutocompleteItem({ selected, @@ -46,7 +42,7 @@ export function AutocompleteItem({ color={selected ? colors.command.selected : undefined} bold={selected} > - {selected ? "▶ " : " "} + {" "} {children} ); diff --git a/src/cli/components/ConversationSelector.tsx b/src/cli/components/ConversationSelector.tsx index 5a5c825..0dbd3e1 100644 --- a/src/cli/components/ConversationSelector.tsx +++ b/src/cli/components/ConversationSelector.tsx @@ -4,7 +4,12 @@ import type { Conversation } from "@letta-ai/letta-client/resources/conversation import { Box, Text, useInput } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; import { getClient } from "../../agent/client"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; +import { MarkdownDisplay } from "./MarkdownDisplay"; + +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; interface ConversationSelectorProps { agentId: string; @@ -333,13 +338,13 @@ export function ConversationSelector({ } else if (input === "n" || input === "N") { // New conversation onNewConversation(); - } else if (input === "j" || input === "J") { + } else if (key.leftArrow) { // Previous page if (page > 0) { setPage((prev) => prev - 1); setSelectedIndex(0); } - } else if (input === "k" || input === "K") { + } else if (key.rightArrow) { // Next page if (canGoNext) { const nextPageIndex = page + 1; @@ -376,14 +381,19 @@ export function ConversationSelector({ const createdTime = formatRelativeTime(conv.created_at); // Build preview content: (1) summary if exists, (2) preview lines, (3) message count fallback + // Uses L-bracket indentation style for visual hierarchy const renderPreview = () => { + const bracket = {"⎿ "}; + const indent = " "; // Same width as "⎿ " for alignment + // Priority 1: Summary if (conv.summary) { return ( + {bracket} - {conv.summary.length > 60 - ? `${conv.summary.slice(0, 57)}...` + {conv.summary.length > 57 + ? `${conv.summary.slice(0, 54)}...` : conv.summary} @@ -400,6 +410,7 @@ export function ConversationSelector({ flexDirection="row" marginLeft={2} > + {idx === 0 ? bracket : {indent}} {line.role === "assistant" ? "👾 " : "👤 "} @@ -416,6 +427,7 @@ export function ConversationSelector({ if (messageCount > 0) { return ( + {bracket} {messageCount} message{messageCount === 1 ? "" : "s"} (no in-context user/agent messages) @@ -426,6 +438,7 @@ export function ConversationSelector({ return ( + {bracket} No in-context messages @@ -462,14 +475,22 @@ export function ConversationSelector({ ); }; + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + return ( - {/* Header */} - + {/* Command header */} + {"> /resume"} + {solidLine} + + + + {/* Title */} + - Resume Conversation + Resume a previous conversation - Select a conversation to resume or start a new one {/* Error state */} @@ -505,22 +526,32 @@ export function ConversationSelector({ )} {/* Footer */} - {!loading && !error && conversations.length > 0 && ( - - - - Page {page + 1} - {hasMore ? "+" : `/${totalPages || 1}`} - {loadingMore ? " (loading...)" : ""} - - - - - ↑↓ navigate · Enter select · J/K page · N new · ESC cancel - - - - )} + {!loading && + !error && + conversations.length > 0 && + (() => { + const footerWidth = Math.max(0, terminalWidth - 2); + const pageText = `Page ${page + 1}${hasMore ? "+" : `/${totalPages || 1}`}${loadingMore ? " (loading...)" : ""}`; + const hintsText = + "Enter select · ↑↓ navigate · ←→ page · N new · Esc cancel"; + + return ( + + + + + + + + + + + + + + + ); + })()} ); } diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 21dd46e..3d14cd6 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -51,6 +51,7 @@ const InputFooter = memo(function InputFooter({ agentName, currentModel, isOpenAICodexProvider, + isAutocompleteActive, }: { ctrlCPressed: boolean; escapePressed: boolean; @@ -61,7 +62,13 @@ const InputFooter = memo(function InputFooter({ agentName: string | null | undefined; currentModel: string | null | undefined; isOpenAICodexProvider: boolean; + isAutocompleteActive: boolean; }) { + // Hide footer when autocomplete is showing + if (isAutocompleteActive) { + return null; + } + return ( {ctrlCPressed ? ( @@ -841,6 +848,7 @@ export function Input({ isOpenAICodexProvider={ currentModelProvider === OPENAI_CODEX_PROVIDER_NAME } + isAutocompleteActive={isAutocompleteActive} /> diff --git a/src/cli/components/McpSelector.tsx b/src/cli/components/McpSelector.tsx index 4d027dd..6643e03 100644 --- a/src/cli/components/McpSelector.tsx +++ b/src/cli/components/McpSelector.tsx @@ -10,6 +10,9 @@ import { getClient } from "../../agent/client"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; + interface McpSelectorProps { agentId: string; onAdd: () => void; @@ -67,6 +70,7 @@ export const McpSelector = memo(function McpSelector({ onCancel, }: McpSelectorProps) { const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [selectedIndex, setSelectedIndex] = useState(0); @@ -444,8 +448,15 @@ export const McpSelector = memo(function McpSelector({ ); return ( - - + + {/* Command header */} + {"> /mcp"} + {solidLine} + + + + {/* Title */} + Tools for {viewingServer.server_name} @@ -455,12 +466,11 @@ export const McpSelector = memo(function McpSelector({ {toolsLoading && ( + {" "} {tools.length > 0 ? "Refreshing tools..." : "Loading tools..."} {tools.length === 0 && ( - - This may take a moment on first load - + {" "}This may take a moment on first load )} )} @@ -468,9 +478,12 @@ export const McpSelector = memo(function McpSelector({ {/* Error state */} {!toolsLoading && toolsError && ( - {toolsError} + + {" "} + {toolsError} + - R refresh from server · Esc back + {" "}R refresh from server · Esc back )} @@ -478,10 +491,12 @@ export const McpSelector = memo(function McpSelector({ {/* Empty state */} {!toolsLoading && !toolsError && tools.length === 0 && ( - No tools available for this server. - Press R to sync tools from the MCP server. + {" "}No tools available for this server. + + {" "}Press R to sync tools from the MCP server. + - R refresh · Esc back + {" "}R refresh · Esc back )} @@ -505,9 +520,8 @@ export const McpSelector = memo(function McpSelector({ isSelected ? colors.selector.itemHighlighted : undefined } > - {isSelected ? ">" : " "} + {isSelected ? "> " : " "} - {/* Row 2: Description */} - - - {truncateText(toolDesc, terminalWidth - 4)} + + + {" "} + {truncateText(toolDesc, terminalWidth - 6)} @@ -546,20 +561,17 @@ export const McpSelector = memo(function McpSelector({ ).length; return ( - - - {toolsTotalPages > 1 && - `Page ${toolsPage + 1}/${toolsTotalPages} · `} - {attachedFromThisServer}/{tools.length} attached from server - · {attachedToolIds.size} total on agent - - - - - ↑↓ navigate · Space/Enter toggle · A attach all · D detach - all · R refresh · Esc back - - + + {" "} + {toolsTotalPages > 1 && + `Page ${toolsPage + 1}/${toolsTotalPages} · `} + {attachedFromThisServer}/{tools.length} attached from server ·{" "} + {attachedToolIds.size} total on agent + + + {" "}Space/Enter toggle · ↑↓ navigate · A attach all · D + detach all · R refresh · Esc back + ); })()} @@ -571,15 +583,24 @@ export const McpSelector = memo(function McpSelector({ if (mode === "confirming-delete" && selectedServer) { const options = ["Yes, delete", "No, cancel"]; return ( - - + + {/* Command header */} + {"> /mcp"} + {solidLine} + + + + {/* Title */} + - Delete MCP Server + Delete MCP server? - - Delete "{selectedServer.server_name}"? - + + + {" "}Delete "{selectedServer.server_name}"? + + {options.map((option, index) => { const isSelected = index === deleteConfirmIndex; @@ -591,7 +612,8 @@ export const McpSelector = memo(function McpSelector({ } bold={isSelected} > - {isSelected ? ">" : " "} {option} + {isSelected ? "> " : " "} + {option} ); @@ -603,26 +625,35 @@ export const McpSelector = memo(function McpSelector({ // Main browsing UI return ( - - + + {/* Command header */} + {"> /mcp"} + {solidLine} + + + + {/* Title */} + - MCP Servers + Manage MCP servers {/* Loading state */} {loading && ( - Loading MCP servers... + {" "}Loading MCP servers... )} {/* Error state */} {!loading && error && ( - Error: {error} + + {" "}Error: {error} + - R refresh · Esc close + {" "}R refresh · Esc cancel )} @@ -630,10 +661,10 @@ export const McpSelector = memo(function McpSelector({ {/* Empty state */} {!loading && !error && servers.length === 0 && ( - No MCP servers configured. - Press A to add a new server. + {" "}No MCP servers configured. + {" "}Press A to add a new server. - A add · Esc close + {" "}A add · Esc cancel )} @@ -649,7 +680,7 @@ export const McpSelector = memo(function McpSelector({ // Calculate available width for target display const nameLen = server.server_name.length; const typeLen = serverType.length; - const fixedChars = 2 + 3 + 3 + typeLen; // "> " + " · " + " · " + type + const fixedChars = 4 + 3 + 3 + typeLen; // " > " + " · " + " · " + type const availableForTarget = Math.max( 20, terminalWidth - nameLen - fixedChars, @@ -662,16 +693,15 @@ export const McpSelector = memo(function McpSelector({ flexDirection="column" marginBottom={1} > - {/* Row 1: Selection indicator, name, type, and ID */} + {/* Row 1: Selection indicator, name, type, and target */} - {isSelected ? ">" : " "} + {isSelected ? "> " : " "} - {/* Row 2: Server ID if available */} {server.id && ( - - - ID: {server.id} + + + {" "}ID: {server.id} )} @@ -703,18 +733,14 @@ export const McpSelector = memo(function McpSelector({ {!loading && !error && servers.length > 0 && ( {totalPages > 1 && ( - - - Page {currentPage + 1}/{totalPages} - - - )} - - ↑↓ navigate · Enter view tools · A add · D delete · R refresh · - Esc close + {" "}Page {currentPage + 1}/{totalPages} - + )} + + {" "}Enter view tools · ↑↓ navigate · A add · D delete · R refresh + · Esc cancel + )} diff --git a/src/cli/components/MemoryTabViewer.tsx b/src/cli/components/MemoryTabViewer.tsx new file mode 100644 index 0000000..9e0a8ae --- /dev/null +++ b/src/cli/components/MemoryTabViewer.tsx @@ -0,0 +1,216 @@ +import type { Block } from "@letta-ai/letta-client/resources/agents/blocks"; +import { Box, Text, useInput } from "ink"; +import Link from "ink-link"; +import { useState } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; +import { MarkdownDisplay } from "./MarkdownDisplay"; + +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; + +const VISIBLE_LINES = 12; // Visible lines for value content + +interface MemoryTabViewerProps { + blocks: Block[]; + agentId: string; + onClose: () => void; + conversationId?: string; +} + +/** + * Format character count as "current / limit" + */ +function formatCharCount(current: number, limit: number | null): string { + if (limit === null || limit === undefined) { + return `${current.toLocaleString()} chars`; + } + return `${current.toLocaleString()} / ${limit.toLocaleString()} chars`; +} + +export function MemoryTabViewer({ + blocks, + agentId, + onClose, + conversationId, +}: MemoryTabViewerProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + const adeUrl = `https://app.letta.com/agents/${agentId}?view=memory${conversationId ? `&conversation=${conversationId}` : ""}`; + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + + // Get current block + const currentBlock = blocks[selectedTabIndex]; + const valueLines = currentBlock?.value?.split("\n") || []; + const maxScrollOffset = Math.max(0, valueLines.length - VISIBLE_LINES); + + // Reset scroll when switching tabs + const switchTab = (newIndex: number) => { + setSelectedTabIndex(newIndex); + setScrollOffset(0); + }; + + useInput((input, key) => { + // CTRL-C: immediately close + if (key.ctrl && input === "c") { + onClose(); + return; + } + + // ESC: close + if (key.escape) { + onClose(); + return; + } + + // Tab or left/right to switch tabs + if (key.tab) { + const nextIndex = (selectedTabIndex + 1) % blocks.length; + switchTab(nextIndex); + return; + } + + if (key.leftArrow) { + const prevIndex = + selectedTabIndex === 0 ? blocks.length - 1 : selectedTabIndex - 1; + switchTab(prevIndex); + return; + } + + if (key.rightArrow) { + const nextIndex = (selectedTabIndex + 1) % blocks.length; + switchTab(nextIndex); + return; + } + + // Up/down to scroll content + if (key.upArrow) { + setScrollOffset((prev) => Math.max(prev - 1, 0)); + } else if (key.downArrow) { + setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset)); + } + }); + + // Render tab bar + const renderTabBar = () => ( + + {blocks.map((block, index) => { + const isActive = index === selectedTabIndex; + return ( + + {` ${block.label} `} + + ); + })} + + ); + + // Empty state + if (blocks.length === 0) { + return ( + + {"> /memory"} + {solidLine} + + + + + + View your agent's memory + + + {" "}No memory blocks attached to this agent. + + {" "}Esc cancel + + + ); + } + + const charCount = (currentBlock?.value || "").length; + const visibleValueLines = valueLines.slice( + scrollOffset, + scrollOffset + VISIBLE_LINES, + ); + const canScrollDown = scrollOffset < maxScrollOffset; + const barColor = colors.selector.itemHighlighted; + + return ( + + {/* Command header */} + {"> /memory"} + {solidLine} + + + + {/* Title */} + + + View your agent's memory + + + + {/* Tab bar */} + + {renderTabBar()} + {currentBlock?.description && ( + + + + + )} + + + {/* Content area */} + + {/* Value content with left border */} + + {visibleValueLines.join("\n") || "(empty)"} + + + {/* Scroll down indicator or phantom row */} + {canScrollDown ? ( + + {" "}↓ {maxScrollOffset - scrollOffset} more line + {maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below + + ) : maxScrollOffset > 0 ? ( + + ) : null} + + + {/* Footer */} + + + {" "} + {formatCharCount(charCount, currentBlock?.limit ?? null)} + {currentBlock?.read_only ? " · read-only" : " · read/write"} + + + {" "}←→/Tab switch · ↑↓ scroll · + + Edit in ADE + + · Esc cancel + + + + ); +} diff --git a/src/cli/components/MemoryViewer.tsx b/src/cli/components/MemoryViewer.tsx deleted file mode 100644 index b973bf5..0000000 --- a/src/cli/components/MemoryViewer.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import type { Block } from "@letta-ai/letta-client/resources/agents/blocks"; -import { Box, Text, useInput } from "ink"; -import Link from "ink-link"; -import { useState } from "react"; -import { colors } from "./colors"; - -const PAGE_SIZE = 3; // Show 3 memory blocks per page -const PREVIEW_LINES = 3; // Show 3 lines of content preview -const DETAIL_DESCRIPTION_LINES = 3; // Max lines for description in detail view -const DETAIL_VALUE_LINES = 12; // Visible lines for value content in detail view - -interface MemoryViewerProps { - blocks: Block[]; - agentId: string; - agentName: string | null; - onClose: () => void; - conversationId?: string; -} - -/** - * Truncate text to a certain number of lines - */ -function truncateToLines(text: string, maxLines: number): string[] { - const lines = text.split("\n").slice(0, maxLines); - return lines; -} - -/** - * Format character count as "current / limit" - */ -function formatCharCount(current: number, limit: number | null): string { - if (limit === null || limit === undefined) { - return `${current.toLocaleString()} chars`; - } - return `${current.toLocaleString()} / ${limit.toLocaleString()} chars`; -} - -export function MemoryViewer({ - blocks, - agentId, - agentName, - onClose, - conversationId, -}: MemoryViewerProps) { - // Construct ADE URL for this agent's memory - const adeUrl = `https://app.letta.com/agents/${agentId}?view=memory${conversationId ? `&conversation=${conversationId}` : ""}`; - const [selectedIndex, setSelectedIndex] = useState(0); - const [currentPage, setCurrentPage] = useState(0); - - // Detail view state - const [detailBlockIndex, setDetailBlockIndex] = useState(null); - const [scrollOffset, setScrollOffset] = useState(0); - - const totalPages = Math.ceil(blocks.length / PAGE_SIZE); - const startIndex = currentPage * PAGE_SIZE; - const visibleBlocks = blocks.slice(startIndex, startIndex + PAGE_SIZE); - - // Navigation within page and across pages - const navigateUp = () => { - if (selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } else if (currentPage > 0) { - setCurrentPage(currentPage - 1); - setSelectedIndex(PAGE_SIZE - 1); - } - }; - - const navigateDown = () => { - if (selectedIndex < visibleBlocks.length - 1) { - setSelectedIndex(selectedIndex + 1); - } else if (currentPage < totalPages - 1) { - setCurrentPage(currentPage + 1); - setSelectedIndex(0); - } - }; - - // Get the block being viewed in detail - const detailBlock = - detailBlockIndex !== null ? blocks[detailBlockIndex] : null; - const detailValueLines = detailBlock?.value?.split("\n") || []; - const maxScrollOffset = Math.max( - 0, - detailValueLines.length - DETAIL_VALUE_LINES, - ); - - useInput((input, key) => { - // CTRL-C: immediately close the entire viewer - if (key.ctrl && input === "c") { - onClose(); - return; - } - - // ESC: exit detail view or close entirely - if (key.escape) { - if (detailBlockIndex !== null) { - setDetailBlockIndex(null); - setScrollOffset(0); - } else { - onClose(); - } - return; - } - - // Enter: open detail view for selected block - if (key.return && detailBlockIndex === null) { - const globalIndex = currentPage * PAGE_SIZE + selectedIndex; - if (globalIndex < blocks.length) { - setDetailBlockIndex(globalIndex); - setScrollOffset(0); - } - return; - } - - // j/k vim-style navigation (list or scroll) - if (input === "j" || key.downArrow) { - if (detailBlockIndex !== null) { - // Scroll down in detail view - setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset)); - } else { - navigateDown(); - } - } else if (input === "k" || key.upArrow) { - if (detailBlockIndex !== null) { - // Scroll up in detail view - setScrollOffset((prev) => Math.max(prev - 1, 0)); - } else { - navigateUp(); - } - } - }); - - if (blocks.length === 0) { - return ( - - - Memory Blocks - - No memory blocks attached to this agent. - Press ESC to close - - ); - } - - // Detail view for a single block - if (detailBlock) { - const charCount = (detailBlock.value || "").length; - const descriptionLines = truncateToLines( - detailBlock.description || "", - DETAIL_DESCRIPTION_LINES, - ); - const visibleValueLines = detailValueLines.slice( - scrollOffset, - scrollOffset + DETAIL_VALUE_LINES, - ); - const canScrollUp = scrollOffset > 0; - const canScrollDown = scrollOffset < maxScrollOffset; - const barColor = colors.selector.itemHighlighted; - - return ( - - {/* Header */} - - - Viewing the - - {detailBlock.label} - - block - {detailBlock.read_only && (read-only)} - - - {formatCharCount(charCount, detailBlock.limit ?? null)} - - - - View/edit in the ADE - - ↑↓/jk to scroll • ESC to go back - - {/* Description (up to 3 lines) */} - {descriptionLines.length > 0 && ( - - {descriptionLines.map((line) => ( - - {line} - - ))} - - )} - - {/* Scrollable value content */} - - {/* Scroll up indicator */} - {canScrollUp && ( - - ↑ {scrollOffset} more line{scrollOffset !== 1 ? "s" : ""} above - - )} - - {/* Value content with left border */} - - {visibleValueLines.join("\n")} - - - {/* Scroll down indicator */} - {canScrollDown && ( - - ↓ {maxScrollOffset - scrollOffset} more line - {maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below - - )} - - - ); - } - - return ( - - {/* Header */} - - - Memory Blocks ({blocks.length} attached to {agentName || "agent"}) - - {totalPages > 1 && ( - - Page {currentPage + 1}/{totalPages} - - )} - - - View/edit in the ADE - - ↑↓/jk to navigate • Enter to view • ESC to close - - {/* Block list */} - - {visibleBlocks.map((block, index) => { - const isSelected = index === selectedIndex; - const contentLines = truncateToLines( - block.value || "", - PREVIEW_LINES, - ); - const charCount = (block.value || "").length; - - const barColor = isSelected - ? colors.selector.itemHighlighted - : colors.command.border; - const hasEllipsis = - (block.value || "").split("\n").length > PREVIEW_LINES; - - // Build content preview text - const previewText = contentLines - .map((line) => - line.length > 80 ? `${line.slice(0, 80)}...` : line, - ) - .join("\n"); - - return ( - - {/* Header row: label + char count */} - - - - {block.label} - - {block.read_only && (read-only)} - - - {formatCharCount(charCount, block.limit ?? null)} - - - - {/* Description (if available) */} - {block.description && ( - - {block.description.length > 60 - ? `${block.description.slice(0, 60)}...` - : block.description} - - )} - - {/* Content preview */} - {previewText} - - {/* Ellipsis if content is truncated */} - {hasEllipsis && ...} - - ); - })} - - - ); -} diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index df4e565..fe224e0 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -7,9 +7,13 @@ import { getAvailableModelsCacheInfo, } from "../../agent/available-models"; import { models } from "../../agent/model"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; -const PAGE_SIZE = 10; +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; + +const VISIBLE_ITEMS = 8; type ModelCategory = "supported" | "all"; const MODEL_CATEGORIES: ModelCategory[] = ["supported", "all"]; @@ -41,9 +45,10 @@ export function ModelSelector({ filterProvider, forceRefresh: forceRefreshOnMount, }: ModelSelectorProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const typedModels = models as UiModel[]; const [category, setCategory] = useState("supported"); - const [currentPage, setCurrentPage] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0); // undefined: not loaded yet (show spinner) @@ -153,18 +158,27 @@ export function ModelSelector({ })); }, [category, supportedModels, otherModelHandles]); - // Pagination - const totalPages = useMemo( - () => Math.max(1, Math.ceil(currentList.length / PAGE_SIZE)), - [currentList.length], - ); + // Show 1 fewer item in "all" category because Search line takes space + const visibleCount = category === "all" ? VISIBLE_ITEMS - 1 : VISIBLE_ITEMS; + + // Scrolling - keep selectedIndex in view + const startIndex = useMemo(() => { + // Keep selected item in the visible window + if (selectedIndex < visibleCount) return 0; + return Math.min( + selectedIndex - visibleCount + 1, + Math.max(0, currentList.length - visibleCount), + ); + }, [selectedIndex, currentList.length, visibleCount]); const visibleModels = useMemo(() => { - const start = currentPage * PAGE_SIZE; - return currentList.slice(start, start + PAGE_SIZE); - }, [currentList, currentPage]); + return currentList.slice(startIndex, startIndex + visibleCount); + }, [currentList, startIndex, visibleCount]); - // Reset page and selection when category changes + const showScrollDown = startIndex + visibleCount < currentList.length; + const itemsBelow = currentList.length - startIndex - visibleCount; + + // Reset selection when category changes const cycleCategory = useCallback(() => { setCategory((current) => { const idx = MODEL_CATEGORIES.indexOf(current); @@ -172,7 +186,6 @@ export function ModelSelector({ (idx + 1) % MODEL_CATEGORIES.length ] as ModelCategory; }); - setCurrentPage(0); setSelectedIndex(0); setSearchQuery(""); }, []); @@ -180,21 +193,21 @@ export function ModelSelector({ // Set initial selection to current model on mount const initializedRef = useRef(false); useEffect(() => { - if (!initializedRef.current && visibleModels.length > 0) { - const index = visibleModels.findIndex((m) => m.id === currentModelId); + if (!initializedRef.current && currentList.length > 0) { + const index = currentList.findIndex((m) => m.id === currentModelId); if (index >= 0) { setSelectedIndex(index); } initializedRef.current = true; } - }, [visibleModels, currentModelId]); + }, [currentList, currentModelId]); // Clamp selectedIndex when list changes useEffect(() => { - if (selectedIndex >= visibleModels.length && visibleModels.length > 0) { - setSelectedIndex(visibleModels.length - 1); + if (selectedIndex >= currentList.length && currentList.length > 0) { + setSelectedIndex(currentList.length - 1); } - }, [selectedIndex, visibleModels.length]); + }, [selectedIndex, currentList.length]); useInput( (input, key) => { @@ -208,7 +221,6 @@ export function ModelSelector({ if (key.escape) { if (searchQuery) { setSearchQuery(""); - setCurrentPage(0); setSelectedIndex(0); } else { onCancel(); @@ -231,50 +243,28 @@ export function ModelSelector({ if (key.backspace || key.delete) { if (searchQuery) { setSearchQuery((prev) => prev.slice(0, -1)); - setCurrentPage(0); setSelectedIndex(0); } return; } // Disable other inputs while loading - if (isLoading || refreshing || visibleModels.length === 0) { + if (isLoading || refreshing || currentList.length === 0) { return; } if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex((prev) => - Math.min(visibleModels.length - 1, prev + 1), - ); - } 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); - } - } else if (key.leftArrow && currentPage > 0) { - setCurrentPage((prev) => prev - 1); - setSelectedIndex(0); - } else if (key.rightArrow && currentPage < totalPages - 1) { - setCurrentPage((prev) => prev + 1); - setSelectedIndex(0); + setSelectedIndex((prev) => Math.min(currentList.length - 1, prev + 1)); } else if (key.return) { - const selectedModel = visibleModels[selectedIndex]; + const selectedModel = currentList[selectedIndex]; if (selectedModel) { onSelect(selectedModel.id); } } else if (category === "all" && input && input.length === 1) { // Capture text input for search (only in "all" category) setSearchQuery((prev) => prev + input); - setCurrentPage(0); setSelectedIndex(0); } }, @@ -284,65 +274,68 @@ export function ModelSelector({ const getCategoryLabel = (cat: ModelCategory) => { if (cat === "supported") return `Recommended (${supportedModels.length})`; - return `All Available Models (${otherModelHandles.length})`; + return `All Available (${otherModelHandles.length})`; }; + // Render tab bar (matches AgentSelector style) + const renderTabBar = () => ( + + {MODEL_CATEGORIES.map((cat) => { + const isActive = cat === category; + return ( + + {` ${getCategoryLabel(cat)} `} + + ); + })} + + ); + return ( - - + + {/* Command header */} + {"> /model"} + {solidLine} + + + + {/* Title and tabs */} + - Select Model (↑↓ navigate, ←→/jk page, Tab category, Enter select, ESC - cancel) + Swap your agent's model {!isLoading && !refreshing && ( - - Category: - {MODEL_CATEGORIES.map((cat, i) => ( - - {i > 0 && · } - - {getCategoryLabel(cat)} - - - ))} - (Tab to switch) - - )} - {!isLoading && !refreshing && ( - - - Page {currentPage + 1}/{totalPages} - {isCached ? " · cached" : ""} · 'r' to refresh - + + {renderTabBar()} {category === "all" && ( - Search: {searchQuery || "(type to search)"} + Search: {searchQuery || "(type to filter)"} )} )} + {/* Loading states */} {isLoading && ( - + Loading available models... )} {refreshing && ( - + Refreshing models... )} {error && ( - + Warning: Could not fetch available models. Showing all models. @@ -350,7 +343,7 @@ export function ModelSelector({ )} {!isLoading && !refreshing && visibleModels.length === 0 && ( - + {category === "supported" ? "No supported models available." @@ -359,40 +352,61 @@ export function ModelSelector({ )} + {/* Model list */} {visibleModels.map((model, index) => { - const isSelected = index === selectedIndex; + const actualIndex = startIndex + index; + const isSelected = actualIndex === selectedIndex; const isCurrent = model.id === currentModelId; return ( - + - {isSelected ? "›" : " "} + {isSelected ? "> " : " "} - - - {model.label} - {isCurrent && (current)} - - {model.description && ( - {model.description} - )} - + + {model.label} + {isCurrent && (current)} + + {model.description && ( + · {model.description} + )} ); })} + {showScrollDown ? ( + + {" "}↓ {itemsBelow} more below + + ) : currentList.length > visibleCount ? ( + + ) : null} + + {/* Footer */} + {!isLoading && !refreshing && currentList.length > 0 && ( + + + {" "} + {currentList.length} models{isCached ? " · cached" : ""} · R to + refresh + + + {" "}Enter select · ↑↓ navigate · Tab switch · Esc cancel + + + )} ); } diff --git a/src/cli/components/ProfileSelector.tsx b/src/cli/components/ProfileSelector.tsx deleted file mode 100644 index a3c503a..0000000 --- a/src/cli/components/ProfileSelector.tsx +++ /dev/null @@ -1,384 +0,0 @@ -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 { settingsManager } from "../../settings-manager"; -import { useTerminalWidth } from "../hooks/useTerminalWidth"; -import { colors } from "./colors"; - -interface ProfileSelectorProps { - currentAgentId: string; - onSelect: (agentId: string) => void; - onUnpin: (agentId: string) => void; - onCancel: () => void; -} - -interface ProfileData { - name: string; - agentId: string; - agent: AgentState | null; - error: string | null; - isLocal: boolean; // true = project-level pin, false = global pin -} - -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" | "confirming-delete"; - -export const ProfileSelector = memo(function ProfileSelector({ - currentAgentId, - onSelect, - onUnpin, - 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 [deleteConfirmIndex, setDeleteConfirmIndex] = useState(0); - - // Load pinned agents and fetch agent data - const loadProfiles = useCallback(async () => { - setLoading(true); - try { - const mergedPinned = settingsManager.getMergedPinnedAgents(); - - if (mergedPinned.length === 0) { - setProfiles([]); - setLoading(false); - return; - } - - const client = await getClient(); - - // Fetch agent data for each pinned agent - const profileDataPromises = mergedPinned.map( - async ({ agentId, isLocal }) => { - try { - const agent = await client.agents.retrieve(agentId, { - include: ["agent.blocks"], - }); - // Use agent name from server - return { name: agent.name, agentId, agent, error: null, isLocal }; - } catch (_err) { - return { - name: agentId.slice(0, 12), - agentId, - agent: null, - error: "Agent not found", - isLocal, - }; - } - }, - ); - - 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) => { - // CTRL-C: immediately cancel (works even during loading) - if (key.ctrl && input === "c") { - onCancel(); - return; - } - - if (loading) 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 - unpin (onUnpin closes the selector) - onUnpin(selectedProfile.agentId); - 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); - } - } else if (key.escape) { - onCancel(); - } 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); - } - } else if (input === "p" || input === "P") { - if (selectedProfile) { - // Unpin from current scope - if (selectedProfile.isLocal) { - settingsManager.unpinLocal(selectedProfile.agentId); - } else { - settingsManager.unpinGlobal(selectedProfile.agentId); - } - } else { - // No profiles - pin the current agent - settingsManager.pinLocal(currentAgentId); - } - // Reload profiles to reflect change - loadProfiles(); - } - }); - - // Unpin confirmation UI - if (mode === "confirming-delete" && selectedProfile) { - const options = ["Yes, unpin", "No, cancel"]; - return ( - - - - Unpin Agent - - - - Unpin "{selectedProfile.name}" from all locations? - - - {options.map((option, index) => { - const isSelected = index === deleteConfirmIndex; - return ( - - - {isSelected ? ">" : " "} {option} - - - ); - })} - - - ); - } - - // Main browsing UI - return ( - - - - Pinned Agents - - - - {/* Loading state */} - {loading && ( - - Loading pinned agents... - - )} - - {/* Empty state */} - {!loading && profiles.length === 0 && ( - - No agents pinned. - Press P to pin the current agent. - - 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} - - - {" "} - · {profile.isLocal ? "project" : "global"} · {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 · P unpin · D unpin all · 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 deleted file mode 100644 index f329658..0000000 --- a/src/cli/components/ResumeSelector.tsx +++ /dev/null @@ -1,789 +0,0 @@ -import type { Letta } from "@letta-ai/letta-client"; -import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; -import { Box, Text, useInput } from "ink"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { getClient } from "../../agent/client"; -import { getModelDisplayName } from "../../agent/model"; -import { settingsManager } from "../../settings-manager"; -import { useTerminalWidth } from "../hooks/useTerminalWidth"; -import { colors } from "./colors"; - -interface ResumeSelectorProps { - currentAgentId: string; - onSelect: (agentId: string) => void; - onCancel: () => void; -} - -type TabId = "pinned" | "letta-code" | "all"; - -interface PinnedAgentData { - agentId: string; - agent: AgentState | null; - error: string | null; - isLocal: boolean; -} - -const TABS: { id: TabId; label: string }[] = [ - { id: "pinned", label: "Pinned" }, - { id: "letta-code", label: "Letta Code" }, - { id: "all", label: "All" }, -]; - -const TAB_DESCRIPTIONS: Record = { - pinned: "Save agents for easy access by pinning them with /pin", - "letta-code": "Displaying agents created inside of Letta Code", - all: "Displaying all available agents", -}; - -const TAB_EMPTY_STATES: Record = { - pinned: "No pinned agents, use /pin to save", - "letta-code": "No agents with tag 'origin:letta-code'", - all: "No agents found", -}; - -const DISPLAY_PAGE_SIZE = 5; -const FETCH_PAGE_SIZE = 20; - -/** - * 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 friendly display name (e.g., "Sonnet 4.5") - */ -function formatModel(agent: AgentState): string { - // Build handle from agent config - let handle: string | null = null; - if (agent.model) { - handle = agent.model; - } else if (agent.llm_config?.model) { - const provider = agent.llm_config.model_endpoint_type || "unknown"; - handle = `${provider}/${agent.llm_config.model}`; - } - - if (handle) { - // Try to get friendly display name - const displayName = getModelDisplayName(handle); - if (displayName) return displayName; - // Fallback to handle - return handle; - } - return "unknown"; -} - -export function ResumeSelector({ - currentAgentId, - onSelect, - onCancel, -}: ResumeSelectorProps) { - const terminalWidth = useTerminalWidth(); - const clientRef = useRef(null); - - // Tab state - const [activeTab, setActiveTab] = useState("pinned"); - - // Pinned tab state - const [pinnedAgents, setPinnedAgents] = useState([]); - const [pinnedLoading, setPinnedLoading] = useState(true); - const [pinnedSelectedIndex, setPinnedSelectedIndex] = useState(0); - const [pinnedPage, setPinnedPage] = useState(0); - - // Letta Code tab state (cached separately) - const [lettaCodeAgents, setLettaCodeAgents] = useState([]); - const [lettaCodeCursor, setLettaCodeCursor] = useState(null); - const [lettaCodeLoading, setLettaCodeLoading] = useState(false); - const [lettaCodeLoadingMore, setLettaCodeLoadingMore] = useState(false); - const [lettaCodeHasMore, setLettaCodeHasMore] = useState(true); - const [lettaCodeSelectedIndex, setLettaCodeSelectedIndex] = useState(0); - const [lettaCodePage, setLettaCodePage] = useState(0); - const [lettaCodeError, setLettaCodeError] = useState(null); - const [lettaCodeLoaded, setLettaCodeLoaded] = useState(false); - const [lettaCodeQuery, setLettaCodeQuery] = useState(""); // Query used to load current data - - // All tab state (cached separately) - const [allAgents, setAllAgents] = useState([]); - const [allCursor, setAllCursor] = useState(null); - const [allLoading, setAllLoading] = useState(false); - const [allLoadingMore, setAllLoadingMore] = useState(false); - const [allHasMore, setAllHasMore] = useState(true); - const [allSelectedIndex, setAllSelectedIndex] = useState(0); - const [allPage, setAllPage] = useState(0); - const [allError, setAllError] = useState(null); - const [allLoaded, setAllLoaded] = useState(false); - const [allQuery, setAllQuery] = useState(""); // Query used to load current data - - // Search state (shared across list tabs) - const [searchInput, setSearchInput] = useState(""); - const [activeQuery, setActiveQuery] = useState(""); - - // Load pinned agents - const loadPinnedAgents = useCallback(async () => { - setPinnedLoading(true); - try { - const mergedPinned = settingsManager.getMergedPinnedAgents(); - - if (mergedPinned.length === 0) { - setPinnedAgents([]); - setPinnedLoading(false); - return; - } - - const client = clientRef.current || (await getClient()); - clientRef.current = client; - - const pinnedData = await Promise.all( - mergedPinned.map(async ({ agentId, isLocal }) => { - try { - const agent = await client.agents.retrieve(agentId, { - include: ["agent.blocks"], - }); - return { agentId, agent, error: null, isLocal }; - } catch { - return { agentId, agent: null, error: "Agent not found", isLocal }; - } - }), - ); - - setPinnedAgents(pinnedData); - } catch { - setPinnedAgents([]); - } finally { - setPinnedLoading(false); - } - }, []); - - // Fetch agents for list tabs (Letta Code / All) - const fetchListAgents = useCallback( - async ( - filterLettaCode: boolean, - afterCursor?: string | null, - query?: string, - ) => { - const client = clientRef.current || (await getClient()); - clientRef.current = client; - - const agentList = await client.agents.list({ - limit: FETCH_PAGE_SIZE, - ...(filterLettaCode && { tags: ["origin:letta-code"] }), - include: ["agent.blocks"], - order: "desc", - order_by: "last_run_completion", - ...(afterCursor && { after: afterCursor }), - ...(query && { query_text: query }), - }); - - const cursor = - agentList.items.length === FETCH_PAGE_SIZE - ? (agentList.items[agentList.items.length - 1]?.id ?? null) - : null; - - return { agents: agentList.items, nextCursor: cursor }; - }, - [], - ); - - // Load Letta Code agents - const loadLettaCodeAgents = useCallback( - async (query?: string) => { - setLettaCodeLoading(true); - setLettaCodeError(null); - try { - const result = await fetchListAgents(true, null, query); - setLettaCodeAgents(result.agents); - setLettaCodeCursor(result.nextCursor); - setLettaCodeHasMore(result.nextCursor !== null); - setLettaCodePage(0); - setLettaCodeSelectedIndex(0); - setLettaCodeLoaded(true); - setLettaCodeQuery(query || ""); // Track query used for this load - } catch (err) { - setLettaCodeError(err instanceof Error ? err.message : String(err)); - } finally { - setLettaCodeLoading(false); - } - }, - [fetchListAgents], - ); - - // Load All agents - const loadAllAgents = useCallback( - async (query?: string) => { - setAllLoading(true); - setAllError(null); - try { - const result = await fetchListAgents(false, null, query); - setAllAgents(result.agents); - setAllCursor(result.nextCursor); - setAllHasMore(result.nextCursor !== null); - setAllPage(0); - setAllSelectedIndex(0); - setAllLoaded(true); - setAllQuery(query || ""); // Track query used for this load - } catch (err) { - setAllError(err instanceof Error ? err.message : String(err)); - } finally { - setAllLoading(false); - } - }, - [fetchListAgents], - ); - - // Load pinned agents on mount - useEffect(() => { - loadPinnedAgents(); - }, [loadPinnedAgents]); - - // Load tab data when switching tabs (only if not already loaded) - useEffect(() => { - if (activeTab === "letta-code" && !lettaCodeLoaded && !lettaCodeLoading) { - loadLettaCodeAgents(); - } else if (activeTab === "all" && !allLoaded && !allLoading) { - loadAllAgents(); - } - }, [ - activeTab, - lettaCodeLoaded, - lettaCodeLoading, - loadLettaCodeAgents, - allLoaded, - allLoading, - loadAllAgents, - ]); - - // Reload current tab when search query changes (only if query differs from cached) - useEffect(() => { - if (activeTab === "letta-code" && activeQuery !== lettaCodeQuery) { - loadLettaCodeAgents(activeQuery || undefined); - } else if (activeTab === "all" && activeQuery !== allQuery) { - loadAllAgents(activeQuery || undefined); - } - }, [ - activeQuery, - activeTab, - lettaCodeQuery, - allQuery, - loadLettaCodeAgents, - loadAllAgents, - ]); - - // Fetch more Letta Code agents - const fetchMoreLettaCodeAgents = useCallback(async () => { - if (lettaCodeLoadingMore || !lettaCodeHasMore || !lettaCodeCursor) return; - - setLettaCodeLoadingMore(true); - try { - const result = await fetchListAgents( - true, - lettaCodeCursor, - activeQuery || undefined, - ); - setLettaCodeAgents((prev) => [...prev, ...result.agents]); - setLettaCodeCursor(result.nextCursor); - setLettaCodeHasMore(result.nextCursor !== null); - } catch { - // Silently fail on pagination errors - } finally { - setLettaCodeLoadingMore(false); - } - }, [ - lettaCodeLoadingMore, - lettaCodeHasMore, - lettaCodeCursor, - fetchListAgents, - activeQuery, - ]); - - // Fetch more All agents - const fetchMoreAllAgents = useCallback(async () => { - if (allLoadingMore || !allHasMore || !allCursor) return; - - setAllLoadingMore(true); - try { - const result = await fetchListAgents( - false, - allCursor, - activeQuery || undefined, - ); - setAllAgents((prev) => [...prev, ...result.agents]); - setAllCursor(result.nextCursor); - setAllHasMore(result.nextCursor !== null); - } catch { - // Silently fail on pagination errors - } finally { - setAllLoadingMore(false); - } - }, [allLoadingMore, allHasMore, allCursor, fetchListAgents, activeQuery]); - - // Pagination calculations - Pinned - const pinnedTotalPages = Math.ceil(pinnedAgents.length / DISPLAY_PAGE_SIZE); - const pinnedStartIndex = pinnedPage * DISPLAY_PAGE_SIZE; - const pinnedPageAgents = pinnedAgents.slice( - pinnedStartIndex, - pinnedStartIndex + DISPLAY_PAGE_SIZE, - ); - - // Pagination calculations - Letta Code - const lettaCodeTotalPages = Math.ceil( - lettaCodeAgents.length / DISPLAY_PAGE_SIZE, - ); - const lettaCodeStartIndex = lettaCodePage * DISPLAY_PAGE_SIZE; - const lettaCodePageAgents = lettaCodeAgents.slice( - lettaCodeStartIndex, - lettaCodeStartIndex + DISPLAY_PAGE_SIZE, - ); - const lettaCodeCanGoNext = - lettaCodePage < lettaCodeTotalPages - 1 || lettaCodeHasMore; - - // Pagination calculations - All - const allTotalPages = Math.ceil(allAgents.length / DISPLAY_PAGE_SIZE); - const allStartIndex = allPage * DISPLAY_PAGE_SIZE; - const allPageAgents = allAgents.slice( - allStartIndex, - allStartIndex + DISPLAY_PAGE_SIZE, - ); - const allCanGoNext = allPage < allTotalPages - 1 || allHasMore; - - // Current tab's state (computed) - const currentLoading = - activeTab === "pinned" - ? pinnedLoading - : activeTab === "letta-code" - ? lettaCodeLoading - : allLoading; - const currentError = - activeTab === "letta-code" - ? lettaCodeError - : activeTab === "all" - ? allError - : null; - const currentAgents = - activeTab === "pinned" - ? pinnedPageAgents.map((p) => p.agent).filter(Boolean) - : activeTab === "letta-code" - ? lettaCodePageAgents - : allPageAgents; - const setCurrentSelectedIndex = - activeTab === "pinned" - ? setPinnedSelectedIndex - : activeTab === "letta-code" - ? setLettaCodeSelectedIndex - : setAllSelectedIndex; - - // Submit search - const submitSearch = useCallback(() => { - if (searchInput !== activeQuery) { - setActiveQuery(searchInput); - } - }, [searchInput, activeQuery]); - - // Clear search (effect will handle reload when query changes) - const clearSearch = useCallback(() => { - setSearchInput(""); - if (activeQuery) { - setActiveQuery(""); - } - }, [activeQuery]); - - useInput((input, key) => { - // CTRL-C: immediately cancel - if (key.ctrl && input === "c") { - onCancel(); - return; - } - - // Tab key cycles through tabs - if (key.tab) { - const currentIndex = TABS.findIndex((t) => t.id === activeTab); - const nextIndex = (currentIndex + 1) % TABS.length; - setActiveTab(TABS[nextIndex]?.id ?? "pinned"); - return; - } - - if (currentLoading) return; - - // For pinned tab, use pinnedPageAgents.length to include "not found" entries - // For other tabs, use currentAgents.length - const maxIndex = - activeTab === "pinned" - ? pinnedPageAgents.length - 1 - : (currentAgents as AgentState[]).length - 1; - - if (key.upArrow) { - setCurrentSelectedIndex((prev: number) => Math.max(0, prev - 1)); - } else if (key.downArrow) { - setCurrentSelectedIndex((prev: number) => Math.min(maxIndex, prev + 1)); - } else if (key.return) { - // If typing a search query (list tabs only), submit it - if ( - activeTab !== "pinned" && - searchInput && - searchInput !== activeQuery - ) { - submitSearch(); - return; - } - - // Select agent - if (activeTab === "pinned") { - const selected = pinnedPageAgents[pinnedSelectedIndex]; - if (selected?.agent) { - onSelect(selected.agentId); - } - } else if (activeTab === "letta-code") { - const selected = lettaCodePageAgents[lettaCodeSelectedIndex]; - if (selected?.id) { - onSelect(selected.id); - } - } else { - const selected = allPageAgents[allSelectedIndex]; - if (selected?.id) { - onSelect(selected.id); - } - } - } else if (key.escape) { - // If typing search (list tabs), clear it first - if (activeTab !== "pinned" && searchInput) { - clearSearch(); - return; - } - onCancel(); - } else if (key.backspace || key.delete) { - if (activeTab !== "pinned") { - setSearchInput((prev) => prev.slice(0, -1)); - } - } else if (input === "j" || input === "J") { - // Previous page - if (activeTab === "pinned") { - if (pinnedPage > 0) { - setPinnedPage((prev) => prev - 1); - setPinnedSelectedIndex(0); - } - } else if (activeTab === "letta-code") { - if (lettaCodePage > 0) { - setLettaCodePage((prev) => prev - 1); - setLettaCodeSelectedIndex(0); - } - } else { - if (allPage > 0) { - setAllPage((prev) => prev - 1); - setAllSelectedIndex(0); - } - } - } else if (input === "k" || input === "K") { - // Next page - if (activeTab === "pinned") { - if (pinnedPage < pinnedTotalPages - 1) { - setPinnedPage((prev) => prev + 1); - setPinnedSelectedIndex(0); - } - } else if (activeTab === "letta-code" && lettaCodeCanGoNext) { - const nextPageIndex = lettaCodePage + 1; - const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE; - - if (nextStartIndex >= lettaCodeAgents.length && lettaCodeHasMore) { - fetchMoreLettaCodeAgents(); - } - - if (nextStartIndex < lettaCodeAgents.length) { - setLettaCodePage(nextPageIndex); - setLettaCodeSelectedIndex(0); - } - } else if (activeTab === "all" && allCanGoNext) { - const nextPageIndex = allPage + 1; - const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE; - - if (nextStartIndex >= allAgents.length && allHasMore) { - fetchMoreAllAgents(); - } - - if (nextStartIndex < allAgents.length) { - setAllPage(nextPageIndex); - setAllSelectedIndex(0); - } - } - // NOTE: "D" for unpin all disabled - too destructive without confirmation - // } else if (activeTab === "pinned" && (input === "d" || input === "D")) { - // const selected = pinnedPageAgents[pinnedSelectedIndex]; - // if (selected) { - // settingsManager.unpinBoth(selected.agentId); - // loadPinnedAgents(); - // } - // } - } else if (activeTab === "pinned" && (input === "p" || input === "P")) { - // Unpin from current scope (pinned tab only) - const selected = pinnedPageAgents[pinnedSelectedIndex]; - if (selected) { - if (selected.isLocal) { - settingsManager.unpinLocal(selected.agentId); - } else { - settingsManager.unpinGlobal(selected.agentId); - } - loadPinnedAgents(); - } - } else if (activeTab !== "pinned" && input && !key.ctrl && !key.meta) { - // Type to search (list tabs only) - setSearchInput((prev) => prev + input); - } - }); - - // Render tab bar - const renderTabBar = () => ( - - {TABS.map((tab) => { - const isActive = tab.id === activeTab; - return ( - - [{tab.label}] - - ); - })} - - ); - - // Render agent item (shared between tabs) - const renderAgentItem = ( - agent: AgentState, - _index: number, - isSelected: boolean, - extra?: { isLocal?: boolean }, - ) => { - const isCurrent = agent.id === currentAgentId; - const relativeTime = formatRelativeTime(agent.last_run_completion); - const blockCount = agent.blocks?.length ?? 0; - const modelStr = formatModel(agent); - - const nameLen = (agent.name || "Unnamed").length; - const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); - const availableForId = Math.max(15, terminalWidth - nameLen - fixedChars); - const displayId = truncateAgentId(agent.id, availableForId); - - return ( - - - - {isSelected ? ">" : " "} - - - - {agent.name || "Unnamed"} - - - {" · "} - {extra?.isLocal !== undefined - ? `${extra.isLocal ? "project" : "global"} · ` - : ""} - {displayId} - - {isCurrent && ( - (current) - )} - - - - {agent.description || "No description"} - - - - - {relativeTime} · {blockCount} memory block - {blockCount === 1 ? "" : "s"} · {modelStr} - - - - ); - }; - - // Render pinned agent item (may have error) - const renderPinnedItem = ( - data: PinnedAgentData, - index: number, - isSelected: boolean, - ) => { - if (data.agent) { - return renderAgentItem(data.agent, index, isSelected, { - isLocal: data.isLocal, - }); - } - - // Error state for missing agent - return ( - - - - {isSelected ? ">" : " "} - - - - {data.agentId.slice(0, 12)} - - · {data.isLocal ? "project" : "global"} - - - - {data.error} - - - - ); - }; - - return ( - - {/* Header */} - - - Browsing Agents - - - {renderTabBar()} - {TAB_DESCRIPTIONS[activeTab]} - - - - {/* Search input - list tabs only */} - {activeTab !== "pinned" && (searchInput || activeQuery) && ( - - Search: - {searchInput} - {searchInput && searchInput !== activeQuery && ( - (press Enter to search) - )} - {activeQuery && searchInput === activeQuery && ( - (Esc to clear) - )} - - )} - - {/* Error state - list tabs */} - {activeTab !== "pinned" && currentError && ( - - Error: {currentError} - Press ESC to cancel - - )} - - {/* Loading state */} - {currentLoading && ( - - Loading agents... - - )} - - {/* Empty state */} - {!currentLoading && - ((activeTab === "pinned" && pinnedAgents.length === 0) || - (activeTab === "letta-code" && - !lettaCodeError && - lettaCodeAgents.length === 0) || - (activeTab === "all" && !allError && allAgents.length === 0)) && ( - - {TAB_EMPTY_STATES[activeTab]} - Press ESC to cancel - - )} - - {/* Pinned tab content */} - {activeTab === "pinned" && !pinnedLoading && pinnedAgents.length > 0 && ( - - {pinnedPageAgents.map((data, index) => - renderPinnedItem(data, index, index === pinnedSelectedIndex), - )} - - )} - - {/* Letta Code tab content */} - {activeTab === "letta-code" && - !lettaCodeLoading && - !lettaCodeError && - lettaCodeAgents.length > 0 && ( - - {lettaCodePageAgents.map((agent, index) => - renderAgentItem(agent, index, index === lettaCodeSelectedIndex), - )} - - )} - - {/* All tab content */} - {activeTab === "all" && - !allLoading && - !allError && - allAgents.length > 0 && ( - - {allPageAgents.map((agent, index) => - renderAgentItem(agent, index, index === allSelectedIndex), - )} - - )} - - {/* Footer */} - {!currentLoading && - ((activeTab === "pinned" && pinnedAgents.length > 0) || - (activeTab === "letta-code" && - !lettaCodeError && - lettaCodeAgents.length > 0) || - (activeTab === "all" && !allError && allAgents.length > 0)) && ( - - - - {activeTab === "pinned" - ? `Page ${pinnedPage + 1}/${pinnedTotalPages || 1}` - : activeTab === "letta-code" - ? `Page ${lettaCodePage + 1}${lettaCodeHasMore ? "+" : `/${lettaCodeTotalPages || 1}`}${lettaCodeLoadingMore ? " (loading...)" : ""}` - : `Page ${allPage + 1}${allHasMore ? "+" : `/${allTotalPages || 1}`}${allLoadingMore ? " (loading...)" : ""}`} - - - - - Tab switch · ↑↓ navigate · Enter select · J/K page - {activeTab === "pinned" ? " · P unpin" : " · Type to search"} - - - - )} - - ); -} diff --git a/src/cli/components/SlashCommandAutocomplete.tsx b/src/cli/components/SlashCommandAutocomplete.tsx index f1509c4..66d97da 100644 --- a/src/cli/components/SlashCommandAutocomplete.tsx +++ b/src/cli/components/SlashCommandAutocomplete.tsx @@ -1,12 +1,9 @@ import { Text } from "ink"; -import Link from "ink-link"; import { useEffect, useMemo, useState } from "react"; import { settingsManager } from "../../settings-manager"; -import { getVersion } from "../../version"; import { commands } from "../commands/registry"; import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation"; import { AutocompleteBox, AutocompleteItem } from "./Autocomplete"; -import { colors } from "./colors"; import type { AutocompleteProps, CommandMatch } from "./types/autocomplete"; const VISIBLE_COMMANDS = 8; // Number of commands visible at once @@ -179,12 +176,10 @@ export function SlashCommandAutocomplete({ startIndex, startIndex + VISIBLE_COMMANDS, ); - const showScrollUp = startIndex > 0; const showScrollDown = startIndex + VISIBLE_COMMANDS < totalMatches; return ( - - {showScrollUp && ↑ {startIndex} more above} + {visibleMatches.map((item, idx) => { const actualIndex = startIndex + idx; return ( @@ -197,20 +192,13 @@ export function SlashCommandAutocomplete({ ); })} - {showScrollDown && ( + {showScrollDown ? ( - {" "} - ↓ {totalMatches - startIndex - VISIBLE_COMMANDS} more below + {" "}↓ {totalMatches - startIndex - VISIBLE_COMMANDS} more below - )} - - - Having issues? Report bugs with /feedback or{" "} - - join our Discord ↗ - - - Version: Letta Code v{getVersion()} + ) : needsScrolling ? ( + + ) : null} ); } diff --git a/src/cli/components/SystemPromptSelector.tsx b/src/cli/components/SystemPromptSelector.tsx index 774efe8..627f399 100644 --- a/src/cli/components/SystemPromptSelector.tsx +++ b/src/cli/components/SystemPromptSelector.tsx @@ -2,8 +2,12 @@ import { Box, Text, useInput } from "ink"; import { useMemo, useState } from "react"; import { SYSTEM_PROMPTS } from "../../agent/promptAssets"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; + interface SystemPromptSelectorProps { currentPromptId?: string; onSelect: (promptId: string) => void; @@ -15,6 +19,8 @@ export function SystemPromptSelector({ onSelect, onCancel, }: SystemPromptSelectorProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const [showAll, setShowAll] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -61,10 +67,17 @@ export function SystemPromptSelector({ }); return ( - - + + {/* Command header */} + {"> /prompt"} + {solidLine} + + + + {/* Title */} + - Select System Prompt (↑↓ to navigate, Enter to select, ESC to cancel) + Swap your agent's system prompt @@ -74,31 +87,27 @@ export function SystemPromptSelector({ const isCurrent = prompt.id === currentPromptId; return ( - + - {isSelected ? "›" : " "} + {isSelected ? "> " : " "} - - - {prompt.label} - {isCurrent && ( - (current) - )} - - {prompt.description} - + + {prompt.label} + {isCurrent && ( + (current) + )} + + · {prompt.description} ); })} {hasShowAllOption && ( - + - {selectedIndex === visiblePrompts.length ? "›" : " "} + {selectedIndex === visiblePrompts.length ? "> " : " "} Show all prompts )} + + {/* Footer */} + + {" "}Enter select · ↑↓ navigate · Esc cancel + ); } diff --git a/src/cli/components/ToolsetSelector.tsx b/src/cli/components/ToolsetSelector.tsx index 84f51aa..16fef93 100644 --- a/src/cli/components/ToolsetSelector.tsx +++ b/src/cli/components/ToolsetSelector.tsx @@ -1,8 +1,12 @@ // Import useInput from vendored Ink for bracketed paste support import { Box, Text, useInput } from "ink"; import { useMemo, useState } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; +// Horizontal line character (matches approval dialogs) +const SOLID_LINE = "─"; + type ToolsetId = | "codex" | "codex_snake" @@ -120,6 +124,8 @@ export function ToolsetSelector({ onSelect, onCancel, }: ToolsetSelectorProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const [showAll, setShowAll] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -166,10 +172,17 @@ export function ToolsetSelector({ }); return ( - - + + {/* Command header */} + {"> /toolset"} + {solidLine} + + + + {/* Title */} + - Select Toolset (↑↓ to navigate, Enter to select, ESC to cancel) + Swap your agent's toolset @@ -179,40 +192,36 @@ export function ToolsetSelector({ const isCurrent = toolset.id === currentToolset; return ( - - + + - {isSelected ? "›" : " "} + {isSelected ? "> " : " "} + + + {toolset.label} + {isCurrent && ( + (current) + )} - - - - {toolset.label} - {isCurrent && ( - - {" "} - (current) - - )} - - - {toolset.description} - + + {" "} + {toolset.description} + ); })} {hasShowAllOption && ( - + - {selectedIndex === visibleToolsets.length ? "›" : " "} + {selectedIndex === visibleToolsets.length ? "> " : " "} Show all toolsets )} + + {/* Footer */} + + {" "}Enter select · ↑↓ navigate · Esc cancel + ); }