diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts deleted file mode 100644 index 2aa6bab6..00000000 --- a/src/commands/agent/list.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * List agents command - */ - -import chalk from "chalk"; -import { - listAgents, - getAgentColumns, - type Agent, -} from "../../services/agentService.js"; -import { output, outputError } from "../../utils/output.js"; - -interface ListOptions { - full?: boolean; - name?: string; - search?: string; - public?: boolean; - private?: boolean; - output?: string; -} - -/** Styling rules keyed by column key. Columns not listed render unstyled. */ -const columnStyle: Record string> = { - id: (v) => chalk.dim(v), - created: (v) => chalk.dim(v), - version: (v) => { - // Dim the "pkg@" prefix when present. Use lastIndexOf to skip scoped package @ (e.g. @scope/pkg@1.0) - const at = v.lastIndexOf("@"); - return at > 0 ? chalk.dim(v.slice(0, at + 1)) + v.slice(at + 1) : v; - }, -}; - -function padStyled(raw: string, styled: string, width: number): string { - return styled + " ".repeat(Math.max(0, width - raw.length)); -} - -/** - * Render a table of agents to stdout. Reusable by other commands. - */ -export function printAgentTable(agents: Agent[]): void { - if (agents.length === 0) { - console.log(chalk.dim("No agents found")); - return; - } - - const termWidth = process.stdout.columns || 120; - const columns = getAgentColumns(agents, termWidth, false); - - // Header - const header = columns.map((col) => col.label.padEnd(col.width)).join(""); - console.log(chalk.bold(header)); - console.log(chalk.dim("─".repeat(Math.min(header.length, termWidth)))); - - // Rows - for (const agent of agents) { - const line = columns - .map((col) => { - const raw = col.getValue(agent); - const styleFn = columnStyle[col.key]; - const styled = styleFn ? styleFn(raw) : raw; - return padStyled(raw, styled, col.width); - }) - .join(""); - console.log(line); - } - - console.log(); - console.log( - chalk.dim(`${agents.length} agent${agents.length !== 1 ? "s" : ""}`), - ); -} - -function printTable(agents: Agent[], isPublic: boolean): void { - if (isPublic) { - console.log( - chalk.dim("Showing PUBLIC agents. Use --private to see private agents"), - ); - } else { - console.log( - chalk.dim("Showing PRIVATE agents. Use --public to see public agents"), - ); - } - console.log(); - - printAgentTable(agents); -} - -/** - * Keep only the most recently created agent for each name. - */ -function keepLatestPerName(agents: Agent[]): Agent[] { - const latestByName = new Map(); - for (const agent of agents) { - const existing = latestByName.get(agent.name); - if (!existing || agent.create_time_ms > existing.create_time_ms) { - latestByName.set(agent.name, agent); - } - } - return Array.from(latestByName.values()); -} - -export async function listAgentsCommand(options: ListOptions): Promise { - try { - const result = await listAgents({ - publicOnly: options.public, - privateOnly: options.private, - name: options.name, - search: options.search, - }); - - const agents = options.full - ? result.agents - : keepLatestPerName(result.agents); - - const format = options.output || "text"; - if (format !== "text") { - output(agents, { format, defaultFormat: "json" }); - } else { - printTable(agents, !!options.public); - } - } catch (error) { - outputError("Failed to list agents", error); - } -} diff --git a/src/commands/agent/list.tsx b/src/commands/agent/list.tsx new file mode 100644 index 00000000..f5706a96 --- /dev/null +++ b/src/commands/agent/list.tsx @@ -0,0 +1,830 @@ +/** + * List agents command + */ + +import React from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import figures from "figures"; +import chalk from "chalk"; +import { + listAgents, + listPublicAgents, + deleteAgent, + type Agent, +} from "../../services/agentService.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; +import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; +import { Table, createTextColumn } from "../../components/Table.js"; +import { ActionsPopup } from "../../components/ActionsPopup.js"; +import { SpinnerComponent } from "../../components/Spinner.js"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { SuccessMessage } from "../../components/SuccessMessage.js"; +import { Header } from "../../components/Header.js"; +import { SearchBar } from "../../components/SearchBar.js"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; +import type { Operation } from "../../components/OperationsMenu.js"; +import { formatTimeAgo } from "../../components/ResourceListView.js"; +import { colors } from "../../utils/theme.js"; +import { useViewportHeight } from "../../hooks/useViewportHeight.js"; +import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; +import { useCursorPagination } from "../../hooks/useCursorPagination.js"; +import { useListSearch } from "../../hooks/useListSearch.js"; +import { useNavigation } from "../../store/navigationStore.js"; + +interface ListOptions { + full?: boolean; + name?: string; + search?: string; + public?: boolean; + private?: boolean; + limit?: string; + startingAfter?: string; + output?: string; +} + +interface ColumnDef { + header: string; + raw: (agent: Agent) => string; + styled: (agent: Agent) => string; +} + +const columns: ColumnDef[] = [ + { + header: "NAME", + raw: (a) => a.name, + styled(a) { + return this.raw(a); + }, + }, + { + header: "SOURCE", + raw: (a) => a.source?.type || "-", + styled(a) { + return this.raw(a); + }, + }, + { + header: "VERSION", + raw: (a) => { + const pkg = a.source?.npm?.package_name || a.source?.pip?.package_name; + return pkg ? `${pkg}@${a.version}` : a.version; + }, + styled(a) { + const pkg = a.source?.npm?.package_name || a.source?.pip?.package_name; + return pkg ? chalk.dim(pkg + "@") + a.version : a.version; + }, + }, + { + header: "ID", + raw: (a) => a.id, + styled(a) { + return chalk.dim(a.id); + }, + }, + { + header: "CREATED", + raw: (a) => formatTimeAgo(a.create_time_ms), + styled(a) { + return chalk.dim(this.raw(a)); + }, + }, +]; + +function computeColumnWidths(agents: Agent[]): number[] { + const minPad = 2; + const maxPad = 4; + const termWidth = process.stdout.columns || 120; + + // Min width per column: max of header and all row values, plus minimum padding + const minWidths = columns.map((col) => { + const maxContent = agents.reduce( + (w, a) => Math.max(w, col.raw(a).length), + col.header.length, + ); + return maxContent + minPad; + }); + + const totalMin = minWidths.reduce((s, w) => s + w, 0); + const slack = termWidth - totalMin; + const extraPerCol = Math.min( + maxPad - minPad, + Math.max(0, Math.floor(slack / columns.length)), + ); + + return minWidths.map((w) => w + extraPerCol); +} + +function padStyled(raw: string, styled: string, width: number): string { + return styled + " ".repeat(Math.max(0, width - raw.length)); +} + +/** + * Render a table of agents to stdout. Reusable by other commands. + */ +export function printAgentTable(agents: Agent[]): void { + if (agents.length === 0) { + console.log(chalk.dim("No agents found")); + return; + } + + const widths = computeColumnWidths(agents); + const termWidth = process.stdout.columns || 120; + + // Header + const header = columns.map((col, i) => col.header.padEnd(widths[i])).join(""); + console.log(chalk.bold(header)); + console.log(chalk.dim("─".repeat(Math.min(header.length, termWidth)))); + + // Rows + for (const agent of agents) { + const line = columns + .map((col, i) => padStyled(col.raw(agent), col.styled(agent), widths[i])) + .join(""); + console.log(line); + } + + console.log(); + console.log( + chalk.dim(`${agents.length} agent${agents.length !== 1 ? "s" : ""}`), + ); +} + +function printTable(agents: Agent[], isPublic: boolean): void { + if (isPublic) { + console.log( + chalk.dim("Showing PUBLIC agents. Use --private to see private agents"), + ); + } else { + console.log( + chalk.dim("Showing PRIVATE agents. Use --public to see public agents"), + ); + } + console.log(); + + printAgentTable(agents); +} + +/** + * Keep only the most recently created agent for each name. + */ +function keepLatestPerName(agents: Agent[]): Agent[] { + const latestByName = new Map(); + for (const agent of agents) { + const existing = latestByName.get(agent.name); + if (!existing || agent.create_time_ms > existing.create_time_ms) { + latestByName.set(agent.name, agent); + } + } + return Array.from(latestByName.values()); +} + +// ─── TUI Component ─────────────────────────────────────────────────────────── + +type AgentTab = "private" | "public"; + +export const ListAgentsUI = ({ + onBack, + onExit, +}: { + onBack?: () => void; + onExit?: () => void; +}) => { + const { exit: inkExit } = useApp(); + const { navigate } = useNavigation(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [showPopup, setShowPopup] = React.useState(false); + const [selectedOperation, setSelectedOperation] = React.useState(0); + const [activeTab, setActiveTab] = React.useState("private"); + + // Delete state + const [selectedAgent, setSelectedAgent] = React.useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const [executingOperation, setExecutingOperation] = React.useState< + string | null + >(null); + const [operationResult, setOperationResult] = React.useState( + null, + ); + const [operationError, setOperationError] = React.useState( + null, + ); + const [operationLoading, setOperationLoading] = React.useState(false); + const [needsRefresh, setNeedsRefresh] = React.useState(false); + + // Search state + const search = useListSearch({ + onSearchSubmit: () => setSelectedIndex(0), + onSearchClear: () => setSelectedIndex(0), + }); + + const overhead = 14 + search.getSearchOverhead(); + const { viewportHeight, terminalWidth } = useViewportHeight({ + overhead, + minHeight: 5, + }); + + const PAGE_SIZE = viewportHeight; + + // Column widths + const fixedWidth = 6; + const idWidth = 25; + const versionWidth = 20; + const sourceWidth = 10; + const timeWidth = 18; + const showSourceColumn = terminalWidth >= 100; + const showVersionColumn = terminalWidth >= 85; + const baseWidth = + fixedWidth + + idWidth + + timeWidth + + (showVersionColumn ? versionWidth : 0) + + (showSourceColumn ? sourceWidth : 0); + const nameWidth = Math.min(40, Math.max(15, terminalWidth - baseWidth)); + + const fetchPage = React.useCallback( + async (params: { + limit: number; + startingAt?: string; + includeTotalCount?: boolean; + }) => { + const fetchFn = activeTab === "public" ? listPublicAgents : listAgents; + const result = await fetchFn({ + limit: params.limit, + startingAfter: params.startingAt, + search: search.submittedSearchQuery || undefined, + includeTotalCount: params.includeTotalCount, + privateOnly: activeTab === "private" ? true : undefined, + }); + return { + items: result.agents, + hasMore: result.hasMore, + totalCount: result.totalCount, + }; + }, + [search.submittedSearchQuery, activeTab], + ); + + const { + items: agents, + loading, + navigating, + error, + currentPage, + hasMore, + hasPrev, + totalCount, + nextPage, + prevPage, + refresh, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (agent: Agent) => agent.id, + pollInterval: 5000, + pollingEnabled: + !showPopup && + !showDeleteConfirm && + !executingOperation && + !search.searchMode, + deps: [PAGE_SIZE, search.submittedSearchQuery, activeTab], + }); + + const operations: Operation[] = React.useMemo(() => { + const ops: Operation[] = [ + { + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }, + ]; + if (activeTab !== "public") { + ops.push({ + key: "delete", + label: "Delete", + color: colors.error, + icon: figures.cross, + }); + } + return ops; + }, [activeTab]); + + const tableColumns = React.useMemo( + () => [ + createTextColumn("id", "ID", (a: Agent) => a.id, { + width: idWidth + 1, + color: colors.idColor, + dimColor: false, + bold: false, + }), + createTextColumn("name", "Name", (a: Agent) => a.name, { + width: nameWidth, + }), + createTextColumn( + "version", + "Version", + (a: Agent) => { + if (a.source?.type === "object") return ""; + const v = a.version || ""; + if (v.length > 16) return `${v.slice(0, 8)}…${v.slice(-4)}`; + return v; + }, + { + width: versionWidth, + color: colors.textDim, + dimColor: false, + bold: false, + visible: showVersionColumn, + }, + ), + createTextColumn( + "source", + "Source", + (a: Agent) => a.source?.type || "-", + { + width: sourceWidth, + color: colors.textDim, + dimColor: false, + bold: false, + visible: showSourceColumn, + }, + ), + createTextColumn( + "created", + "Created", + (a: Agent) => (a.create_time_ms ? formatTimeAgo(a.create_time_ms) : ""), + { + width: timeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ], + [ + idWidth, + nameWidth, + versionWidth, + sourceWidth, + timeWidth, + showVersionColumn, + showSourceColumn, + ], + ); + + useExitOnCtrlC(); + + // Refresh list after a successful delete + React.useEffect(() => { + if (needsRefresh) { + setNeedsRefresh(false); + refresh(); + } + }, [needsRefresh, refresh]); + + React.useEffect(() => { + if (agents.length > 0 && selectedIndex >= agents.length) { + setSelectedIndex(Math.max(0, agents.length - 1)); + } + }, [agents.length, selectedIndex]); + + const selectedAgentItem = agents[selectedIndex]; + + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const startIndex = currentPage * PAGE_SIZE; + const endIndex = + totalCount > 0 + ? Math.min(startIndex + agents.length, totalCount) + : startIndex + agents.length; + const showingRange = navigating + ? `${startIndex + 1}+` + : endIndex === startIndex + 1 + ? `${startIndex + 1}` + : `${startIndex + 1}-${endIndex}`; + + const executeOperation = async (agent: Agent, operationKey: string) => { + if (!agent) return; + try { + setOperationLoading(true); + switch (operationKey) { + case "delete": + await deleteAgent(agent.id); + setOperationResult(`Agent ${agent.name} deleted successfully`); + break; + } + } catch (err) { + setOperationError(err as Error); + } finally { + setOperationLoading(false); + } + }; + + useInput((input, key) => { + if (search.searchMode) { + if (key.escape) { + search.cancelSearch(); + } + return; + } + + if (operationResult || operationError) { + if (input === "q" || key.escape || key.return) { + const wasDelete = executingOperation === "delete"; + const hadError = operationError !== null; + setOperationResult(null); + setOperationError(null); + setExecutingOperation(null); + setSelectedAgent(null); + if (wasDelete && !hadError) { + setNeedsRefresh(true); + } + } + return; + } + + if (showPopup) { + if (key.upArrow && selectedOperation > 0) { + setSelectedOperation(selectedOperation - 1); + } else if (key.downArrow && selectedOperation < operations.length - 1) { + setSelectedOperation(selectedOperation + 1); + } else if (key.return) { + setShowPopup(false); + const operationKey = operations[selectedOperation].key; + if (operationKey === "view_details") { + navigate("agent-detail", { agentId: selectedAgentItem.id }); + } else if (operationKey === "delete") { + setSelectedAgent(selectedAgentItem); + setShowDeleteConfirm(true); + } + } else if (input === "v" && selectedAgentItem) { + setShowPopup(false); + navigate("agent-detail", { agentId: selectedAgentItem.id }); + } else if (input === "d" && activeTab !== "public") { + setShowPopup(false); + setSelectedAgent(selectedAgentItem); + setShowDeleteConfirm(true); + } else if (key.escape || input === "q") { + setShowPopup(false); + setSelectedOperation(0); + } + return; + } + + // Tab switching + if (key.tab) { + setActiveTab((prev) => (prev === "private" ? "public" : "private")); + setSelectedIndex(0); + return; + } + + const pageAgents = agents.length; + + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pageAgents - 1); + } else if (key.downArrow && selectedIndex < pageAgents - 1) { + setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pageAgents - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "n" || key.rightArrow) && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "p" || key.leftArrow) && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(0); + } else if (key.return && selectedAgentItem) { + navigate("agent-detail", { agentId: selectedAgentItem.id }); + } else if (input === "a" && selectedAgentItem) { + setShowPopup(true); + setSelectedOperation(0); + } else if (input === "c" && activeTab === "private") { + navigate("agent-create"); + } else if (input === "/") { + search.enterSearchMode(); + } else if (key.escape) { + if (search.handleEscape()) { + return; + } + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + } + }); + + // Operation result display + if (operationResult || operationError) { + return ( + <> + +
+ {operationResult && } + {operationError && ( + + )} + + + ); + } + + // Delete confirmation + if (showDeleteConfirm && selectedAgent) { + return ( + { + setShowDeleteConfirm(false); + setExecutingOperation("delete"); + executeOperation(selectedAgent, "delete"); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setSelectedAgent(null); + }} + /> + ); + } + + // Operation loading + if (operationLoading && selectedAgent) { + return ( + <> + + + + ); + } + + // Loading state + if (loading && agents.length === 0) { + return ( + <> + + + + ); + } + + // Error state + if (error) { + return ( + <> + + + + ); + } + + // Main list view + return ( + <> + + + {/* Tab bar */} + + + {activeTab === "private" ? figures.pointer : " "} Private + + + + {activeTab === "public" ? figures.pointer : " "} Public + + + {" "} + (Tab to switch) + + + + + + {!showPopup && ( + a.id} + selectedIndex={selectedIndex} + title={`agents[${totalCount}]`} + columns={tableColumns} + emptyState={ + + {figures.info} No {activeTab} agents found. + + } + /> + )} + + {!showPopup && ( + + {totalCount > 0 && ( + <> + + {figures.hamburger} {totalCount} + + + {" "} + total + + + )} + {totalCount > 0 && totalPages > 1 && ( + <> + + {" "} + •{" "} + + {navigating ? ( + + {figures.pointer} Loading page {currentPage + 1}... + + ) : ( + + Page {currentPage + 1} of {totalPages} + + )} + + )} + {endIndex > startIndex && ( + <> + + {totalCount > 0 ? " • " : ""} + + + Showing {showingRange} + {totalCount > 0 ? ` of ${totalCount}` : ""} + + + )} + {search.submittedSearchQuery && ( + <> + + {" "} + •{" "} + + + Filtered: "{search.submittedSearchQuery}" + + + )} + + )} + + {showPopup && selectedAgentItem && ( + + ({ + key: op.key, + label: op.label, + color: op.color, + icon: op.icon, + shortcut: + op.key === "view_details" + ? "v" + : op.key === "delete" + ? "d" + : "", + }))} + selectedOperation={selectedOperation} + onClose={() => setShowPopup(false)} + /> + + )} + + + + ); +}; + +const CLI_PAGE_SIZE = 100; + +export async function listAgentsCommand(options: ListOptions): Promise { + try { + const maxResults = parseLimit(options.limit); + + let agents: Agent[]; + + if (options.startingAfter) { + const pageLimit = maxResults === Infinity ? CLI_PAGE_SIZE : maxResults; + const { agents: page, hasMore } = await listAgents({ + limit: pageLimit, + startingAfter: options.startingAfter, + publicOnly: options.public, + privateOnly: options.private, + name: options.name, + search: options.search, + }); + agents = options.full ? page : keepLatestPerName(page); + if (hasMore && agents.length > 0) { + console.log( + chalk.dim( + "More results may be available; use --starting-after with the last ID to continue.", + ), + ); + console.log(); + } + } else { + const all: Agent[] = []; + let cursor: string | undefined; + while (all.length < maxResults) { + const remaining = maxResults - all.length; + const pageLimit = Math.min(CLI_PAGE_SIZE, remaining); + const { agents: page, hasMore } = await listAgents({ + limit: pageLimit, + startingAfter: cursor, + publicOnly: options.public, + privateOnly: options.private, + name: options.name, + search: options.search, + }); + all.push(...page); + if (!hasMore || page.length === 0) { + break; + } + cursor = page[page.length - 1].id; + } + agents = options.full ? all : keepLatestPerName(all); + } + + output(agents, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list agents", error); + } +} diff --git a/src/commands/axon/list.ts b/src/commands/axon/list.ts deleted file mode 100644 index d27702fe..00000000 --- a/src/commands/axon/list.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * List active axons (beta) - */ - -import chalk from "chalk"; -import { formatTimeAgo } from "../../components/ResourceListView.js"; -import { listActiveAxons, type Axon } from "../../services/axonService.js"; -import { output, outputError, parseLimit } from "../../utils/output.js"; - -interface ListOptions { - limit?: string; - startingAfter?: string; - output?: string; -} - -const PAGE_SIZE = 100; - -function printTable(axons: Axon[]): void { - if (axons.length === 0) { - console.log(chalk.dim("No active axons found")); - return; - } - - const COL_ID = 34; - const COL_NAME = 28; - const COL_CREATED = 12; - - const header = - "ID".padEnd(COL_ID) + - " " + - "NAME".padEnd(COL_NAME) + - " " + - "CREATED".padEnd(COL_CREATED); - console.log(chalk.bold(header)); - console.log(chalk.dim("─".repeat(header.length))); - - for (const axon of axons) { - const id = - axon.id.length > COL_ID ? axon.id.slice(0, COL_ID - 1) + "…" : axon.id; - const nameRaw = axon.name ?? ""; - const name = - nameRaw.length > COL_NAME - ? nameRaw.slice(0, COL_NAME - 1) + "…" - : nameRaw; - const created = formatTimeAgo(axon.created_at_ms); - console.log( - `${id.padEnd(COL_ID)} ${name.padEnd(COL_NAME)} ${created.padEnd(COL_CREATED)}`, - ); - } - - console.log(); - console.log( - chalk.dim(`${axons.length} axon${axons.length !== 1 ? "s" : ""}`), - ); -} - -export async function listAxonsCommand(options: ListOptions): Promise { - try { - const maxResults = parseLimit(options.limit); - const format = options.output || "text"; - - let axons: Axon[]; - - if (options.startingAfter) { - const pageLimit = maxResults === Infinity ? PAGE_SIZE : maxResults; - const { axons: page, hasMore } = await listActiveAxons({ - limit: pageLimit, - startingAfter: options.startingAfter, - }); - axons = page; - if (format === "text" && hasMore && axons.length > 0) { - console.log( - chalk.dim( - "More results may be available; use --starting-after with the last ID to continue.", - ), - ); - console.log(); - } - } else { - const all: Axon[] = []; - let cursor: string | undefined; - while (all.length < maxResults) { - const remaining = maxResults - all.length; - const pageLimit = Math.min(PAGE_SIZE, remaining); - const { axons: page, hasMore } = await listActiveAxons({ - limit: pageLimit, - startingAfter: cursor, - }); - all.push(...page); - if (!hasMore || page.length === 0) { - break; - } - cursor = page[page.length - 1].id; - } - axons = all; - } - - if (format !== "text") { - output(axons, { format, defaultFormat: "json" }); - } else { - printTable(axons); - } - } catch (error) { - outputError("Failed to list active axons", error); - } -} diff --git a/src/commands/axon/list.tsx b/src/commands/axon/list.tsx new file mode 100644 index 00000000..3f3d6e0c --- /dev/null +++ b/src/commands/axon/list.tsx @@ -0,0 +1,487 @@ +/** + * List active axons (beta) + */ + +import React from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import figures from "figures"; +import chalk from "chalk"; +import { formatTimeAgo } from "../../components/ResourceListView.js"; +import { listActiveAxons, type Axon } from "../../services/axonService.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; +import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; +import { Table, createTextColumn } from "../../components/Table.js"; +import { ActionsPopup } from "../../components/ActionsPopup.js"; +import { SpinnerComponent } from "../../components/Spinner.js"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { SearchBar } from "../../components/SearchBar.js"; +import type { Operation } from "../../components/OperationsMenu.js"; +import { colors } from "../../utils/theme.js"; +import { useViewportHeight } from "../../hooks/useViewportHeight.js"; +import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; +import { useCursorPagination } from "../../hooks/useCursorPagination.js"; +import { useListSearch } from "../../hooks/useListSearch.js"; +import { useNavigation } from "../../store/navigationStore.js"; + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +interface ListOptions { + limit?: string; + startingAfter?: string; + output?: string; +} + +const CLI_PAGE_SIZE = 100; + +function printTable(axons: Axon[]): void { + if (axons.length === 0) { + console.log(chalk.dim("No active axons found")); + return; + } + + const COL_ID = 34; + const COL_NAME = 28; + const COL_CREATED = 12; + + const header = + "ID".padEnd(COL_ID) + + " " + + "NAME".padEnd(COL_NAME) + + " " + + "CREATED".padEnd(COL_CREATED); + console.log(chalk.bold(header)); + console.log(chalk.dim("─".repeat(header.length))); + + for (const axon of axons) { + const id = + axon.id.length > COL_ID ? axon.id.slice(0, COL_ID - 1) + "…" : axon.id; + const nameRaw = axon.name ?? ""; + const name = + nameRaw.length > COL_NAME + ? nameRaw.slice(0, COL_NAME - 1) + "…" + : nameRaw; + const created = formatTimeAgo(axon.created_at_ms); + console.log( + `${id.padEnd(COL_ID)} ${name.padEnd(COL_NAME)} ${created.padEnd(COL_CREATED)}`, + ); + } + + console.log(); + console.log( + chalk.dim(`${axons.length} axon${axons.length !== 1 ? "s" : ""}`), + ); +} + +export async function listAxonsCommand(options: ListOptions): Promise { + try { + const maxResults = parseLimit(options.limit); + const format = options.output; + + let axons: Axon[]; + + if (options.startingAfter) { + const pageLimit = maxResults === Infinity ? CLI_PAGE_SIZE : maxResults; + const { axons: page, hasMore } = await listActiveAxons({ + limit: pageLimit, + startingAfter: options.startingAfter, + }); + axons = page; + if (format === "text" && hasMore && axons.length > 0) { + console.log( + chalk.dim( + "More results may be available; use --starting-after with the last ID to continue.", + ), + ); + console.log(); + } + } else { + const all: Axon[] = []; + let cursor: string | undefined; + while (all.length < maxResults) { + const remaining = maxResults - all.length; + const pageLimit = Math.min(CLI_PAGE_SIZE, remaining); + const { axons: page, hasMore } = await listActiveAxons({ + limit: pageLimit, + startingAfter: cursor, + }); + all.push(...page); + if (!hasMore || page.length === 0) { + break; + } + cursor = page[page.length - 1].id; + } + axons = all; + } + + output(axons, { format, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list active axons", error); + } +} + +// ─── TUI Component ────────────────────────────────────────────────────────── + +export const ListAxonsUI = ({ + onBack, + onExit, +}: { + onBack?: () => void; + onExit?: () => void; +}) => { + const { exit: inkExit } = useApp(); + const { navigate } = useNavigation(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [showPopup, setShowPopup] = React.useState(false); + const [selectedOperation, setSelectedOperation] = React.useState(0); + + // Search state + const search = useListSearch({ + onSearchSubmit: () => setSelectedIndex(0), + onSearchClear: () => setSelectedIndex(0), + }); + + const overhead = 13 + search.getSearchOverhead(); + const { viewportHeight, terminalWidth } = useViewportHeight({ + overhead, + minHeight: 5, + }); + + const PAGE_SIZE = viewportHeight; + + // Column widths + const fixedWidth = 6; + const idWidth = 30; + const timeWidth = 18; + const baseWidth = fixedWidth + idWidth + timeWidth; + const nameWidth = Math.min(40, Math.max(15, terminalWidth - baseWidth)); + + const fetchPage = React.useCallback( + async (params: { + limit: number; + startingAt?: string; + includeTotalCount?: boolean; + }) => { + const result = await listActiveAxons({ + limit: params.limit, + startingAfter: params.startingAt, + search: search.submittedSearchQuery || undefined, + includeTotalCount: params.includeTotalCount, + }); + return { + items: result.axons, + hasMore: result.hasMore, + totalCount: result.totalCount, + }; + }, + [search.submittedSearchQuery], + ); + + const { + items: axons, + loading, + navigating, + error, + currentPage, + hasMore, + hasPrev, + totalCount, + nextPage, + prevPage, + refresh, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (axon: Axon) => axon.id, + pollInterval: 5000, + pollingEnabled: !showPopup && !search.searchMode, + deps: [PAGE_SIZE, search.submittedSearchQuery], + }); + + const operations: Operation[] = React.useMemo( + () => [ + { + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }, + ], + [], + ); + + const tableColumns = React.useMemo( + () => [ + createTextColumn("id", "ID", (a: Axon) => a.id, { + width: idWidth + 1, + color: colors.idColor, + dimColor: false, + bold: false, + }), + createTextColumn("name", "Name", (a: Axon) => a.name ?? "—", { + width: nameWidth, + }), + createTextColumn( + "created", + "Created", + (a: Axon) => (a.created_at_ms ? formatTimeAgo(a.created_at_ms) : ""), + { + width: timeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ], + [idWidth, nameWidth, timeWidth], + ); + + useExitOnCtrlC(); + + React.useEffect(() => { + if (axons.length > 0 && selectedIndex >= axons.length) { + setSelectedIndex(Math.max(0, axons.length - 1)); + } + }, [axons.length, selectedIndex]); + + const selectedAxonItem = axons[selectedIndex]; + + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const startIndex = currentPage * PAGE_SIZE; + const endIndex = + totalCount > 0 + ? Math.min(startIndex + axons.length, totalCount) + : startIndex + axons.length; + const showingRange = navigating + ? `${startIndex + 1}+` + : endIndex === startIndex + 1 + ? `${startIndex + 1}` + : `${startIndex + 1}-${endIndex}`; + + useInput((input, key) => { + if (search.searchMode) { + if (key.escape) { + search.cancelSearch(); + } + return; + } + + if (showPopup) { + if (key.upArrow && selectedOperation > 0) { + setSelectedOperation(selectedOperation - 1); + } else if (key.downArrow && selectedOperation < operations.length - 1) { + setSelectedOperation(selectedOperation + 1); + } else if (key.return) { + setShowPopup(false); + const operationKey = operations[selectedOperation].key; + if (operationKey === "view_details") { + navigate("axon-detail", { axonId: selectedAxonItem.id }); + } + } else if (input === "v" && selectedAxonItem) { + setShowPopup(false); + navigate("axon-detail", { axonId: selectedAxonItem.id }); + } else if (key.escape || input === "q") { + setShowPopup(false); + setSelectedOperation(0); + } + return; + } + + const pageAxons = axons.length; + + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pageAxons - 1); + } else if (key.downArrow && selectedIndex < pageAxons - 1) { + setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pageAxons - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "n" || key.rightArrow) && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "p" || key.leftArrow) && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(0); + } else if (key.return && selectedAxonItem) { + navigate("axon-detail", { axonId: selectedAxonItem.id }); + } else if (input === "a" && selectedAxonItem) { + setShowPopup(true); + setSelectedOperation(0); + } else if (input === "/") { + search.enterSearchMode(); + } else if (key.escape) { + if (search.handleEscape()) { + return; + } + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + } + }); + + // Loading state + if (loading && axons.length === 0) { + return ( + <> + + + + ); + } + + // Error state + if (error) { + return ( + <> + + + + ); + } + + // Main list view + return ( + <> + + + + + {!showPopup && ( +
a.id} + selectedIndex={selectedIndex} + title={`axons[${totalCount}]`} + columns={tableColumns} + emptyState={ + {figures.info} No axons found. + } + /> + )} + + {!showPopup && ( + + {totalCount > 0 && ( + <> + + {figures.hamburger} {totalCount} + + + {" "} + total + + + )} + {totalCount > 0 && totalPages > 1 && ( + <> + + {" "} + •{" "} + + {navigating ? ( + + {figures.pointer} Loading page {currentPage + 1}... + + ) : ( + + Page {currentPage + 1} of {totalPages} + + )} + + )} + {endIndex > startIndex && ( + <> + + {totalCount > 0 ? " • " : ""} + + + Showing {showingRange} + {totalCount > 0 ? ` of ${totalCount}` : ""} + + + )} + {search.submittedSearchQuery && ( + <> + + {" "} + •{" "} + + + Filtered: "{search.submittedSearchQuery}" + + + )} + + )} + + {showPopup && selectedAxonItem && ( + + ({ + key: op.key, + label: op.label, + color: op.color, + icon: op.icon, + shortcut: op.key === "view_details" ? "v" : "", + }))} + selectedOperation={selectedOperation} + onClose={() => setShowPopup(false)} + /> + + )} + + + + ); +}; diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 746f2a5b..c64f0257 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -969,7 +969,7 @@ const ListBlueprintsUI = ({ {showPopup && selectedBlueprintItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 20de58b5..7f33a874 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -797,7 +797,7 @@ const ListDevboxesUI = ({ {showPopup && selectedDevbox && ( setShowPopup(false)} diff --git a/src/commands/gateway-config/list.tsx b/src/commands/gateway-config/list.tsx index 09650469..535f6a42 100644 --- a/src/commands/gateway-config/list.tsx +++ b/src/commands/gateway-config/list.tsx @@ -740,7 +740,7 @@ const ListGatewayConfigsUI = ({ {showPopup && selectedConfigItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/mcp-config/list.tsx b/src/commands/mcp-config/list.tsx index 4fe13af7..8215194c 100644 --- a/src/commands/mcp-config/list.tsx +++ b/src/commands/mcp-config/list.tsx @@ -664,7 +664,7 @@ const ListMcpConfigsUI = ({ {showPopup && selectedConfigItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index d0e3f572..09ede631 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -769,7 +769,7 @@ const ListNetworkPoliciesUI = ({ {showPopup && selectedPolicyItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 1bffd700..5a1cf1bc 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -375,7 +375,7 @@ const ListObjectsUI = ({ switch (operationKey) { case "delete": await client.objects.delete(obj.id); - setOperationResult(`Storage object ${obj.id} deleted successfully`); + setOperationResult(`Object ${obj.id} deleted successfully`); break; case "download": { if (!targetPath) { @@ -583,7 +583,7 @@ const ListObjectsUI = ({ <> -
+
{figures.arrowRight} Downloading:{" "} @@ -650,11 +650,11 @@ const ListObjectsUI = ({ if (showDeleteConfirm && selectedObject) { return ( o.key === executingOperation)?.label || "Operation"; const messages: Record = { - delete: "Deleting storage object...", + delete: "Deleting object...", download: "Downloading...", }; return ( <> - - + + ); } @@ -711,8 +711,8 @@ const ListObjectsUI = ({ if (error) { return ( <> - - + + ); } @@ -720,7 +720,7 @@ const ListObjectsUI = ({ // Main list view return ( <> - + {/* Search bar */} {/* Table - hide when popup is shown */} @@ -739,12 +739,11 @@ const ListObjectsUI = ({ data={objects} keyExtractor={(obj: ObjectListItem) => obj.id} selectedIndex={selectedIndex} - title={`storage_objects[${totalCount}]`} + title={`objects[${totalCount}]`} columns={columns} emptyState={ - {figures.info} No storage objects found. Try: rli object upload{" "} - {""} + {figures.info} No objects found. Try: rli object upload {""} } /> @@ -810,7 +809,7 @@ const ListObjectsUI = ({ {showPopup && selectedObjectItem && ( ({ key: op.key, label: op.label, @@ -905,6 +904,6 @@ export async function listObjects(options: ListOptions) { output(allObjects, { format: options.output, defaultFormat: "json" }); } catch (error) { - outputError("Failed to list storage objects", error); + outputError("Failed to list objects", error); } } diff --git a/src/commands/secret/list.tsx b/src/commands/secret/list.tsx index 58e7e56b..725deacb 100644 --- a/src/commands/secret/list.tsx +++ b/src/commands/secret/list.tsx @@ -611,7 +611,7 @@ const ListSecretsUI = ({ {showPopup && selectedSecretItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 153f2888..7390ca15 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -682,7 +682,7 @@ const ListSnapshotsUI = ({ {showPopup && selectedSnapshotItem && ( ({ key: op.key, label: op.label, diff --git a/src/components/ActionsPopup.tsx b/src/components/ActionsPopup.tsx index b39db66a..15587ac4 100644 --- a/src/components/ActionsPopup.tsx +++ b/src/components/ActionsPopup.tsx @@ -11,7 +11,7 @@ interface ResourceWithId { } interface ActionsPopupProps { - devbox: ResourceWithId; + resource: ResourceWithId; operations: Array<{ key: string; label: string; @@ -24,7 +24,7 @@ interface ActionsPopupProps { } export const ActionsPopup = ({ - devbox: _devbox, + resource: _resource, operations, selectedOperation, onClose: _onClose, diff --git a/src/components/AgentsObjectsMenu.tsx b/src/components/AgentsObjectsMenu.tsx new file mode 100644 index 00000000..a52a1062 --- /dev/null +++ b/src/components/AgentsObjectsMenu.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { Box, Text, useInput, useApp, useStdout } from "ink"; +import figures from "figures"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; +import { colors } from "../utils/theme.js"; +import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import { useMenuStore } from "../store/menuStore.js"; + +interface AgentsObjectsMenuItem { + key: string; + label: string; + description: string; + icon: string; + color: string; +} + +const menuItems: AgentsObjectsMenuItem[] = [ + { + key: "agents", + label: "Agents", + description: "Manage AI agents for devboxes", + icon: "◆", + color: colors.warning, + }, + { + key: "objects", + label: "Objects", + description: "Manage files and data in cloud storage", + icon: "▤", + color: colors.secondary, + }, + { + key: "axons", + label: "Axons", + description: "Event streams for devboxes", + icon: "◇", + color: colors.accent3, + }, +]; + +interface AgentsObjectsMenuProps { + onSelect: (key: string) => void; + onBack: () => void; +} + +export const AgentsObjectsMenu = ({ + onSelect, + onBack, +}: AgentsObjectsMenuProps) => { + const { exit } = useApp(); + const { stdout } = useStdout(); + const { agentsObjectsSelectedKey, setAgentsObjectsSelectedKey } = + useMenuStore(); + + // Calculate initial index from persisted key + const initialIndex = React.useMemo(() => { + const index = menuItems.findIndex( + (item) => item.key === agentsObjectsSelectedKey, + ); + return index >= 0 ? index : 0; + }, [agentsObjectsSelectedKey]); + + const [selectedIndex, setSelectedIndex] = React.useState(initialIndex); + + // Persist selection when it changes + React.useEffect(() => { + const currentKey = menuItems[selectedIndex]?.key; + if (currentKey && currentKey !== agentsObjectsSelectedKey) { + setAgentsObjectsSelectedKey(currentKey); + } + }, [selectedIndex, agentsObjectsSelectedKey, setAgentsObjectsSelectedKey]); + + // Get terminal dimensions for responsive layout + const getTerminalDimensions = React.useCallback(() => { + return { + width: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80, + }; + }, [stdout]); + + const [terminalDimensions, setTerminalDimensions] = React.useState( + getTerminalDimensions, + ); + + React.useEffect(() => { + setTerminalDimensions(getTerminalDimensions()); + + if (!stdout) return; + + const handleResize = () => { + setTerminalDimensions(getTerminalDimensions()); + }; + + stdout.on("resize", handleResize); + + return () => { + stdout.off("resize", handleResize); + }; + }, [stdout, getTerminalDimensions]); + + const terminalWidth = terminalDimensions.width; + const isNarrow = terminalWidth < 70; + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + useInput((input, key) => { + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < menuItems.length - 1) { + setSelectedIndex(selectedIndex + 1); + } else if (key.return) { + onSelect(menuItems[selectedIndex].key); + } else if (key.escape) { + onBack(); + } else if (input === "g" || input === "1") { + onSelect("agents"); + } else if (input === "o" || input === "2") { + onSelect("objects"); + } else if (input === "x" || input === "3") { + onSelect("axons"); + } else if (input === "q") { + exit(); + } + }); + + return ( + + + + + + Agents & Objects + + + {isNarrow ? "" : " • Manage agents, objects, and event streams"} + + + + + {menuItems.map((item, index) => { + const isSelected = index === selectedIndex; + return ( + + + {isSelected ? figures.pointer : " "} + + + + {item.icon} + + + + {item.label} + + {!isNarrow && ( + + {" "} + - {item.description} + + )} + + {" "} + [{index + 1}] + + + ); + })} + + + + + ); +}; diff --git a/src/components/BenchmarkMenu.tsx b/src/components/BenchmarkMenu.tsx index 2c31783a..279198d1 100644 --- a/src/components/BenchmarkMenu.tsx +++ b/src/components/BenchmarkMenu.tsx @@ -8,6 +8,7 @@ import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import { useMenuStore } from "../store/menuStore.js"; /* Some useful icon chars @@ -102,8 +103,26 @@ interface BenchmarkMenuProps { export const BenchmarkMenu = ({ onSelect, onBack }: BenchmarkMenuProps) => { const { exit } = useApp(); - const [selectedIndex, setSelectedIndex] = React.useState(0); const { stdout } = useStdout(); + const { benchmarkSelectedKey, setBenchmarkSelectedKey } = useMenuStore(); + + // Calculate initial index from persisted key + const initialIndex = React.useMemo(() => { + const index = benchmarkMenuItems.findIndex( + (item) => item.key === benchmarkSelectedKey, + ); + return index >= 0 ? index : 0; + }, [benchmarkSelectedKey]); + + const [selectedIndex, setSelectedIndex] = React.useState(initialIndex); + + // Persist selection when it changes + React.useEffect(() => { + const currentKey = benchmarkMenuItems[selectedIndex]?.key; + if (currentKey && currentKey !== benchmarkSelectedKey) { + setBenchmarkSelectedKey(currentKey); + } + }, [selectedIndex, benchmarkSelectedKey, setBenchmarkSelectedKey]); // Get terminal dimensions for responsive layout const getTerminalDimensions = React.useCallback(() => { diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 23e51cad..051a5926 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -15,7 +15,6 @@ import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { MetadataDisplay } from "./MetadataDisplay.js"; import { ResourcePicker, createTextColumn, Column } from "./ResourcePicker.js"; -import { buildAgentTableColumns } from "./agentColumns.js"; import { formatTimeAgo } from "./ResourceListView.js"; import { getStatusDisplay } from "./StatusBadge.js"; import { @@ -1633,7 +1632,64 @@ export const DevboxCreatePage = ({ }, getItemId: (agent) => agent.id, getItemLabel: (agent) => agent.name || agent.id, - columns: buildAgentTableColumns, + columns: (tw: number): Column[] => { + const fixedWidth = 6; + const idWidth = 25; + const versionWidth = 20; + const sourceWidth = 8; + const nameWidth = Math.min( + 40, + Math.max( + 15, + Math.floor( + (tw - fixedWidth - idWidth - versionWidth - sourceWidth) * + 0.5, + ), + ), + ); + const timeWidth = Math.max( + 18, + tw - + fixedWidth - + idWidth - + nameWidth - + versionWidth - + sourceWidth, + ); + return [ + createTextColumn("id", "ID", (a) => a.id, { + width: idWidth + 1, + color: colors.idColor, + }), + createTextColumn("name", "Name", (a) => a.name, { + width: nameWidth, + }), + createTextColumn( + "source", + "Source", + (a) => a.source?.type || "", + { width: sourceWidth, color: colors.textDim }, + ), + createTextColumn( + "version", + "Version", + (a) => { + if (a.source?.type === "object") return ""; + const v = a.version || ""; + if (v.length > 16) return `${v.slice(0, 8)}…${v.slice(-4)}`; + return v; + }, + { width: versionWidth, color: colors.textDim }, + ), + createTextColumn( + "created", + "Created", + (a) => + a.create_time_ms ? formatTimeAgo(a.create_time_ms) : "", + { width: timeWidth, color: colors.textDim }, + ), + ]; + }, mode: "single", emptyMessage: "No agents found", searchPlaceholder: "Search agents...", diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 14df4a8e..4670df7b 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -25,13 +25,6 @@ interface MenuItem { } const allMenuItems: MenuItem[] = [ - { - key: "benchmarks", - label: "Benchmarks", - description: "Performance testing and evaluation", - icon: "▷", - color: colors.success, - }, { key: "devboxes", label: "Devboxes", @@ -39,13 +32,6 @@ const allMenuItems: MenuItem[] = [ icon: "◉", color: colors.accent1, }, - { - key: "blueprints", - label: "Blueprints", - description: "Create and manage devbox templates", - icon: "▣", - color: colors.accent2, - }, { key: "snapshots", label: "Snapshots", @@ -54,18 +40,25 @@ const allMenuItems: MenuItem[] = [ color: colors.accent3, }, { - key: "agents", - label: "Agents", - description: "Manage AI agents for devboxes", + key: "blueprints", + label: "Blueprints", + description: "Create and manage devbox templates", + icon: "▣", + color: colors.accent2, + }, + { + key: "agents-objects", + label: "Agents & Objects", + description: "Agents, objects, and event streams", icon: "◆", color: colors.warning, }, { - key: "objects", - label: "Storage Objects", - description: "Manage files and data in cloud storage", - icon: "▤", - color: colors.secondary, + key: "benchmarks", + label: "Benchmarks", + description: "Performance testing and evaluation", + icon: "▷", + color: colors.success, }, { key: "settings", @@ -206,14 +199,12 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { exit(); } else if (input === "d") { selectByKey("devboxes"); - } else if (input === "b") { - selectByKey("blueprints"); } else if (input === "s") { selectByKey("snapshots"); + } else if (input === "b") { + selectByKey("blueprints"); } else if (input === "a") { - selectByKey("agents"); - } else if (input === "o") { - selectByKey("objects"); + selectByKey("agents-objects"); } else if (input === "e") { selectByKey("benchmarks"); } else if (input === "n") { diff --git a/src/components/ResourceActionsMenu.tsx b/src/components/ResourceActionsMenu.tsx index 1d461479..1a21c50f 100644 --- a/src/components/ResourceActionsMenu.tsx +++ b/src/components/ResourceActionsMenu.tsx @@ -223,7 +223,7 @@ export const ResourceActionsMenu = (props: ResourceActionsMenuProps) => { ({ key: op.key, label: op.label, diff --git a/src/components/ResourcePicker.tsx b/src/components/ResourcePicker.tsx index 519193ae..48a660d6 100644 --- a/src/components/ResourcePicker.tsx +++ b/src/components/ResourcePicker.tsx @@ -82,6 +82,9 @@ export interface ResourcePickerConfig { /** Label for the create new action (default: "Create new") */ createNewLabel?: string; + + /** Additional lines of overhead from wrapper components (e.g., tab headers) */ + additionalOverhead?: number; } export interface ResourcePickerProps { @@ -134,7 +137,11 @@ export function ResourcePicker({ // Calculate overhead for viewport height // Matches list pages: breadcrumb(4) + table chrome(4) + stats(2) + nav tips(2) + buffer(1) = 13 - const overhead = 13 + search.getSearchOverhead() + extraOverhead; + const overhead = + 13 + + search.getSearchOverhead() + + extraOverhead + + (config.additionalOverhead || 0); const { viewportHeight, terminalWidth } = useViewportHeight({ overhead, minHeight: 5, diff --git a/src/router/Router.tsx b/src/router/Router.tsx index da10fe24..9d2feb13 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -28,11 +28,11 @@ const KNOWN_SCREENS: Set = new Set([ "devbox-actions", "devbox-exec", "devbox-create", + "snapshot-list", + "snapshot-detail", "blueprint-list", "blueprint-detail", "blueprint-logs", - "snapshot-list", - "snapshot-detail", "network-policy-list", "network-policy-detail", "network-policy-create", @@ -45,6 +45,12 @@ const KNOWN_SCREENS: Set = new Set([ "secret-list", "secret-detail", "secret-create", + "agents-objects-menu", + "agent-list", + "agent-detail", + "agent-create", + "axon-list", + "axon-detail", "object-list", "object-detail", "ssh-session", @@ -58,9 +64,6 @@ const KNOWN_SCREENS: Set = new Set([ "benchmark-job-list", "benchmark-job-detail", "benchmark-job-create", - "agent-list", - "agent-detail", - "agent-create", ]); /** @@ -108,11 +111,11 @@ import { DevboxDetailScreen } from "../screens/DevboxDetailScreen.js"; import { DevboxActionsScreen } from "../screens/DevboxActionsScreen.js"; import { DevboxExecScreen } from "../screens/DevboxExecScreen.js"; import { DevboxCreateScreen } from "../screens/DevboxCreateScreen.js"; +import { SnapshotListScreen } from "../screens/SnapshotListScreen.js"; +import { SnapshotDetailScreen } from "../screens/SnapshotDetailScreen.js"; import { BlueprintListScreen } from "../screens/BlueprintListScreen.js"; import { BlueprintDetailScreen } from "../screens/BlueprintDetailScreen.js"; import { BlueprintLogsScreen } from "../screens/BlueprintLogsScreen.js"; -import { SnapshotListScreen } from "../screens/SnapshotListScreen.js"; -import { SnapshotDetailScreen } from "../screens/SnapshotDetailScreen.js"; import { NetworkPolicyListScreen } from "../screens/NetworkPolicyListScreen.js"; import { NetworkPolicyDetailScreen } from "../screens/NetworkPolicyDetailScreen.js"; import { NetworkPolicyCreateScreen } from "../screens/NetworkPolicyCreateScreen.js"; @@ -124,6 +127,12 @@ import { SettingsMenuScreen } from "../screens/SettingsMenuScreen.js"; import { SecretListScreen } from "../screens/SecretListScreen.js"; import { SecretDetailScreen } from "../screens/SecretDetailScreen.js"; import { SecretCreateScreen } from "../screens/SecretCreateScreen.js"; +import { AgentsObjectsMenuScreen } from "../screens/AgentsObjectsMenuScreen.js"; +import { AgentListScreen } from "../screens/AgentListScreen.js"; +import { AgentDetailScreen } from "../screens/AgentDetailScreen.js"; +import { AgentCreateScreen } from "../screens/AgentCreateScreen.js"; +import { AxonListScreen } from "../screens/AxonListScreen.js"; +import { AxonDetailScreen } from "../screens/AxonDetailScreen.js"; import { ObjectListScreen } from "../screens/ObjectListScreen.js"; import { ObjectDetailScreen } from "../screens/ObjectDetailScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; @@ -137,9 +146,6 @@ import { ScenarioRunDetailScreen } from "../screens/ScenarioRunDetailScreen.js"; import { BenchmarkJobListScreen } from "../screens/BenchmarkJobListScreen.js"; import { BenchmarkJobDetailScreen } from "../screens/BenchmarkJobDetailScreen.js"; import { BenchmarkJobCreateScreen } from "../screens/BenchmarkJobCreateScreen.js"; -import { AgentListScreen } from "../screens/AgentListScreen.js"; -import { AgentDetailScreen } from "../screens/AgentDetailScreen.js"; -import { AgentCreateScreen } from "../screens/AgentCreateScreen.js"; /** * Router component that renders the current screen @@ -277,6 +283,12 @@ export function Router() { {currentScreen === "devbox-create" && ( )} + {currentScreen === "snapshot-list" && ( + + )} + {currentScreen === "snapshot-detail" && ( + + )} {currentScreen === "blueprint-list" && ( )} @@ -286,12 +298,6 @@ export function Router() { {currentScreen === "blueprint-logs" && ( )} - {currentScreen === "snapshot-list" && ( - - )} - {currentScreen === "snapshot-detail" && ( - - )} {currentScreen === "network-policy-list" && ( )} @@ -322,6 +328,24 @@ export function Router() { {currentScreen === "secret-create" && ( )} + {currentScreen === "agents-objects-menu" && ( + + )} + {currentScreen === "agent-list" && ( + + )} + {currentScreen === "agent-detail" && ( + + )} + {currentScreen === "agent-create" && ( + + )} + {currentScreen === "axon-list" && ( + + )} + {currentScreen === "axon-detail" && ( + + )} {currentScreen === "object-list" && ( )} @@ -364,15 +388,6 @@ export function Router() { {...params} /> )} - {currentScreen === "agent-list" && ( - - )} - {currentScreen === "agent-detail" && ( - - )} - {currentScreen === "agent-create" && ( - - )} {!KNOWN_SCREENS.has(currentScreen) && ( )} diff --git a/src/screens/AgentDetailScreen.tsx b/src/screens/AgentDetailScreen.tsx index 61415cd8..74a9b25e 100644 --- a/src/screens/AgentDetailScreen.tsx +++ b/src/screens/AgentDetailScreen.tsx @@ -2,16 +2,20 @@ * AgentDetailScreen - Detail page for agents */ import React from "react"; -import { Box, Text } from "ink"; +import { Text } from "ink"; import figures from "figures"; import { useNavigation } from "../store/navigationStore.js"; -import { getAgent, deleteAgent, type Agent } from "../services/agentService.js"; import { ResourceDetailPage, formatTimestamp, type DetailSection, type ResourceOperation, } from "../components/ResourceDetailPage.js"; +import { getAgent, deleteAgent, type Agent } from "../services/agentService.js"; +import { + getObject, + buildObjectDetailFields, +} from "../services/objectService.js"; import { useResourceDetail } from "../hooks/useResourceDetail.js"; import { SpinnerComponent } from "../components/Spinner.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; @@ -26,12 +30,31 @@ interface AgentDetailScreenProps { export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { const { goBack } = useNavigation(); - const { data: agent, error } = useResourceDetail({ + const { + data: agent, + loading, + error, + } = useResourceDetail({ id: agentId, fetch: getAgent, - pollInterval: 5000, }); + // Fetch underlying object details for object-based agents + const [objectDetails, setObjectDetails] = React.useState + > | null>(null); + + React.useEffect(() => { + const source = agent?.source; + if (source?.type === "object" && source.object?.object_id) { + getObject(source.object.object_id) + .then((obj) => setObjectDetails(obj)) + .catch(() => { + /* silently ignore - object may have been deleted */ + }); + } + }, [agent]); + const [deleting, setDeleting] = React.useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [operationError, setOperationError] = React.useState( @@ -78,86 +101,109 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { ); } + const source = agent.source; + // Build detail sections const detailSections: DetailSection[] = []; const basicFields = []; basicFields.push({ label: "Version", value: agent.version }); - basicFields.push({ - label: "Public", - value: agent.is_public ? "Yes" : "No", - }); if (agent.create_time_ms) { basicFields.push({ label: "Created", value: formatTimestamp(agent.create_time_ms), }); } + basicFields.push({ + label: "Public", + value: agent.is_public ? "Yes" : "No", + }); - if (agent.source) { - basicFields.push({ - label: "Source Type", - value: agent.source.type || "-", - }); - if (agent.source.npm) { - basicFields.push({ + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + + // Source section + if (source) { + const sourceFields = [{ label: "Type", value: source.type || "-" }]; + + if (source.npm) { + sourceFields.push({ label: "Package", - value: agent.source.npm.package_name, + value: source.npm.package_name, }); - if (agent.source.npm.registry_url) { - basicFields.push({ + if (source.npm.registry_url) { + sourceFields.push({ label: "Registry", - value: agent.source.npm.registry_url, + value: source.npm.registry_url, }); } - } - if (agent.source.pip) { - basicFields.push({ + } else if (source.pip) { + sourceFields.push({ label: "Package", - value: agent.source.pip.package_name, + value: source.pip.package_name, }); - if (agent.source.pip.registry_url) { - basicFields.push({ + if (source.pip.registry_url) { + sourceFields.push({ label: "Registry", - value: agent.source.pip.registry_url, + value: source.pip.registry_url, }); } - } - if (agent.source.git) { - basicFields.push({ + } else if (source.git) { + sourceFields.push({ label: "Repository", - value: agent.source.git.repository, + value: source.git.repository, }); - if (agent.source.git.ref) { - basicFields.push({ label: "Ref", value: agent.source.git.ref }); + if (source.git.ref) { + sourceFields.push({ label: "Ref", value: source.git.ref }); } - } - if (agent.source.object) { - basicFields.push({ + } else if (source.object) { + sourceFields.push({ label: "Object ID", - value: agent.source.object.object_id, + value: source.object.object_id, }); } - } - detailSections.push({ - title: "Details", - icon: figures.squareSmallFilled, - color: colors.warning, - fields: basicFields, - }); + detailSections.push({ + title: "Source", + icon: figures.info, + color: colors.info, + fields: sourceFields, + }); - const operations: ResourceOperation[] = [ - { - key: "delete", - label: "Delete", - color: colors.error, - icon: figures.cross, - shortcut: "d", - }, - ]; + // Add a dedicated "Object Details" section for object-based agents, + // reusing the same field builder as the Object detail screen + if (source?.type === "object" && objectDetails) { + const objectFields = buildObjectDetailFields(objectDetails); + if (objectDetails.name) { + objectFields.unshift({ label: "Name", value: objectDetails.name }); + } + detailSections.push({ + title: "Object Details", + icon: figures.squareSmallFilled, + color: colors.secondary, + fields: objectFields, + }); + } + } + + const isPublic = agent.is_public; + const operations: ResourceOperation[] = isPublic + ? [] + : [ + { + key: "delete", + label: "Delete Agent", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; - const handleOperation = async (operation: string) => { + const handleOperation = (operation: string) => { if (operation === "delete") { setShowDeleteConfirm(true); } @@ -178,6 +224,7 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { const buildDetailLines = (a: Agent): React.ReactElement[] => { const lines: React.ReactElement[] = []; + lines.push( Agent Details @@ -201,12 +248,6 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { Version: {a.version} , ); - lines.push( - - {" "} - Public: {a.is_public ? "Yes" : "No"} - , - ); if (a.create_time_ms) { lines.push( @@ -217,53 +258,7 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { } lines.push( ); - if (a.source) { - lines.push( - - Source - , - ); - lines.push( - - {" "} - Type: {a.source.type} - , - ); - if (a.source.npm) { - lines.push( - - {" "} - Package: {a.source.npm.package_name} - , - ); - } - if (a.source.pip) { - lines.push( - - {" "} - Package: {a.source.pip.package_name} - , - ); - } - if (a.source.git) { - lines.push( - - {" "} - Repository: {a.source.git.repository} - , - ); - } - if (a.source.object) { - lines.push( - - {" "} - Object ID: {a.source.object.object_id} - , - ); - } - lines.push( ); - } - + // Raw JSON lines.push( Raw JSON @@ -286,7 +281,7 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { return ( a.name} getId={(a) => a.id} - getStatus={(a) => (a.is_public ? "public" : "private")} + getStatus={() => (agent.is_public ? "public" : "private")} detailSections={detailSections} operations={operations} onOperation={handleOperation} diff --git a/src/screens/AgentListScreen.tsx b/src/screens/AgentListScreen.tsx index 19be4e0c..b72f694e 100644 --- a/src/screens/AgentListScreen.tsx +++ b/src/screens/AgentListScreen.tsx @@ -1,279 +1,12 @@ /** - * AgentListScreen - List and manage agents + * AgentListScreen - Wraps ListAgentsUI for TUI navigation */ import React from "react"; -import { Box, Text, useInput } from "ink"; -import figures from "figures"; import { useNavigation } from "../store/navigationStore.js"; -import { listAgents, listPublicAgents } from "../services/agentService.js"; -import type { Agent } from "../services/agentService.js"; -import { Breadcrumb } from "../components/Breadcrumb.js"; -import { NavigationTips } from "../components/NavigationTips.js"; -import { SpinnerComponent } from "../components/Spinner.js"; -import { ErrorMessage } from "../components/ErrorMessage.js"; -import { Table } from "../components/Table.js"; -import { buildAgentTableColumns } from "../components/agentColumns.js"; -import { SearchBar } from "../components/SearchBar.js"; -import { colors } from "../utils/theme.js"; -import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; -import { useViewportHeight } from "../hooks/useViewportHeight.js"; -import { useCursorPagination } from "../hooks/useCursorPagination.js"; -import { useListSearch } from "../hooks/useListSearch.js"; - -type Tab = "private" | "public"; +import { ListAgentsUI } from "../commands/agent/list.js"; export function AgentListScreen() { - const { navigate, goBack } = useNavigation(); - const [activeTab, setActiveTab] = React.useState("private"); - const [selectedIndex, setSelectedIndex] = React.useState(0); - - useExitOnCtrlC(); - - // Search state - const search = useListSearch({ - onSearchSubmit: () => setSelectedIndex(0), - onSearchClear: () => setSelectedIndex(0), - }); - - // Base overhead is 13; add 2 for the tab bar (content + marginBottom) - const overhead = 15 + search.getSearchOverhead(); - const { viewportHeight, terminalWidth } = useViewportHeight({ - overhead, - minHeight: 5, - }); - - const PAGE_SIZE = viewportHeight; - - // Fetch function for pagination hook - const fetchPage = React.useCallback( - async (params: { limit: number; startingAt?: string }) => { - const fetchFn = activeTab === "public" ? listPublicAgents : listAgents; - const result = await fetchFn({ - limit: params.limit, - startingAfter: params.startingAt, - search: search.submittedSearchQuery || undefined, - }); - - return { - items: result.agents, - hasMore: result.hasMore, - totalCount: result.totalCount, - }; - }, - [activeTab, search.submittedSearchQuery], - ); - - // Use the shared pagination hook - const { - items: agents, - loading, - navigating, - error, - currentPage, - hasMore, - hasPrev, - totalCount, - nextPage, - prevPage, - } = useCursorPagination({ - fetchPage, - pageSize: PAGE_SIZE, - getItemId: (agent: Agent) => agent.id, - pollInterval: 5000, - pollingEnabled: !search.searchMode, - deps: [PAGE_SIZE, activeTab, search.submittedSearchQuery], - }); - - const columns = React.useMemo( - () => buildAgentTableColumns(terminalWidth), - [terminalWidth], - ); - - // Ensure selected index is within bounds - React.useEffect(() => { - if (agents.length > 0 && selectedIndex >= agents.length) { - setSelectedIndex(Math.max(0, agents.length - 1)); - } - }, [agents.length, selectedIndex]); - - const selectedAgent = agents[selectedIndex]; - - // Calculate pagination info for display - const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); - const startIndex = currentPage * PAGE_SIZE; - const endIndex = startIndex + agents.length; - - useInput((input, key) => { - // Handle search mode input - if (search.searchMode) { - if (key.escape) { - search.cancelSearch(); - } - return; - } - - const pageAgents = agents.length; - - // Handle list view navigation - if (key.upArrow && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < pageAgents - 1) { - setSelectedIndex(selectedIndex + 1); - } else if ( - (input === "n" || key.rightArrow) && - !loading && - !navigating && - hasMore - ) { - nextPage(); - setSelectedIndex(0); - } else if ( - (input === "p" || key.leftArrow) && - !loading && - !navigating && - hasPrev - ) { - prevPage(); - setSelectedIndex(0); - } else if (key.return && selectedAgent) { - navigate("agent-detail", { agentId: selectedAgent.id }); - } else if (key.tab) { - setActiveTab(activeTab === "private" ? "public" : "private"); - setSelectedIndex(0); - } else if (input === "/") { - search.enterSearchMode(); - } else if (input === "c") { - navigate("agent-create"); - } else if (key.escape) { - if (search.handleEscape()) { - return; - } - goBack(); - } - }); - - // Loading state - if (loading && agents.length === 0) { - return ( - <> - - - - ); - } - - // Error state - if (error) { - return ( - <> - - - - ); - } - - // Main list view - return ( - <> - - - {/* Tab bar */} - - - {activeTab === "private" ? "▸ " : " "}Private - - | - - {activeTab === "public" ? "▸ " : " "}Public - - - - {/* Search bar */} - - - {/* Table */} -
agent.id} - selectedIndex={selectedIndex} - title={`agents[${totalCount}]`} - columns={columns} - emptyState={ - {figures.info} No agents found - } - /> - - {/* Statistics Bar */} - - - {figures.hamburger} {totalCount} - - - {" "} - total - - {totalPages > 1 && ( - <> - - {" "} - •{" "} - - {navigating ? ( - - {figures.pointer} Loading page {currentPage + 1}... - - ) : ( - - Page {currentPage + 1} of {totalPages} - - )} - - )} - - {" "} - •{" "} - - - Showing {startIndex + 1}-{endIndex} of {totalCount} - - + const { goBack } = useNavigation(); - {/* Help Bar */} - - - ); + return ; } diff --git a/src/screens/AgentsObjectsMenuScreen.tsx b/src/screens/AgentsObjectsMenuScreen.tsx new file mode 100644 index 00000000..9f35505b --- /dev/null +++ b/src/screens/AgentsObjectsMenuScreen.tsx @@ -0,0 +1,28 @@ +/** + * AgentsObjectsMenuScreen - Agents & Objects sub-menu using navigation context + */ +import React from "react"; +import { useNavigation, type ScreenName } from "../store/navigationStore.js"; +import { AgentsObjectsMenu } from "../components/AgentsObjectsMenu.js"; + +export function AgentsObjectsMenuScreen() { + const { navigate, goBack } = useNavigation(); + + const handleSelect = (key: string) => { + switch (key) { + case "agents": + navigate("agent-list"); + break; + case "objects": + navigate("object-list"); + break; + case "axons": + navigate("axon-list"); + break; + default: + navigate(key as ScreenName); + } + }; + + return ; +} diff --git a/src/screens/AxonDetailScreen.tsx b/src/screens/AxonDetailScreen.tsx new file mode 100644 index 00000000..4515d82c --- /dev/null +++ b/src/screens/AxonDetailScreen.tsx @@ -0,0 +1,154 @@ +/** + * AxonDetailScreen - Detail page for axons + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { + ResourceDetailPage, + formatTimestamp, + type DetailSection, +} from "../components/ResourceDetailPage.js"; +import { getAxon, type Axon } from "../services/axonService.js"; +import { useResourceDetail } from "../hooks/useResourceDetail.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +interface AxonDetailScreenProps { + axonId?: string; +} + +export function AxonDetailScreen({ axonId }: AxonDetailScreenProps) { + const { goBack } = useNavigation(); + + const { data: axon, error } = useResourceDetail({ + id: axonId, + fetch: getAxon, + }); + + if (!axon && axonId && !error) { + return ( + <> + + + + ); + } + + if (error && !axon) { + return ( + <> + + + + ); + } + + if (!axon) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + const basicFields = []; + if (axon.name) { + basicFields.push({ label: "Name", value: axon.name }); + } + if (axon.created_at_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(axon.created_at_ms), + }); + } + + if (basicFields.length > 0) { + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + } + + const buildDetailLines = (a: Axon): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + lines.push( + + Axon Details + , + ); + lines.push( + + {" "} + ID: {a.id} + , + ); + lines.push( + + {" "} + Name: {a.name ?? "(none)"} + , + ); + if (a.created_at_ms) { + lines.push( + + {" "} + Created: {new Date(a.created_at_ms).toLocaleString()} + , + ); + } + lines.push( ); + + // Raw JSON + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(a, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + return ( + a.name ?? a.id} + getId={(a) => a.id} + getStatus={() => "active"} + detailSections={detailSections} + operations={[]} + onOperation={async () => {}} + onBack={goBack} + buildDetailLines={buildDetailLines} + /> + ); +} diff --git a/src/screens/AxonListScreen.tsx b/src/screens/AxonListScreen.tsx new file mode 100644 index 00000000..a8ac810a --- /dev/null +++ b/src/screens/AxonListScreen.tsx @@ -0,0 +1,12 @@ +/** + * AxonListScreen - Wraps ListAxonsUI for TUI navigation + */ +import React from "react"; +import { useNavigation } from "../store/navigationStore.js"; +import { ListAxonsUI } from "../commands/axon/list.js"; + +export function AxonListScreen() { + const { goBack } = useNavigation(); + + return ; +} diff --git a/src/screens/BenchmarkJobListScreen.tsx b/src/screens/BenchmarkJobListScreen.tsx index 82ad69ff..33c90963 100644 --- a/src/screens/BenchmarkJobListScreen.tsx +++ b/src/screens/BenchmarkJobListScreen.tsx @@ -450,7 +450,7 @@ export function BenchmarkJobListScreen() { {showPopup && selectedJob && ( ({ key: op.key, label: op.label, diff --git a/src/screens/BenchmarkListScreen.tsx b/src/screens/BenchmarkListScreen.tsx index bd25cb64..f99812e7 100644 --- a/src/screens/BenchmarkListScreen.tsx +++ b/src/screens/BenchmarkListScreen.tsx @@ -409,7 +409,7 @@ export function BenchmarkListScreen() { {showPopup && selectedBenchmark && ( ({ key: op.key, label: op.label, diff --git a/src/screens/BenchmarkRunListScreen.tsx b/src/screens/BenchmarkRunListScreen.tsx index df81086e..39199a96 100644 --- a/src/screens/BenchmarkRunListScreen.tsx +++ b/src/screens/BenchmarkRunListScreen.tsx @@ -395,7 +395,7 @@ export function BenchmarkRunListScreen() { {showPopup && selectedRun && ( ({ key: op.key, label: op.label, diff --git a/src/screens/MenuScreen.tsx b/src/screens/MenuScreen.tsx index c6c1cf2a..33532c54 100644 --- a/src/screens/MenuScreen.tsx +++ b/src/screens/MenuScreen.tsx @@ -22,11 +22,8 @@ export function MenuScreen() { case "settings": navigate("settings-menu"); break; - case "objects": - navigate("object-list"); - break; - case "agents": - navigate("agent-list"); + case "agents-objects": + navigate("agents-objects-menu"); break; case "benchmarks": navigate("benchmark-menu"); diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 06163944..46cb6254 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -15,13 +15,13 @@ import { import { getClient } from "../utils/client.js"; import { ResourceDetailPage, - formatTimestamp, type DetailSection, type ResourceOperation, } from "../components/ResourceDetailPage.js"; import { getObject, deleteObject, + buildObjectDetailFields, formatFileSize, } from "../services/objectService.js"; import { useResourceDetail } from "../hooks/useResourceDetail.js"; @@ -126,10 +126,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( <> @@ -141,10 +138,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( <> ); @@ -175,68 +166,15 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Build detail sections const detailSections: DetailSection[] = []; - // Basic details section - const basicFields = []; - if (storageObject.content_type) { - basicFields.push({ - label: "Content Type", - value: storageObject.content_type, - }); - } - if (storageObject.size_bytes !== undefined) { - basicFields.push({ - label: "Size", - value: formatFileSize(storageObject.size_bytes), - }); - } - if (storageObject.state) { - basicFields.push({ - label: "State", - value: storageObject.state, - }); - } - if (storageObject.is_public !== undefined) { - basicFields.push({ - label: "Public", - value: storageObject.is_public ? "Yes" : "No", - }); - } - if (storageObject.create_time_ms) { - basicFields.push({ - label: "Created", - value: formatTimestamp(storageObject.create_time_ms), - }); - } - - // TTL / Expires - show remaining time before auto-deletion - if (storageObject.delete_after_time_ms) { - const now = Date.now(); - const remainingMs = storageObject.delete_after_time_ms - now; - - let ttlValue: string; - let ttlColor = colors.text; - - if (remainingMs <= 0) { - ttlValue = "Expired"; - ttlColor = colors.error; - } else { - const remainingMinutes = Math.floor(remainingMs / 60000); - if (remainingMinutes < 60) { - ttlValue = `${remainingMinutes}m remaining`; - ttlColor = remainingMinutes < 10 ? colors.warning : colors.text; - } else { - const hours = Math.floor(remainingMinutes / 60); - const mins = remainingMinutes % 60; - ttlValue = `${hours}h ${mins}m remaining`; - } - } - - basicFields.push({ - label: "Expires", - value: {ttlValue}, - }); - } - + // Basic details section — reuse shared field builder + const colorMap: Record = { + error: colors.error, + warning: colors.warning, + }; + const basicFields = buildObjectDetailFields(storageObject).map((f) => ({ + ...f, + color: f.color ? (colorMap[f.color] ?? f.color) : undefined, + })); if (basicFields.length > 0) { detailSections.push({ title: "Details", @@ -343,7 +281,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Core Information lines.push( - Storage Object Details + Object Details , ); lines.push( @@ -479,7 +417,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { <> -
+
{figures.arrowRight} Downloading:{" "} @@ -563,11 +501,11 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { if (showDeleteConfirm && storageObject) { return ( - + ); } @@ -596,7 +534,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( obj.name || obj.id} getId={(obj) => obj.id} getStatus={(obj) => obj.state || "unknown"} diff --git a/src/screens/ScenarioRunListScreen.tsx b/src/screens/ScenarioRunListScreen.tsx index 8935c520..9c1b03ac 100644 --- a/src/screens/ScenarioRunListScreen.tsx +++ b/src/screens/ScenarioRunListScreen.tsx @@ -394,7 +394,7 @@ export function ScenarioRunListScreen({ {showPopup && selectedRun && ( ({ key: op.key, label: op.label, diff --git a/src/services/agentService.ts b/src/services/agentService.ts index d75387f8..64bde020 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -136,6 +136,7 @@ export interface ListAgentsOptions { name?: string; search?: string; version?: string; + includeTotalCount?: boolean; } export interface ListAgentsResult { @@ -160,10 +161,15 @@ export async function listAgents( name?: string; search?: string; version?: string; + include_total_count?: boolean; } = { limit: options.limit, }; + if (options.includeTotalCount !== undefined) { + queryParams.include_total_count = options.includeTotalCount; + } + if (options.startingAfter) { queryParams.starting_after = options.startingAfter; } @@ -228,6 +234,9 @@ export async function listPublicAgents( if (options.search) { queryParams.search = options.search; } + if (options.includeTotalCount !== undefined) { + queryParams.include_total_count = options.includeTotalCount; + } // SDK doesn't have agents.listPublic yet, use raw HTTP call const response = await (client as any).get("/v1/agents/list_public", { diff --git a/src/services/axonService.ts b/src/services/axonService.ts index ef23ae50..71dd4f33 100644 --- a/src/services/axonService.ts +++ b/src/services/axonService.ts @@ -1,5 +1,5 @@ /** - * Axon service — active axons listing (beta API) + * Axon service — axon listing and retrieval */ import { getClient } from "../utils/client.js"; import type { AxonView } from "@runloop/api-client/resources/axons/axons"; @@ -10,31 +10,52 @@ export type Axon = AxonView; export interface ListActiveAxonsOptions { limit?: number; startingAfter?: string; + name?: string; + id?: string; + search?: string; + includeTotalCount?: boolean; } export interface ListActiveAxonsResult { axons: Axon[]; hasMore: boolean; + totalCount: number; } /** - * List active axons with optional cursor pagination (`limit`, `starting_after`). + * List active axons with optional cursor pagination and search. + * Search uses smart parsing: `axn_*` prefix → ID filter, otherwise name filter. */ export async function listActiveAxons( options: ListActiveAxonsOptions, ): Promise { const client = getClient(); - const query: { - limit?: number; - starting_after?: string; - } = {}; + const query: Record = {}; if (options.limit !== undefined) { query.limit = options.limit; } if (options.startingAfter) { query.starting_after = options.startingAfter; } + if (options.includeTotalCount !== undefined) { + query.include_total_count = options.includeTotalCount; + } + + // Smart search parsing + if (options.search) { + if (options.search.startsWith("axn_")) { + query.id = options.search; + } else { + query.name = options.search; + } + } + if (options.name) { + query.name = options.name; + } + if (options.id) { + query.id = options.id; + } const page = (await client.axons.list( Object.keys(query).length > 0 ? query : undefined, @@ -42,6 +63,16 @@ export async function listActiveAxons( const axons = page.axons || []; const hasMore = page.has_more || false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const totalCount = (page as any).total_count ?? axons.length; - return { axons, hasMore }; + return { axons, hasMore, totalCount }; +} + +/** + * Get a single axon by ID. + */ +export async function getAxon(id: string): Promise { + const client = getClient(); + return client.axons.retrieve(id); } diff --git a/src/services/objectService.ts b/src/services/objectService.ts index b4c2647b..656331c0 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -2,6 +2,7 @@ * Object Service - Handles all storage object API calls */ import { getClient } from "../utils/client.js"; +import { formatTimestamp } from "../utils/time.js"; import type { StorageObjectView } from "../store/objectStore.js"; export interface ListObjectsOptions { @@ -118,6 +119,65 @@ export async function deleteObject(id: string): Promise { await client.objects.delete(id); } +export interface ObjectDetailField { + label: string; + value: string; + color?: string; +} + +/** + * Build standard detail fields for a storage object. + * Shared between ObjectDetailScreen and AgentDetailScreen. + */ +export function buildObjectDetailFields( + obj: StorageObjectView, +): ObjectDetailField[] { + const fields: ObjectDetailField[] = []; + + if (obj.content_type) { + fields.push({ label: "Content Type", value: obj.content_type }); + } + if (obj.size_bytes !== undefined && obj.size_bytes !== null) { + fields.push({ label: "Size", value: formatFileSize(obj.size_bytes) }); + } + if (obj.state) { + fields.push({ label: "State", value: obj.state }); + } + if (obj.is_public !== undefined) { + fields.push({ label: "Public", value: obj.is_public ? "Yes" : "No" }); + } + if (obj.create_time_ms) { + fields.push({ + label: "Created", + value: formatTimestamp(obj.create_time_ms) ?? "", + }); + } + if (obj.delete_after_time_ms) { + const remainingMs = obj.delete_after_time_ms - Date.now(); + if (remainingMs <= 0) { + fields.push({ label: "Expires", value: "Expired", color: "error" }); + } else { + const remainingMinutes = Math.floor(remainingMs / 60000); + if (remainingMinutes < 60) { + fields.push({ + label: "Expires", + value: `${remainingMinutes}m remaining`, + color: remainingMinutes < 10 ? "warning" : undefined, + }); + } else { + const hours = Math.floor(remainingMinutes / 60); + const mins = remainingMinutes % 60; + fields.push({ + label: "Expires", + value: `${hours}h ${mins}m remaining`, + }); + } + } + } + + return fields; +} + /** * Format file size in human-readable format */ diff --git a/src/store/menuStore.ts b/src/store/menuStore.ts index f4f02cf2..80e0ee8b 100644 --- a/src/store/menuStore.ts +++ b/src/store/menuStore.ts @@ -5,6 +5,10 @@ interface MenuState { setSelectedKey: (key: string) => void; settingsSelectedKey: string; setSettingsSelectedKey: (key: string) => void; + agentsObjectsSelectedKey: string; + setAgentsObjectsSelectedKey: (key: string) => void; + benchmarkSelectedKey: string; + setBenchmarkSelectedKey: (key: string) => void; } export const useMenuStore = create((set) => ({ @@ -12,4 +16,8 @@ export const useMenuStore = create((set) => ({ setSelectedKey: (key) => set({ selectedKey: key }), settingsSelectedKey: "network-policies", setSettingsSelectedKey: (key) => set({ settingsSelectedKey: key }), + agentsObjectsSelectedKey: "agents", + setAgentsObjectsSelectedKey: (key) => set({ agentsObjectsSelectedKey: key }), + benchmarkSelectedKey: "benchmarks", + setBenchmarkSelectedKey: (key) => set({ benchmarkSelectedKey: key }), })); diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 30e58466..a6cc54f9 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -19,11 +19,11 @@ export type ScreenName = | "devbox-actions" | "devbox-exec" | "devbox-create" + | "snapshot-list" + | "snapshot-detail" | "blueprint-list" | "blueprint-detail" | "blueprint-logs" - | "snapshot-list" - | "snapshot-detail" | "network-policy-list" | "network-policy-detail" | "network-policy-create" @@ -36,6 +36,12 @@ export type ScreenName = | "secret-list" | "secret-detail" | "secret-create" + | "agents-objects-menu" + | "agent-list" + | "agent-detail" + | "agent-create" + | "axon-list" + | "axon-detail" | "object-list" | "object-detail" | "ssh-session" @@ -47,10 +53,7 @@ export type ScreenName = | "scenario-run-detail" | "benchmark-job-list" | "benchmark-job-detail" - | "benchmark-job-create" - | "agent-list" - | "agent-detail" - | "agent-create"; + | "benchmark-job-create"; export interface RouteParams { devboxId?: string; @@ -61,6 +64,8 @@ export interface RouteParams { gatewayConfigId?: string; mcpConfigId?: string; secretId?: string; + agentId?: string; + axonId?: string; objectId?: string; operation?: string; focusDevboxId?: string; @@ -82,7 +87,6 @@ export interface RouteParams { scenarioRunId?: string; benchmarkJobId?: string; initialBenchmarkIds?: string; - agentId?: string; [key: string]: string | ScreenName | RouteParams | undefined; } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index f1c4ce80..f520205d 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -86,8 +86,10 @@ export function createProgram(): Command { "--mcp ", "MCP configurations (format: ENV_VAR_NAME=mcp_config_id_or_name,secret_id_or_name)", ) - .option("--agent ", "Agent to mount (name or ID)") - .option("--agent-path ", "Path to mount the agent on the devbox") + .option( + "--agent ", + "Agents to mount (format: name_or_id or name_or_id:/mount/path)", + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -1176,9 +1178,11 @@ export function createProgram(): Command { .option("--search ", "Search by agent ID or name") .option("--public", "Show only public agents") .option("--private", "Show only private agents") + .option("--limit ", "Max results to return (0 = unlimited)", "0") + .option("--starting-after ", "Cursor for pagination (agent ID)") .option( "-o, --output [format]", - "Output format: text|json|yaml (default: text)", + "Output format: text|json|yaml (default: json)", ) .action(async (options) => { const { listAgentsCommand } = await import("../commands/agent/list.js"); diff --git a/tests/__tests__/services/objectService.test.ts b/tests/__tests__/services/objectService.test.ts new file mode 100644 index 00000000..e17a8860 --- /dev/null +++ b/tests/__tests__/services/objectService.test.ts @@ -0,0 +1,105 @@ +import { + formatFileSize, + buildObjectDetailFields, +} from "../../../src/services/objectService.js"; +import type { StorageObjectView } from "../../../src/store/objectStore.js"; + +describe("formatFileSize", () => { + it("returns Unknown for null", () => { + expect(formatFileSize(null)).toBe("Unknown"); + }); + + it("returns Unknown for undefined", () => { + expect(formatFileSize(undefined)).toBe("Unknown"); + }); + + it("formats bytes", () => { + expect(formatFileSize(500)).toBe("500 B"); + }); + + it("formats kilobytes", () => { + expect(formatFileSize(1024)).toBe("1.00 KB"); + }); + + it("formats megabytes", () => { + expect(formatFileSize(1024 * 1024)).toBe("1.00 MB"); + }); + + it("formats gigabytes", () => { + expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.00 GB"); + }); + + it("formats zero bytes", () => { + expect(formatFileSize(0)).toBe("0 B"); + }); +}); + +describe("buildObjectDetailFields", () => { + const baseObject: StorageObjectView = { + id: "obj_123", + name: "test.txt", + content_type: "text/plain", + create_time_ms: 1700000000000, + state: "READY", + size_bytes: 1024, + }; + + it("includes content type", () => { + const fields = buildObjectDetailFields(baseObject); + expect(fields.find((f) => f.label === "Content Type")?.value).toBe( + "text/plain", + ); + }); + + it("includes formatted size", () => { + const fields = buildObjectDetailFields(baseObject); + expect(fields.find((f) => f.label === "Size")?.value).toBe("1.00 KB"); + }); + + it("includes state", () => { + const fields = buildObjectDetailFields(baseObject); + expect(fields.find((f) => f.label === "State")?.value).toBe("READY"); + }); + + it("includes public field when set", () => { + const fields = buildObjectDetailFields({ ...baseObject, is_public: true }); + expect(fields.find((f) => f.label === "Public")?.value).toBe("Yes"); + }); + + it("includes created timestamp", () => { + const fields = buildObjectDetailFields(baseObject); + expect(fields.find((f) => f.label === "Created")).toBeDefined(); + }); + + it("includes expires when delete_after_time_ms is set", () => { + const future = Date.now() + 3600000; // 1 hour from now + const fields = buildObjectDetailFields({ + ...baseObject, + delete_after_time_ms: future, + }); + const expiresField = fields.find((f) => f.label === "Expires"); + expect(expiresField).toBeDefined(); + expect(expiresField?.value).toContain("remaining"); + }); + + it("shows Expired with error color for past delete_after_time_ms", () => { + const past = Date.now() - 1000; + const fields = buildObjectDetailFields({ + ...baseObject, + delete_after_time_ms: past, + }); + const expiresField = fields.find((f) => f.label === "Expires"); + expect(expiresField?.value).toBe("Expired"); + expect(expiresField?.color).toBe("error"); + }); + + it("shows warning color when expiry is under 10 minutes", () => { + const soon = Date.now() + 5 * 60000; // 5 minutes from now + const fields = buildObjectDetailFields({ + ...baseObject, + delete_after_time_ms: soon, + }); + const expiresField = fields.find((f) => f.label === "Expires"); + expect(expiresField?.color).toBe("warning"); + }); +});