feat: unify /pinned and /agents into tabbed agent browser (#412)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -72,7 +72,6 @@ import { NewAgentDialog } from "./components/NewAgentDialog";
|
||||
import { OAuthCodeDialog } from "./components/OAuthCodeDialog";
|
||||
import { PinDialog, validateAgentName } from "./components/PinDialog";
|
||||
import { PlanModeDialog } from "./components/PlanModeDialog";
|
||||
import { ProfileSelector } from "./components/ProfileSelector";
|
||||
import { QuestionDialog } from "./components/QuestionDialog";
|
||||
import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
||||
import { ResumeSelector } from "./components/ResumeSelector";
|
||||
@@ -478,7 +477,6 @@ export default function App({
|
||||
| "system"
|
||||
| "agent"
|
||||
| "resume"
|
||||
| "profile"
|
||||
| "search"
|
||||
| "subagent"
|
||||
| "feedback"
|
||||
@@ -2945,8 +2943,14 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /agents command - show agent selector (/resume is hidden alias)
|
||||
if (msg.trim() === "/agents" || msg.trim() === "/resume") {
|
||||
// Special handling for /agents command - show agent browser
|
||||
// /resume, /pinned, /profiles are hidden aliases
|
||||
if (
|
||||
msg.trim() === "/agents" ||
|
||||
msg.trim() === "/resume" ||
|
||||
msg.trim() === "/pinned" ||
|
||||
msg.trim() === "/profiles"
|
||||
) {
|
||||
setActiveOverlay("resume");
|
||||
return { submitted: true };
|
||||
}
|
||||
@@ -2972,9 +2976,9 @@ export default function App({
|
||||
setAgentName,
|
||||
};
|
||||
|
||||
// /profile - open profile selector
|
||||
// /profile - open agent browser (now points to /agents)
|
||||
if (!subcommand) {
|
||||
setActiveOverlay("profile");
|
||||
setActiveOverlay("resume");
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
@@ -3033,12 +3037,6 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /profiles and /pinned commands - open pinned agents selector
|
||||
if (msg.trim() === "/profiles" || msg.trim() === "/pinned") {
|
||||
setActiveOverlay("profile");
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /new command - create new agent dialog
|
||||
if (msg.trim() === "/new") {
|
||||
setActiveOverlay("new");
|
||||
@@ -5508,33 +5506,6 @@ Plan file path: ${planFilePath}`;
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Profile Selector - conditionally mounted as overlay */}
|
||||
{activeOverlay === "profile" && (
|
||||
<ProfileSelector
|
||||
currentAgentId={agentId}
|
||||
onSelect={async (id) => {
|
||||
closeOverlay();
|
||||
await handleAgentSelect(id);
|
||||
}}
|
||||
onUnpin={(unpinAgentId) => {
|
||||
closeOverlay();
|
||||
settingsManager.unpinBoth(unpinAgentId);
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/pinned",
|
||||
output: `Unpinned agent ${unpinAgentId.slice(0, 12)}`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
}}
|
||||
onCancel={closeOverlay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message Search - conditionally mounted as overlay */}
|
||||
{activeOverlay === "search" && (
|
||||
<MessageSearch onClose={closeOverlay} />
|
||||
|
||||
@@ -12,12 +12,12 @@ interface Command {
|
||||
|
||||
export const commands: Record<string, Command> = {
|
||||
// === Page 1: Most commonly used (order 10-19) ===
|
||||
"/pinned": {
|
||||
desc: "Browse pinned agents",
|
||||
"/agents": {
|
||||
desc: "Browse agents (pinned, Letta Code, all)",
|
||||
order: 10,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to open pinned agents selector
|
||||
return "Opening pinned agents...";
|
||||
// Handled specially in App.tsx to open agent browser
|
||||
return "Opening agent browser...";
|
||||
},
|
||||
},
|
||||
"/model": {
|
||||
@@ -85,14 +85,6 @@ export const commands: Record<string, Command> = {
|
||||
return "Creating new agent...";
|
||||
},
|
||||
},
|
||||
"/agents": {
|
||||
desc: "Browse all agents",
|
||||
order: 21,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to show agent selector
|
||||
return "Opening agent selector...";
|
||||
},
|
||||
},
|
||||
"/pin": {
|
||||
desc: "Pin current agent globally, or use -l for local only",
|
||||
order: 22,
|
||||
@@ -336,6 +328,20 @@ export const commands: Record<string, Command> = {
|
||||
return "Opening agent selector...";
|
||||
},
|
||||
},
|
||||
"/pinned": {
|
||||
desc: "Browse pinned agents",
|
||||
hidden: true, // Alias for /agents (opens to Pinned tab)
|
||||
handler: () => {
|
||||
return "Opening agent browser...";
|
||||
},
|
||||
},
|
||||
"/profiles": {
|
||||
desc: "Browse pinned agents",
|
||||
hidden: true, // Alias for /agents (opens to Pinned tab)
|
||||
handler: () => {
|
||||
return "Opening agent browser...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
|
||||
@@ -12,8 +14,35 @@ interface ResumeSelectorProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
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
|
||||
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<TabId, string> = {
|
||||
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<TabId, string> = {
|
||||
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
|
||||
@@ -40,28 +69,34 @@ function formatRelativeTime(dateStr: string | null | undefined): string {
|
||||
|
||||
/**
|
||||
* Truncate agent ID with middle ellipsis if it exceeds available width
|
||||
* e.g., "agent-6b383e6f-f2df-43ed-ad88-8c832f1129d0" -> "agent-6b3...9d0"
|
||||
*/
|
||||
function truncateAgentId(id: string, availableWidth: number): string {
|
||||
if (id.length <= availableWidth) return id;
|
||||
if (availableWidth < 15) return id.slice(0, availableWidth); // Too narrow for ellipsis
|
||||
const prefixLen = Math.floor((availableWidth - 3) / 2); // -3 for "..."
|
||||
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
|
||||
* Format model string to show friendly display name (e.g., "Sonnet 4.5")
|
||||
*/
|
||||
function formatModel(agent: AgentState): string {
|
||||
// Prefer the new model field
|
||||
// Build handle from agent config
|
||||
let handle: string | null = null;
|
||||
if (agent.model) {
|
||||
return agent.model;
|
||||
}
|
||||
// Fall back to llm_config
|
||||
if (agent.llm_config?.model) {
|
||||
handle = agent.model;
|
||||
} else if (agent.llm_config?.model) {
|
||||
const provider = agent.llm_config.model_endpoint_type || "unknown";
|
||||
return `${provider}/${agent.llm_config.model}`;
|
||||
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";
|
||||
}
|
||||
@@ -72,22 +107,88 @@ export function ResumeSelector({
|
||||
onCancel,
|
||||
}: ResumeSelectorProps) {
|
||||
const terminalWidth = useTerminalWidth();
|
||||
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 [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState(""); // What user is typing
|
||||
const [activeQuery, setActiveQuery] = useState(""); // Submitted search query
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [filterLettaCode, setFilterLettaCode] = useState(true); // Filter to only letta-code agents
|
||||
const clientRef = useRef<Letta | null>(null);
|
||||
|
||||
// Fetch agents from the server
|
||||
const fetchAgents = useCallback(
|
||||
async (afterCursor?: string | null, query?: string) => {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabId>("pinned");
|
||||
|
||||
// Pinned tab state
|
||||
const [pinnedAgents, setPinnedAgents] = useState<PinnedAgentData[]>([]);
|
||||
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<AgentState[]>([]);
|
||||
const [lettaCodeCursor, setLettaCodeCursor] = useState<string | null>(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<string | null>(null);
|
||||
const [lettaCodeLoaded, setLettaCodeLoaded] = useState(false);
|
||||
const [lettaCodeQuery, setLettaCodeQuery] = useState<string>(""); // Query used to load current data
|
||||
|
||||
// All tab state (cached separately)
|
||||
const [allAgents, setAllAgents] = useState<AgentState[]>([]);
|
||||
const [allCursor, setAllCursor] = useState<string | null>(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<string | null>(null);
|
||||
const [allLoaded, setAllLoaded] = useState(false);
|
||||
const [allQuery, setAllQuery] = useState<string>(""); // 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;
|
||||
|
||||
@@ -101,48 +202,211 @@ export function ResumeSelector({
|
||||
...(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,
|
||||
};
|
||||
return { agents: agentList.items, nextCursor: cursor };
|
||||
},
|
||||
[filterLettaCode],
|
||||
[],
|
||||
);
|
||||
|
||||
// Fetch agents when activeQuery changes (initial load or search submitted)
|
||||
useEffect(() => {
|
||||
const doFetch = async () => {
|
||||
setLoading(true);
|
||||
// Load Letta Code agents
|
||||
const loadLettaCodeAgents = useCallback(
|
||||
async (query?: string) => {
|
||||
setLettaCodeLoading(true);
|
||||
setLettaCodeError(null);
|
||||
try {
|
||||
const result = await fetchAgents(null, activeQuery || undefined);
|
||||
setAllAgents(result.agents);
|
||||
setNextCursor(result.nextCursor);
|
||||
setHasMore(result.nextCursor !== null);
|
||||
setCurrentPage(0);
|
||||
setSelectedIndex(0);
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLettaCodeError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLettaCodeLoading(false);
|
||||
}
|
||||
};
|
||||
doFetch();
|
||||
}, [fetchAgents, activeQuery]);
|
||||
},
|
||||
[fetchListAgents],
|
||||
);
|
||||
|
||||
// Submit search (called when Enter is pressed while typing search)
|
||||
// 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
|
||||
// Clear search (effect will handle reload when query changes)
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearchInput("");
|
||||
if (activeQuery) {
|
||||
@@ -150,109 +414,267 @@ export function ResumeSelector({
|
||||
}
|
||||
}, [activeQuery]);
|
||||
|
||||
// Fetch more agents when needed
|
||||
const fetchMoreAgents = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || !nextCursor) return;
|
||||
|
||||
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]);
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
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(pageAgents.length - 1, prev + 1));
|
||||
setCurrentSelectedIndex((prev: number) =>
|
||||
Math.min((currentAgents as AgentState[]).length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.return) {
|
||||
// If typing a search query, submit it; otherwise select agent
|
||||
if (searchInput && searchInput !== activeQuery) {
|
||||
// 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 selectedAgent = pageAgents[selectedIndex];
|
||||
if (selectedAgent?.id) {
|
||||
onSelect(selectedAgent.id);
|
||||
const selected = allPageAgents[allSelectedIndex];
|
||||
if (selected?.id) {
|
||||
onSelect(selected.id);
|
||||
}
|
||||
}
|
||||
} else if (key.escape) {
|
||||
// If typing search, clear it first; otherwise cancel
|
||||
if (searchInput) {
|
||||
// If typing search (list tabs), clear it first
|
||||
if (activeTab !== "pinned" && searchInput) {
|
||||
clearSearch();
|
||||
} else {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
onCancel();
|
||||
} else if (key.backspace || key.delete) {
|
||||
setSearchInput((prev) => prev.slice(0, -1));
|
||||
if (activeTab !== "pinned") {
|
||||
setSearchInput((prev) => prev.slice(0, -1));
|
||||
}
|
||||
} else if (input === "j" || input === "J") {
|
||||
// Previous page (j = up/back)
|
||||
if (currentPage > 0) {
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
setSelectedIndex(0);
|
||||
// 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 (k = down/forward)
|
||||
if (canGoNext) {
|
||||
const nextPageIndex = currentPage + 1;
|
||||
// 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;
|
||||
|
||||
// Fetch more if we need data for the next page
|
||||
if (nextStartIndex >= allAgents.length && hasMore) {
|
||||
fetchMoreAgents();
|
||||
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();
|
||||
}
|
||||
|
||||
// Navigate if we have the data
|
||||
if (nextStartIndex < allAgents.length) {
|
||||
setCurrentPage(nextPageIndex);
|
||||
setSelectedIndex(0);
|
||||
setAllPage(nextPageIndex);
|
||||
setAllSelectedIndex(0);
|
||||
}
|
||||
}
|
||||
} else if (input === "/") {
|
||||
// Ignore "/" - just starts typing search
|
||||
} else if (input === "a" || input === "A") {
|
||||
// Toggle filter between letta-code agents and all agents
|
||||
setFilterLettaCode((prev) => !prev);
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Add regular characters to search input
|
||||
} else if (activeTab === "pinned" && (input === "d" || input === "D")) {
|
||||
// Unpin from all (pinned tab only)
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Always show the header, with contextual content below
|
||||
// Render tab bar
|
||||
const renderTabBar = () => (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<Text
|
||||
key={tab.id}
|
||||
color={isActive ? colors.selector.itemHighlighted : undefined}
|
||||
bold={isActive}
|
||||
>
|
||||
[{tab.label}]
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<Box key={agent.id} flexDirection="column" marginBottom={1}>
|
||||
<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>
|
||||
{" · "}
|
||||
{extra?.isLocal !== undefined
|
||||
? `${extra.isLocal ? "project" : "global"} · `
|
||||
: ""}
|
||||
{displayId}
|
||||
</Text>
|
||||
{isCurrent && (
|
||||
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
{agent.description || "No description"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor>
|
||||
{relativeTime} · {blockCount} memory block
|
||||
{blockCount === 1 ? "" : "s"} · {modelStr}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Box key={data.agentId} flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
||||
>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
||||
>
|
||||
{data.agentId.slice(0, 12)}
|
||||
</Text>
|
||||
<Text dimColor> · {data.isLocal ? "project" : "global"}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text color="red" italic>
|
||||
{data.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Box flexDirection="column" gap={1} marginBottom={1}>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Browsing Agents (sorting by last run)
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{filterLettaCode
|
||||
? "Displaying agents created in Letta Code (press A to show all)"
|
||||
: "Displaying all agents (press A to filter to Letta Code)"}
|
||||
Browsing Agents
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
{renderTabBar()}
|
||||
<Text dimColor>{TAB_DESCRIPTIONS[activeTab]}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search input - show when typing or when there's an active search */}
|
||||
{(searchInput || activeQuery) && (
|
||||
<Box>
|
||||
{/* Search input - list tabs only */}
|
||||
{activeTab !== "pinned" && (searchInput || activeQuery) && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>Search: </Text>
|
||||
<Text>{searchInput}</Text>
|
||||
{searchInput && searchInput !== activeQuery && (
|
||||
@@ -264,113 +686,94 @@ export function ResumeSelector({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
{/* Error state - list tabs */}
|
||||
{activeTab !== "pinned" && currentError && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Error: {error}</Text>
|
||||
<Text color="red">Error: {currentError}</Text>
|
||||
<Text dimColor>Press ESC to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && !error && (
|
||||
{currentLoading && (
|
||||
<Box>
|
||||
<Text dimColor>Loading agents...</Text>
|
||||
</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 to search
|
||||
</Text>
|
||||
{!currentLoading &&
|
||||
((activeTab === "pinned" && pinnedAgents.length === 0) ||
|
||||
(activeTab === "letta-code" &&
|
||||
!lettaCodeError &&
|
||||
lettaCodeAgents.length === 0) ||
|
||||
(activeTab === "all" && !allError && allAgents.length === 0)) && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>{TAB_EMPTY_STATES[activeTab]}</Text>
|
||||
<Text dimColor>Press ESC to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Pinned tab content */}
|
||||
{activeTab === "pinned" && !pinnedLoading && pinnedAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{pinnedPageAgents.map((data, index) =>
|
||||
renderPinnedItem(data, index, index === pinnedSelectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Letta Code tab content */}
|
||||
{activeTab === "letta-code" &&
|
||||
!lettaCodeLoading &&
|
||||
!lettaCodeError &&
|
||||
lettaCodeAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{lettaCodePageAgents.map((agent, index) =>
|
||||
renderAgentItem(agent, index, index === lettaCodeSelectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* All tab content */}
|
||||
{activeTab === "all" &&
|
||||
!allLoading &&
|
||||
!allError &&
|
||||
allAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{allPageAgents.map((agent, index) =>
|
||||
renderAgentItem(agent, index, index === allSelectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{!currentLoading &&
|
||||
((activeTab === "pinned" && pinnedAgents.length > 0) ||
|
||||
(activeTab === "letta-code" &&
|
||||
!lettaCodeError &&
|
||||
lettaCodeAgents.length > 0) ||
|
||||
(activeTab === "all" && !allError && allAgents.length > 0)) && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{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...)" : ""}`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Tab switch · ↑↓ navigate · Enter select · J/K page
|
||||
{activeTab === "pinned"
|
||||
? " · P unpin · D unpin all"
|
||||
: " · Type to search"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user