feat: improve interactive menu styling (#553)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
216
src/cli/components/MemoryTabViewer.tsx
Normal file
216
src/cli/components/MemoryTabViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user