feat: paginated resume (#194)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-12 15:35:53 -08:00
committed by GitHub
parent 226d20fbae
commit a1763d29de

View File

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