diff --git a/src/cli/components/ResumeSelector.tsx b/src/cli/components/ResumeSelector.tsx index 359e08f..92a1c80 100644 --- a/src/cli/components/ResumeSelector.tsx +++ b/src/cli/components/ResumeSelector.tsx @@ -1,6 +1,7 @@ +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 { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; @@ -11,7 +12,8 @@ interface ResumeSelectorProps { onCancel: () => void; } -const PAGE_SIZE = 5; +const DISPLAY_PAGE_SIZE = 5; // How many agents to show per page +const FETCH_PAGE_SIZE = 20; // How many agents to fetch from server at once /** * Format a relative time string from a date @@ -70,87 +72,107 @@ export function ResumeSelector({ onCancel, }: ResumeSelectorProps) { const terminalWidth = useTerminalWidth(); - const [agents, setAgents] = useState([]); + const [allAgents, setAllAgents] = useState([]); // All fetched agents + const [nextCursor, setNextCursor] = useState(null); + const [currentPage, setCurrentPage] = useState(0); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); - const [currentPage, setCurrentPage] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(""); + const [searchInput, setSearchInput] = useState(""); // What user is typing + const [activeQuery, setActiveQuery] = useState(""); // Submitted search query + const [hasMore, setHasMore] = useState(true); + const clientRef = useRef(null); + // Fetch agents from the server + const fetchAgents = useCallback( + async (afterCursor?: string | null, query?: string) => { + const client = clientRef.current || (await getClient()); + clientRef.current = client; + + const agentList = await client.agents.list({ + limit: FETCH_PAGE_SIZE, + include: ["agent.blocks"], + order: "desc", + order_by: "last_run_completion", + ...(afterCursor && { after: afterCursor }), + ...(query && { query_text: query }), + }); + + // Get cursor for next fetch (last item's ID if there are more) + const cursor = + agentList.items.length === FETCH_PAGE_SIZE + ? (agentList.items[agentList.items.length - 1]?.id ?? null) + : null; + + return { + agents: agentList.items, + nextCursor: cursor, + }; + }, + [], + ); + + // Fetch agents when activeQuery changes (initial load or search submitted) useEffect(() => { - const fetchAgents = async () => { + const doFetch = async () => { + setLoading(true); try { - const client = await getClient(); - // Fetch agents with higher limit to ensure we get the current agent - // Include blocks to get memory block count - const agentList = await client.agents.list({ - limit: 200, - include: ["agent.blocks"], - order: "desc", - order_by: "last_run_completion", - }); - - // Sort client-side: most recent first, nulls last - const sorted = [...agentList.items].sort((a, b) => { - const aTime = a.last_run_completion - ? new Date(a.last_run_completion).getTime() - : 0; - const bTime = b.last_run_completion - ? new Date(b.last_run_completion).getTime() - : 0; - // Put nulls (0) at the end - if (aTime === 0 && bTime === 0) return 0; - if (aTime === 0) return 1; - if (bTime === 0) return -1; - // Most recent first - return bTime - aTime; - }); - - setAgents(sorted); - setLoading(false); + const result = await fetchAgents(null, activeQuery || undefined); + setAllAgents(result.agents); + setNextCursor(result.nextCursor); + setHasMore(result.nextCursor !== null); + setCurrentPage(0); + setSelectedIndex(0); } catch (err) { setError(err instanceof Error ? err.message : String(err)); + } finally { setLoading(false); } }; - fetchAgents(); - }, []); + doFetch(); + }, [fetchAgents, activeQuery]); - // Debounce search query (300ms delay) - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQuery(searchQuery); - }, 300); - return () => clearTimeout(timer); - }, [searchQuery]); + // Submit search (called when Enter is pressed while typing search) + const submitSearch = useCallback(() => { + if (searchInput !== activeQuery) { + setActiveQuery(searchInput); + } + }, [searchInput, activeQuery]); - // Filter agents based on debounced search query - const filteredAgents = agents.filter((agent) => { - if (!debouncedQuery) return true; - const query = debouncedQuery.toLowerCase(); - const name = (agent.name || "").toLowerCase(); - const id = (agent.id || "").toLowerCase(); - return name.includes(query) || id.includes(query); - }); + // Clear search + const clearSearch = useCallback(() => { + setSearchInput(""); + if (activeQuery) { + setActiveQuery(""); + } + }, [activeQuery]); - // Pin current agent to top of list (if it matches the filter) - const matchingAgents = [...filteredAgents].sort((a, b) => { - if (a.id === currentAgentId) return -1; - if (b.id === currentAgentId) return 1; - return 0; // Keep sort order for everything else - }); + // Fetch more agents when needed + const fetchMoreAgents = useCallback(async () => { + if (loadingMore || !hasMore || !nextCursor) return; - const totalPages = Math.ceil(matchingAgents.length / PAGE_SIZE); - const startIndex = currentPage * PAGE_SIZE; - const pageAgents = matchingAgents.slice(startIndex, startIndex + PAGE_SIZE); + setLoadingMore(true); + try { + const result = await fetchAgents(nextCursor, activeQuery || undefined); + setAllAgents((prev) => [...prev, ...result.agents]); + setNextCursor(result.nextCursor); + setHasMore(result.nextCursor !== null); + } catch (_err) { + // Silently fail on pagination errors + } finally { + setLoadingMore(false); + } + }, [loadingMore, hasMore, nextCursor, fetchAgents, activeQuery]); - // Reset selected index and page when filtered list changes - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset when query changes - useEffect(() => { - setSelectedIndex(0); - setCurrentPage(0); - }, [debouncedQuery]); + // Calculate display pages from all fetched agents + const totalDisplayPages = Math.ceil(allAgents.length / DISPLAY_PAGE_SIZE); + const startIndex = currentPage * DISPLAY_PAGE_SIZE; + const pageAgents = allAgents.slice( + startIndex, + startIndex + DISPLAY_PAGE_SIZE, + ); + const canGoNext = currentPage < totalDisplayPages - 1 || hasMore; useInput((input, key) => { if (loading || error) return; @@ -160,14 +182,24 @@ export function ResumeSelector({ } else if (key.downArrow) { setSelectedIndex((prev) => Math.min(pageAgents.length - 1, prev + 1)); } else if (key.return) { - const selectedAgent = pageAgents[selectedIndex]; - if (selectedAgent?.id) { - onSelect(selectedAgent.id); + // If typing a search query, submit it; otherwise select agent + if (searchInput && searchInput !== activeQuery) { + submitSearch(); + } else { + const selectedAgent = pageAgents[selectedIndex]; + if (selectedAgent?.id) { + onSelect(selectedAgent.id); + } } } else if (key.escape) { - onCancel(); + // If typing search, clear it first; otherwise cancel + if (searchInput) { + clearSearch(); + } else { + onCancel(); + } } else if (key.backspace || key.delete) { - setSearchQuery((prev) => prev.slice(0, -1)); + setSearchInput((prev) => prev.slice(0, -1)); } else if (input === "j" || input === "J") { // Previous page (j = up/back) if (currentPage > 0) { @@ -176,45 +208,30 @@ export function ResumeSelector({ } } else if (input === "k" || input === "K") { // Next page (k = down/forward) - if (currentPage < totalPages - 1) { - setCurrentPage((prev) => prev + 1); - setSelectedIndex(0); + if (canGoNext) { + const nextPageIndex = currentPage + 1; + const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE; + + // Fetch more if we need data for the next page + if (nextStartIndex >= allAgents.length && hasMore) { + fetchMoreAgents(); + } + + // Navigate if we have the data + if (nextStartIndex < allAgents.length) { + setCurrentPage(nextPageIndex); + setSelectedIndex(0); + } } } else if (input === "/") { - // Ignore "/" - it's shown in help but just starts typing search - // Don't add it to the search query + // Ignore "/" - just starts typing search } else if (input && !key.ctrl && !key.meta) { - // Add regular characters to search query (searches name and ID) - setSearchQuery((prev) => prev + input); + // Add regular characters to search input + setSearchInput((prev) => prev + input); } }); - if (loading) { - return ( - - Loading agents... - - ); - } - - if (error) { - return ( - - Error loading agents: {error} - Press ESC to cancel - - ); - } - - if (agents.length === 0) { - return ( - - No agents found - Press ESC to cancel - - ); - } - + // Always show the header, with contextual content below return ( @@ -223,91 +240,127 @@ export function ResumeSelector({ - {searchQuery && ( + {/* Search input - show when typing or when there's an active search */} + {(searchInput || activeQuery) && ( - Search (name/ID): - {searchQuery} + Search: + {searchInput} + {searchInput && searchInput !== activeQuery && ( + (press Enter to search) + )} + {activeQuery && searchInput === activeQuery && ( + (Esc to clear) + )} )} - - {pageAgents.map((agent, index) => { - const isSelected = index === selectedIndex; - const isCurrent = agent.id === currentAgentId; - - const relativeTime = formatRelativeTime(agent.last_run_completion); - const blockCount = agent.blocks?.length ?? 0; - const modelStr = formatModel(agent); - - // Calculate available width for agent ID - // Row format: "> Name · agent-id (current)" - const nameLen = (agent.name || "Unnamed").length; - const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); // "> " + " · " + " (current)" - const availableForId = Math.max( - 15, - terminalWidth - nameLen - fixedChars, - ); - const displayId = truncateAgentId(agent.id, availableForId); - - return ( - - {/* Row 1: Selection indicator, agent name, and ID */} - - - {isSelected ? ">" : " "} - - - - {agent.name || "Unnamed"} - - · {displayId} - {isCurrent && ( - (current) - )} - - {/* Row 2: Description */} - - - {agent.description || "No description"} - - - {/* Row 3: Metadata (dimmed) */} - - - {relativeTime} · {blockCount} memory block - {blockCount === 1 ? "" : "s"} · {modelStr} - - - - ); - })} - - - {/* Footer with pagination and controls */} - - - - Page {currentPage + 1}/{totalPages || 1} - {matchingAgents.length > 0 && - ` (${matchingAgents.length} agent${matchingAgents.length === 1 ? "" : "s"})`} - + {/* Error state */} + {error && ( + + Error: {error} + Press ESC to cancel + )} + + {/* Loading state */} + {loading && !error && ( - - ↑↓ navigate · Enter select · J/K prev/next page · Type to search · - Esc cancel - + Loading agents... - + )} + + {/* Empty state */} + {!loading && !error && allAgents.length === 0 && ( + + + {activeQuery ? "No matching agents found" : "No agents found"} + + Press ESC to cancel + + )} + + {/* Agent list - only show when loaded and have agents */} + {!loading && !error && allAgents.length > 0 && ( + + {pageAgents.map((agent, index) => { + const isSelected = index === selectedIndex; + const isCurrent = agent.id === currentAgentId; + + const relativeTime = formatRelativeTime(agent.last_run_completion); + const blockCount = agent.blocks?.length ?? 0; + const modelStr = formatModel(agent); + + // Calculate available width for agent ID + // Row format: "> Name · agent-id (current)" + const nameLen = (agent.name || "Unnamed").length; + const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); // "> " + " · " + " (current)" + const availableForId = Math.max( + 15, + terminalWidth - nameLen - fixedChars, + ); + const displayId = truncateAgentId(agent.id, availableForId); + + return ( + + {/* Row 1: Selection indicator, agent name, and ID */} + + + {isSelected ? ">" : " "} + + + + {agent.name || "Unnamed"} + + · {displayId} + {isCurrent && ( + (current) + )} + + {/* Row 2: Description */} + + + {agent.description || "No description"} + + + {/* Row 3: Metadata (dimmed) */} + + + {relativeTime} · {blockCount} memory block + {blockCount === 1 ? "" : "s"} · {modelStr} + + + + ); + })} + + )} + + {/* Footer with pagination and controls - only show when loaded with agents */} + {!loading && !error && allAgents.length > 0 && ( + + + + Page {currentPage + 1} + {hasMore ? "+" : `/${totalDisplayPages || 1}`} + {loadingMore && " (loading...)"} + + + + + ↑↓ navigate · Enter select · J/K page · Type + Enter to search + + + + )} ); }