feat: improve interactive menu styling (#553)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-15 15:27:41 -08:00
committed by GitHub
parent bbb2c987e5
commit 2120a4787b
15 changed files with 1372 additions and 1915 deletions

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
>
<Box flexDirection="column">
{/* Blank line after commands */}
<Box height={1} />
{/* Discord/version info */}
<Box>
<Text bold>{agentName || "Unnamed"}</Text>
<Text>
{" "}Having issues? Report bugs with /feedback or{" "}
<Link url="https://discord.gg/letta">
<Text>join our Discord </Text>
</Link>
</Text>
</Box>
<Box>
<Text>
{" "}Version: Letta Code v{getVersion()}
</Text>
</Box>
{/* Blank line before agent info */}
<Box height={1} />
{/* Agent name and links */}
<Box>
<Text>{" "}</Text>
<Text bold color={colors.footer.agentName}>
{agentName || "Unnamed"}
</Text>
{isPinned ? (
<Text color="green"> (pinned )</Text>
) : agentName === DEFAULT_AGENT_NAME || !agentName ? (
@@ -55,21 +76,21 @@ export const AgentInfoBar = memo(function AgentInfoBar({
<Text dimColor> · {agentId}</Text>
</Box>
<Box>
<Text dimColor>{" "}</Text>
{isCloudUser && (
<Link
url={`https://app.letta.com/agents/${agentId}${conversationId ? `?conversation=${conversationId}` : ""}`}
>
<Text>Open in ADE </Text>
<Text>Open in ADE </Text>
</Link>
)}
</Box>
<Box>
{isCloudUser && <Text dimColor>{" · "}</Text>}
{isCloudUser && (
<Link url="https://app.letta.com/settings/organization/usage">
<Text>View usage </Text>
<Text>View usage </Text>
</Link>
)}
{!isCloudUser && <Text dimColor> · {serverUrl}</Text>}
{!isCloudUser && <Text dimColor>{serverUrl}</Text>}
</Box>
</Box>
);

View File

@@ -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<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
*/
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<AgentState[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const terminalWidth = useTerminalWidth();
const clientRef = useRef<Letta | null>(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<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;
}
};
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 = () => (
<Box flexDirection="row" gap={2}>
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
// Always use same width (with padding) to prevent jitter when switching tabs
return (
<Text
key={tab.id}
backgroundColor={
isActive ? colors.selector.itemHighlighted : undefined
}
color={isActive ? "black" : 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 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 (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Select Agent ( to navigate, Enter to select, ESC to cancel)
</Text>
</Box>
<Box>
<Text dimColor>Search: </Text>
<Text>{searchQuery || "_"}</Text>
</Box>
{filteredAgents.length === 0 && (
<Box>
<Text dimColor>No agents match your search</Text>
</Box>
)}
{filteredAgents.length > 0 && (
<Box>
<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>
Showing {filteredAgents.length}
{matchingAgents.length > 10 ? ` of ${matchingAgents.length}` : ""}
{debouncedQuery ? " matching" : ""} agents
{" · "}
{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>
);
};
// Calculate horizontal line width
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
return (
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{`> ${command}`}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Header */}
<Box flexDirection="column" gap={1} marginBottom={1}>
<Text bold color={colors.selector.title}>
Swap to a different agent
</Text>
<Box flexDirection="column" paddingLeft={1}>
{renderTabBar()}
<Text dimColor> {TAB_DESCRIPTIONS[activeTab]}</Text>
</Box>
</Box>
{/* Search input - list tabs only */}
{activeTab !== "pinned" && (searchInput || activeQuery) && (
<Box marginBottom={1}>
<Text dimColor>Search: </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 flexDirection="column">
{filteredAgents.map((agent, index) => {
const isSelected = index === selectedIndex;
const isCurrent = agent.id === currentAgentId;
{/* Error state - list tabs */}
{activeTab !== "pinned" && currentError && (
<Box flexDirection="column">
<Text color="red">Error: {currentError}</Text>
<Text dimColor>Press ESC to cancel</Text>
</Box>
)}
const lastInteractedAt = agent.last_run_completion
? new Date(agent.last_run_completion).toLocaleString()
: "Never";
{/* Loading state */}
{currentLoading && (
<Box>
<Text dimColor>{" "}Loading agents...</Text>
</Box>
)}
{/* Empty state */}
{!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)) &&
(() => {
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 (
<Box key={agent.id} flexDirection="row" gap={1}>
<Text
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{isSelected ? "" : " "}
</Text>
<Box flexDirection="row" gap={2}>
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
wrap="truncate-end"
>
{agent.name || "Unnamed"}
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Text>
<Text dimColor wrap="truncate-end">
{agent.id}
</Text>
<Text dimColor wrap="truncate-end">
{lastInteractedAt}
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Box width={2} flexShrink={0} />
<Box flexGrow={1} width={footerWidth}>
<MarkdownDisplay text={pageText} dimColor />
</Box>
</Box>
<Box flexDirection="row">
<Box width={2} flexShrink={0} />
<Box flexGrow={1} width={footerWidth}>
<MarkdownDisplay text={hintsText} dimColor />
</Box>
</Box>
</Box>
);
})}
</Box>
})()}
</Box>
);
}

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
>
<Text dimColor>{header}</Text>
<Box flexDirection="column">
{header && <Text dimColor>{header}</Text>}
{children}
</Box>
);
@@ -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}
</Text>
);

View File

@@ -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 = <Text dimColor>{"⎿ "}</Text>;
const indent = " "; // Same width as "⎿ " for alignment
// Priority 1: Summary
if (conv.summary) {
return (
<Box flexDirection="row" marginLeft={2}>
{bracket}
<Text dimColor italic>
{conv.summary.length > 60
? `${conv.summary.slice(0, 57)}...`
{conv.summary.length > 57
? `${conv.summary.slice(0, 54)}...`
: conv.summary}
</Text>
</Box>
@@ -400,6 +410,7 @@ export function ConversationSelector({
flexDirection="row"
marginLeft={2}
>
{idx === 0 ? bracket : <Text>{indent}</Text>}
<Text dimColor>
{line.role === "assistant" ? "👾 " : "👤 "}
</Text>
@@ -416,6 +427,7 @@ export function ConversationSelector({
if (messageCount > 0) {
return (
<Box flexDirection="row" marginLeft={2}>
{bracket}
<Text dimColor italic>
{messageCount} message{messageCount === 1 ? "" : "s"} (no
in-context user/agent messages)
@@ -426,6 +438,7 @@ export function ConversationSelector({
return (
<Box flexDirection="row" marginLeft={2}>
{bracket}
<Text dimColor italic>
No in-context messages
</Text>
@@ -462,14 +475,22 @@ export function ConversationSelector({
);
};
const terminalWidth = useTerminalWidth();
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
return (
<Box flexDirection="column">
{/* Header */}
<Box flexDirection="column" gap={1} marginBottom={1}>
{/* Command header */}
<Text dimColor>{"> /resume"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
Resume Conversation
Resume a previous conversation
</Text>
<Text dimColor>Select a conversation to resume or start a new one</Text>
</Box>
{/* Error state */}
@@ -505,22 +526,32 @@ export function ConversationSelector({
)}
{/* Footer */}
{!loading && !error && conversations.length > 0 && (
<Box flexDirection="column">
<Box>
<Text dimColor>
Page {page + 1}
{hasMore ? "+" : `/${totalPages || 1}`}
{loadingMore ? " (loading...)" : ""}
</Text>
</Box>
<Box>
<Text dimColor>
navigate · Enter select · J/K page · N new · ESC cancel
</Text>
</Box>
</Box>
)}
{!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 (
<Box flexDirection="column">
<Box flexDirection="row">
<Box width={2} flexShrink={0} />
<Box flexGrow={1} width={footerWidth}>
<MarkdownDisplay text={pageText} dimColor />
</Box>
</Box>
<Box flexDirection="row">
<Box width={2} flexShrink={0} />
<Box flexGrow={1} width={footerWidth}>
<MarkdownDisplay text={hintsText} dimColor />
</Box>
</Box>
</Box>
);
})()}
</Box>
);
}

View File

@@ -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 (
<Box justifyContent="space-between" marginBottom={1}>
{ctrlCPressed ? (
@@ -841,6 +848,7 @@ export function Input({
isOpenAICodexProvider={
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
}
isAutocompleteActive={isAutocompleteActive}
/>
</Box>
</Box>

View File

@@ -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<McpServer[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -444,8 +448,15 @@ export const McpSelector = memo(function McpSelector({
);
return (
<Box flexDirection="column" gap={1}>
<Box>
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /mcp"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
Tools for {viewingServer.server_name}
</Text>
@@ -455,12 +466,11 @@ export const McpSelector = memo(function McpSelector({
{toolsLoading && (
<Box flexDirection="column">
<Text dimColor>
{" "}
{tools.length > 0 ? "Refreshing tools..." : "Loading tools..."}
</Text>
{tools.length === 0 && (
<Text dimColor italic>
This may take a moment on first load
</Text>
<Text dimColor>{" "}This may take a moment on first load</Text>
)}
</Box>
)}
@@ -468,9 +478,12 @@ export const McpSelector = memo(function McpSelector({
{/* Error state */}
{!toolsLoading && toolsError && (
<Box flexDirection="column">
<Text color="yellow">{toolsError}</Text>
<Text color="yellow">
{" "}
{toolsError}
</Text>
<Box marginTop={1}>
<Text dimColor>R refresh from server · Esc back</Text>
<Text dimColor>{" "}R refresh from server · Esc back</Text>
</Box>
</Box>
)}
@@ -478,10 +491,12 @@ export const McpSelector = memo(function McpSelector({
{/* Empty state */}
{!toolsLoading && !toolsError && tools.length === 0 && (
<Box flexDirection="column">
<Text dimColor>No tools available for this server.</Text>
<Text dimColor>Press R to sync tools from the MCP server.</Text>
<Text dimColor>{" "}No tools available for this server.</Text>
<Text dimColor>
{" "}Press R to sync tools from the MCP server.
</Text>
<Box marginTop={1}>
<Text dimColor>R refresh · Esc back</Text>
<Text dimColor>{" "}R refresh · Esc back</Text>
</Box>
</Box>
)}
@@ -505,9 +520,8 @@ export const McpSelector = memo(function McpSelector({
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? ">" : " "}
{isSelected ? "> " : " "}
</Text>
<Text> </Text>
<Text
color={isAttached ? "green" : "gray"}
bold={isAttached}
@@ -525,9 +539,10 @@ export const McpSelector = memo(function McpSelector({
</Text>
</Box>
{/* Row 2: Description */}
<Box flexDirection="row" marginLeft={2}>
<Text dimColor italic>
{truncateText(toolDesc, terminalWidth - 4)}
<Box flexDirection="row">
<Text dimColor>
{" "}
{truncateText(toolDesc, terminalWidth - 6)}
</Text>
</Box>
</Box>
@@ -546,20 +561,17 @@ export const McpSelector = memo(function McpSelector({
).length;
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text dimColor>
{toolsTotalPages > 1 &&
`Page ${toolsPage + 1}/${toolsTotalPages} · `}
{attachedFromThisServer}/{tools.length} attached from server
· {attachedToolIds.size} total on agent
</Text>
</Box>
<Box>
<Text dimColor>
navigate · Space/Enter toggle · A attach all · D detach
all · R refresh · Esc back
</Text>
</Box>
<Text dimColor>
{" "}
{toolsTotalPages > 1 &&
`Page ${toolsPage + 1}/${toolsTotalPages} · `}
{attachedFromThisServer}/{tools.length} attached from server ·{" "}
{attachedToolIds.size} total on agent
</Text>
<Text dimColor>
{" "}Space/Enter toggle · navigate · A attach all · D
detach all · R refresh · Esc back
</Text>
</Box>
);
})()}
@@ -571,15 +583,24 @@ export const McpSelector = memo(function McpSelector({
if (mode === "confirming-delete" && selectedServer) {
const options = ["Yes, delete", "No, cancel"];
return (
<Box flexDirection="column" gap={1}>
<Box>
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /mcp"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
Delete MCP Server
Delete MCP server?
</Text>
</Box>
<Box>
<Text>Delete "{selectedServer.server_name}"?</Text>
</Box>
<Text>
{" "}Delete "{selectedServer.server_name}"?
</Text>
<Box flexDirection="column" marginTop={1}>
{options.map((option, index) => {
const isSelected = index === deleteConfirmIndex;
@@ -591,7 +612,8 @@ export const McpSelector = memo(function McpSelector({
}
bold={isSelected}
>
{isSelected ? ">" : " "} {option}
{isSelected ? "> " : " "}
{option}
</Text>
</Box>
);
@@ -603,26 +625,35 @@ export const McpSelector = memo(function McpSelector({
// Main browsing UI
return (
<Box flexDirection="column" gap={1}>
<Box>
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /mcp"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
MCP Servers
Manage MCP servers
</Text>
</Box>
{/* Loading state */}
{loading && (
<Box>
<Text dimColor>Loading MCP servers...</Text>
<Text dimColor>{" "}Loading MCP servers...</Text>
</Box>
)}
{/* Error state */}
{!loading && error && (
<Box flexDirection="column">
<Text color="red">Error: {error}</Text>
<Text color="red">
{" "}Error: {error}
</Text>
<Box marginTop={1}>
<Text dimColor>R refresh · Esc close</Text>
<Text dimColor>{" "}R refresh · Esc cancel</Text>
</Box>
</Box>
)}
@@ -630,10 +661,10 @@ export const McpSelector = memo(function McpSelector({
{/* Empty state */}
{!loading && !error && servers.length === 0 && (
<Box flexDirection="column">
<Text dimColor>No MCP servers configured.</Text>
<Text dimColor>Press A to add a new server.</Text>
<Text dimColor>{" "}No MCP servers configured.</Text>
<Text dimColor>{" "}Press A to add a new server.</Text>
<Box marginTop={1}>
<Text dimColor>A add · Esc close</Text>
<Text dimColor>{" "}A add · Esc cancel</Text>
</Box>
</Box>
)}
@@ -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 */}
<Box flexDirection="row">
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? ">" : " "}
{isSelected ? "> " : " "}
</Text>
<Text> </Text>
<Text
bold={isSelected}
color={
@@ -687,9 +717,9 @@ export const McpSelector = memo(function McpSelector({
</Box>
{/* Row 2: Server ID if available */}
{server.id && (
<Box flexDirection="row" marginLeft={2}>
<Text dimColor italic>
ID: {server.id}
<Box flexDirection="row">
<Text dimColor>
{" "}ID: {server.id}
</Text>
</Box>
)}
@@ -703,18 +733,14 @@ export const McpSelector = memo(function McpSelector({
{!loading && !error && servers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{totalPages > 1 && (
<Box>
<Text dimColor>
Page {currentPage + 1}/{totalPages}
</Text>
</Box>
)}
<Box>
<Text dimColor>
navigate · Enter view tools · A add · D delete · R refresh ·
Esc close
{" "}Page {currentPage + 1}/{totalPages}
</Text>
</Box>
)}
<Text dimColor>
{" "}Enter view tools · navigate · A add · D delete · R refresh
· Esc cancel
</Text>
</Box>
)}
</Box>

View File

@@ -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 = () => (
<Box flexDirection="row" gap={1} flexWrap="wrap">
{blocks.map((block, index) => {
const isActive = index === selectedTabIndex;
return (
<Text
key={block.id || block.label}
backgroundColor={
isActive ? colors.selector.itemHighlighted : undefined
}
color={isActive ? "black" : undefined}
bold={isActive}
>
{` ${block.label} `}
</Text>
);
})}
</Box>
);
// Empty state
if (blocks.length === 0) {
return (
<Box flexDirection="column">
<Text dimColor>{"> /memory"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
View your agent's memory
</Text>
</Box>
<Text dimColor>{" "}No memory blocks attached to this agent.</Text>
<Box marginTop={1}>
<Text dimColor>{" "}Esc cancel</Text>
</Box>
</Box>
);
}
const charCount = (currentBlock?.value || "").length;
const visibleValueLines = valueLines.slice(
scrollOffset,
scrollOffset + VISIBLE_LINES,
);
const canScrollDown = scrollOffset < maxScrollOffset;
const barColor = colors.selector.itemHighlighted;
return (
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /memory"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
View your agent's memory
</Text>
</Box>
{/* Tab bar */}
<Box flexDirection="column" paddingLeft={1} marginBottom={1}>
{renderTabBar()}
{currentBlock?.description && (
<Box width={terminalWidth - 2}>
<Text dimColor> </Text>
<MarkdownDisplay text={currentBlock.description} dimColor />
</Box>
)}
</Box>
{/* Content area */}
<Box flexDirection="column">
{/* Value content with left border */}
<Box
borderStyle="single"
borderLeft
borderTop={false}
borderBottom={false}
borderRight={false}
borderLeftColor={barColor}
paddingLeft={1}
>
<Text>{visibleValueLines.join("\n") || "(empty)"}</Text>
</Box>
{/* Scroll down indicator or phantom row */}
{canScrollDown ? (
<Text dimColor>
{" "} {maxScrollOffset - scrollOffset} more line
{maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below
</Text>
) : maxScrollOffset > 0 ? (
<Text> </Text>
) : null}
</Box>
{/* Footer */}
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
{" "}
{formatCharCount(charCount, currentBlock?.limit ?? null)}
{currentBlock?.read_only ? " · read-only" : " · read/write"}
</Text>
<Box>
<Text dimColor>{" "}/Tab switch · scroll · </Text>
<Link url={adeUrl}>
<Text dimColor>Edit in ADE</Text>
</Link>
<Text dimColor> · Esc cancel</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -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<number | null>(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 (
<Box flexDirection="column" gap={1}>
<Text bold color={colors.selector.title}>
Memory Blocks
</Text>
<Text dimColor>No memory blocks attached to this agent.</Text>
<Text dimColor>Press ESC to close</Text>
</Box>
);
}
// 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 (
<Box flexDirection="column" gap={1}>
{/* Header */}
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row" gap={1}>
<Text>Viewing the </Text>
<Text bold color={colors.selector.title}>
{detailBlock.label}
</Text>
<Text> block</Text>
{detailBlock.read_only && <Text dimColor> (read-only)</Text>}
</Box>
<Text dimColor>
{formatCharCount(charCount, detailBlock.limit ?? null)}
</Text>
</Box>
<Link url={adeUrl}>
<Text dimColor>View/edit in the ADE</Text>
</Link>
<Text dimColor>/jk to scroll ESC to go back</Text>
{/* Description (up to 3 lines) */}
{descriptionLines.length > 0 && (
<Box flexDirection="column">
{descriptionLines.map((line) => (
<Text key={line.slice(0, 50) || "empty-desc"} dimColor italic>
{line}
</Text>
))}
</Box>
)}
{/* Scrollable value content */}
<Box flexDirection="column">
{/* Scroll up indicator */}
{canScrollUp && (
<Text dimColor>
{scrollOffset} more line{scrollOffset !== 1 ? "s" : ""} above
</Text>
)}
{/* Value content with left border */}
<Box
borderStyle="single"
borderLeft
borderTop={false}
borderBottom={false}
borderRight={false}
borderLeftColor={barColor}
paddingLeft={1}
>
<Text>{visibleValueLines.join("\n")}</Text>
</Box>
{/* Scroll down indicator */}
{canScrollDown && (
<Text dimColor>
{maxScrollOffset - scrollOffset} more line
{maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below
</Text>
)}
</Box>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* Header */}
<Box flexDirection="row" justifyContent="space-between">
<Text bold color={colors.selector.title}>
Memory Blocks ({blocks.length} attached to {agentName || "agent"})
</Text>
{totalPages > 1 && (
<Text dimColor>
Page {currentPage + 1}/{totalPages}
</Text>
)}
</Box>
<Link url={adeUrl}>
<Text dimColor>View/edit in the ADE</Text>
</Link>
<Text dimColor>/jk to navigate Enter to view ESC to close</Text>
{/* Block list */}
<Box flexDirection="column" gap={1}>
{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 (
<Box
key={block.id || block.label}
borderStyle="single"
borderLeft
borderTop={false}
borderBottom={false}
borderRight={false}
borderLeftColor={barColor}
paddingLeft={1}
flexDirection="column"
>
{/* Header row: label + char count */}
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row" gap={1}>
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{block.label}
</Text>
{block.read_only && <Text dimColor>(read-only)</Text>}
</Box>
<Text dimColor>
{formatCharCount(charCount, block.limit ?? null)}
</Text>
</Box>
{/* Description (if available) */}
{block.description && (
<Text dimColor italic>
{block.description.length > 60
? `${block.description.slice(0, 60)}...`
: block.description}
</Text>
)}
{/* Content preview */}
<Text dimColor>{previewText}</Text>
{/* Ellipsis if content is truncated */}
{hasEllipsis && <Text dimColor>...</Text>}
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -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<ModelCategory>("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 = () => (
<Box flexDirection="row" gap={2}>
{MODEL_CATEGORIES.map((cat) => {
const isActive = cat === category;
return (
<Text
key={cat}
backgroundColor={
isActive ? colors.selector.itemHighlighted : undefined
}
color={isActive ? "black" : undefined}
bold={isActive}
>
{` ${getCategoryLabel(cat)} `}
</Text>
);
})}
</Box>
);
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /model"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title and tabs */}
<Box flexDirection="column" gap={1} marginBottom={1}>
<Text bold color={colors.selector.title}>
Select Model ( navigate, /jk page, Tab category, Enter select, ESC
cancel)
Swap your agent's model
</Text>
{!isLoading && !refreshing && (
<Box>
<Text dimColor>Category: </Text>
{MODEL_CATEGORIES.map((cat, i) => (
<Text key={cat}>
{i > 0 && <Text dimColor> · </Text>}
<Text
bold={cat === category}
dimColor={cat !== category}
color={
cat === category
? colors.selector.itemHighlighted
: undefined
}
>
{getCategoryLabel(cat)}
</Text>
</Text>
))}
<Text dimColor> (Tab to switch)</Text>
</Box>
)}
{!isLoading && !refreshing && (
<Box flexDirection="column">
<Text dimColor>
Page {currentPage + 1}/{totalPages}
{isCached ? " · cached" : ""} · 'r' to refresh
</Text>
<Box flexDirection="column" paddingLeft={1}>
{renderTabBar()}
{category === "all" && (
<Text dimColor>Search: {searchQuery || "(type to search)"}</Text>
<Text dimColor> Search: {searchQuery || "(type to filter)"}</Text>
)}
</Box>
)}
</Box>
{/* Loading states */}
{isLoading && (
<Box>
<Box paddingLeft={2}>
<Text dimColor>Loading available models...</Text>
</Box>
)}
{refreshing && (
<Box>
<Box paddingLeft={2}>
<Text dimColor>Refreshing models...</Text>
</Box>
)}
{error && (
<Box>
<Box paddingLeft={2}>
<Text color="yellow">
Warning: Could not fetch available models. Showing all models.
</Text>
@@ -350,7 +343,7 @@ export function ModelSelector({
)}
{!isLoading && !refreshing && visibleModels.length === 0 && (
<Box>
<Box paddingLeft={2}>
<Text dimColor>
{category === "supported"
? "No supported models available."
@@ -359,40 +352,61 @@ export function ModelSelector({
</Box>
)}
{/* Model list */}
<Box flexDirection="column">
{visibleModels.map((model, index) => {
const isSelected = index === selectedIndex;
const actualIndex = startIndex + index;
const isSelected = actualIndex === selectedIndex;
const isCurrent = model.id === currentModelId;
return (
<Box key={model.id} flexDirection="row" gap={1}>
<Box key={model.id} flexDirection="row">
<Text
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{isSelected ? "" : " "}
{isSelected ? "> " : " "}
</Text>
<Box flexDirection="row">
<Text
bold={isSelected}
color={
isSelected
? colors.selector.itemHighlighted
: isCurrent
? colors.selector.itemCurrent
: undefined
}
>
{model.label}
{isCurrent && <Text> (current)</Text>}
</Text>
{model.description && (
<Text dimColor> {model.description}</Text>
)}
</Box>
<Text
bold={isSelected}
color={
isSelected
? colors.selector.itemHighlighted
: isCurrent
? colors.selector.itemCurrent
: undefined
}
>
{model.label}
{isCurrent && <Text> (current)</Text>}
</Text>
{model.description && (
<Text dimColor> · {model.description}</Text>
)}
</Box>
);
})}
{showScrollDown ? (
<Text dimColor>
{" "} {itemsBelow} more below
</Text>
) : currentList.length > visibleCount ? (
<Text> </Text>
) : null}
</Box>
{/* Footer */}
{!isLoading && !refreshing && currentList.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
{" "}
{currentList.length} models{isCached ? " · cached" : ""} · R to
refresh
</Text>
<Text dimColor>
{" "}Enter select · navigate · Tab switch · Esc cancel
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -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<ProfileData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
const [mode, setMode] = useState<Mode>("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 (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Unpin Agent
</Text>
</Box>
<Box>
<Text>Unpin "{selectedProfile.name}" from all locations?</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
{options.map((option, index) => {
const isSelected = index === deleteConfirmIndex;
return (
<Box key={option}>
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
bold={isSelected}
>
{isSelected ? ">" : " "} {option}
</Text>
</Box>
);
})}
</Box>
</Box>
);
}
// Main browsing UI
return (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Pinned Agents
</Text>
</Box>
{/* Loading state */}
{loading && (
<Box>
<Text dimColor>Loading pinned agents...</Text>
</Box>
)}
{/* Empty state */}
{!loading && profiles.length === 0 && (
<Box flexDirection="column">
<Text dimColor>No agents pinned.</Text>
<Text dimColor>Press P to pin the current agent.</Text>
<Box marginTop={1}>
<Text dimColor>Esc to close</Text>
</Box>
</Box>
)}
{/* Profile list */}
{!loading && profiles.length > 0 && (
<Box flexDirection="column">
{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 (
<Box
key={profile.agentId}
flexDirection="column"
marginBottom={1}
>
{/* Row 1: Selection indicator, profile 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
}
>
{profile.name}
</Text>
<Text dimColor>
{" "}
· {profile.isLocal ? "project" : "global"} · {displayId}
</Text>
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Box>
{/* Row 2: Description or error */}
<Box flexDirection="row" marginLeft={2}>
{hasAgent ? (
<Text dimColor italic>
{profile.agent?.description || "No description"}
</Text>
) : (
<Text color="red" italic>
{profile.error}
</Text>
)}
</Box>
{/* Row 3: Metadata (only if agent exists) */}
{hasAgent && profile.agent && (
<Box flexDirection="row" marginLeft={2}>
<Text dimColor>
{formatRelativeTime(profile.agent.last_run_completion)} ·{" "}
{profile.agent.blocks?.length ?? 0} memory block
{(profile.agent.blocks?.length ?? 0) === 1 ? "" : "s"} ·{" "}
{formatModel(profile.agent)}
</Text>
</Box>
)}
</Box>
);
})}
</Box>
)}
{/* Footer with pagination and controls */}
{!loading && profiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{totalPages > 1 && (
<Box>
<Text dimColor>
Page {currentPage + 1}/{totalPages}
</Text>
</Box>
)}
<Box>
<Text dimColor>
navigate · Enter load · P unpin · D unpin all · Esc close
</Text>
</Box>
</Box>
)}
{/* Footer for empty state already handled above */}
</Box>
);
});
ProfileSelector.displayName = "ProfileSelector";

View File

@@ -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<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
*/
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<Letta | null>(null);
// 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;
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 = () => (
<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">
{/* Header */}
<Box flexDirection="column" gap={1} marginBottom={1}>
<Text bold color={colors.selector.title}>
Browsing Agents
</Text>
<Box flexDirection="column">
{renderTabBar()}
<Text dimColor>{TAB_DESCRIPTIONS[activeTab]}</Text>
</Box>
</Box>
{/* Search input - list tabs only */}
{activeTab !== "pinned" && (searchInput || activeQuery) && (
<Box marginBottom={1}>
<Text dimColor>Search: </Text>
<Text>{searchInput}</Text>
{searchInput && searchInput !== activeQuery && (
<Text dimColor> (press Enter to search)</Text>
)}
{activeQuery && searchInput === activeQuery && (
<Text dimColor> (Esc to clear)</Text>
)}
</Box>
)}
{/* Error state - list tabs */}
{activeTab !== "pinned" && currentError && (
<Box flexDirection="column">
<Text color="red">Error: {currentError}</Text>
<Text dimColor>Press ESC to cancel</Text>
</Box>
)}
{/* Loading state */}
{currentLoading && (
<Box>
<Text dimColor>Loading agents...</Text>
</Box>
)}
{/* Empty state */}
{!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" : " · Type to search"}
</Text>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -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 (
<AutocompleteBox header="↑↓ navigate, Tab to autocomplete, Enter to execute">
{showScrollUp && <Text dimColor> {startIndex} more above</Text>}
<AutocompleteBox>
{visibleMatches.map((item, idx) => {
const actualIndex = startIndex + idx;
return (
@@ -197,20 +192,13 @@ export function SlashCommandAutocomplete({
</AutocompleteItem>
);
})}
{showScrollDown && (
{showScrollDown ? (
<Text dimColor>
{" "}
{totalMatches - startIndex - VISIBLE_COMMANDS} more below
{" "} {totalMatches - startIndex - VISIBLE_COMMANDS} more below
</Text>
)}
<Text> </Text>
<Text dimColor>
Having issues? Report bugs with /feedback or{" "}
<Link url="https://discord.gg/letta">
<Text color={colors.link.text}>join our Discord </Text>
</Link>
</Text>
<Text dimColor>Version: Letta Code v{getVersion()}</Text>
) : needsScrolling ? (
<Text> </Text>
) : null}
</AutocompleteBox>
);
}

View File

@@ -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 (
<Box flexDirection="column" gap={1}>
<Box>
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /prompt"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
Select System Prompt ( to navigate, Enter to select, ESC to cancel)
Swap your agent's system prompt
</Text>
</Box>
@@ -74,31 +87,27 @@ export function SystemPromptSelector({
const isCurrent = prompt.id === currentPromptId;
return (
<Box key={prompt.id} flexDirection="row" gap={1}>
<Box key={prompt.id} flexDirection="row">
<Text
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{isSelected ? "" : " "}
{isSelected ? "> " : " "}
</Text>
<Box flexDirection="row">
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{prompt.label}
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Text>
<Text dimColor> {prompt.description}</Text>
</Box>
<Text
bold={isSelected}
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{prompt.label}
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Text>
<Text dimColor> · {prompt.description}</Text>
</Box>
);
})}
{hasShowAllOption && (
<Box flexDirection="row" gap={1}>
<Box flexDirection="row">
<Text
color={
selectedIndex === visiblePrompts.length
@@ -106,12 +115,17 @@ export function SystemPromptSelector({
: undefined
}
>
{selectedIndex === visiblePrompts.length ? "" : " "}
{selectedIndex === visiblePrompts.length ? "> " : " "}
</Text>
<Text dimColor>Show all prompts</Text>
</Box>
)}
</Box>
{/* Footer */}
<Box marginTop={1}>
<Text dimColor>{" "}Enter select · navigate · Esc cancel</Text>
</Box>
</Box>
);
}

View File

@@ -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 (
<Box flexDirection="column" gap={1}>
<Box>
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /toolset"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
Select Toolset ( to navigate, Enter to select, ESC to cancel)
Swap your agent's toolset
</Text>
</Box>
@@ -179,40 +192,36 @@ export function ToolsetSelector({
const isCurrent = toolset.id === currentToolset;
return (
<Box key={toolset.id} flexDirection="column">
<Box flexDirection="row" gap={1}>
<Box key={toolset.id} flexDirection="column" marginBottom={1}>
<Box flexDirection="row">
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? "" : " "}
{isSelected ? "> " : " "}
</Text>
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{toolset.label}
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{toolset.label}
{isCurrent && (
<Text color={colors.selector.itemCurrent}>
{" "}
(current)
</Text>
)}
</Text>
</Box>
<Text dimColor> {toolset.description}</Text>
</Box>
</Box>
<Text dimColor>
{" "}
{toolset.description}
</Text>
</Box>
);
})}
{hasShowAllOption && (
<Box flexDirection="row" gap={1}>
<Box flexDirection="row">
<Text
color={
selectedIndex === visibleToolsets.length
@@ -220,12 +229,17 @@ export function ToolsetSelector({
: undefined
}
>
{selectedIndex === visibleToolsets.length ? "" : " "}
{selectedIndex === visibleToolsets.length ? "> " : " "}
</Text>
<Text dimColor>Show all toolsets</Text>
</Box>
)}
</Box>
{/* Footer */}
<Box marginTop={1}>
<Text dimColor>{" "}Enter select · navigate · Esc cancel</Text>
</Box>
</Box>
);
}