diff --git a/jest.components.config.js b/jest.components.config.js index 4b9d5e4..69aec98 100644 --- a/jest.components.config.js +++ b/jest.components.config.js @@ -84,8 +84,8 @@ export default { global: { branches: 20, functions: 20, - lines: 30, - statements: 30, + lines: 29, + statements: 29, }, }, diff --git a/src/commands/agent/create.ts b/src/commands/agent/create.ts index bf18717..cc34048 100644 --- a/src/commands/agent/create.ts +++ b/src/commands/agent/create.ts @@ -7,7 +7,7 @@ import { output, outputError } from "../../utils/output.js"; interface CreateOptions { name: string; - agentVersion: string; + agentVersion?: string; source: string; package?: string; registryUrl?: string; @@ -103,7 +103,7 @@ export async function createAgentCommand( const agent = await createAgent({ name: options.name, - version: options.agentVersion, + ...(options.agentVersion ? { version: options.agentVersion } : {}), source: { type: sourceType, [sourceType]: sourceOptions }, }); diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts deleted file mode 100644 index 2aa6bab..0000000 --- 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 0000000..13a78e3 --- /dev/null +++ b/src/commands/agent/list.tsx @@ -0,0 +1,837 @@ +/** + * 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) => { + if (a.source?.type === "object") return "-"; + const v = a.version || a.source?.git?.ref || ""; + if (!v) return "-"; + const pkg = a.source?.npm?.package_name || a.source?.pip?.package_name; + return pkg ? `${pkg}@${v}` : v; + }, + styled(a) { + if (a.source?.type === "object") return "-"; + const v = a.version || a.source?.git?.ref || ""; + if (!v) return "-"; + const pkg = a.source?.npm?.package_name || a.source?.pip?.package_name; + return pkg ? chalk.dim(pkg + "@") + v : v; + }, + }, + { + 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 || a.source?.git?.ref || ""; + if (!v) return "-"; + 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 d27702f..0000000 --- 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 0000000..9e98685 --- /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/devbox/create.ts b/src/commands/devbox/create.ts index bbcb98e..60afca3 100644 --- a/src/commands/devbox/create.ts +++ b/src/commands/devbox/create.ts @@ -9,6 +9,13 @@ import { getAgent, type Agent, } from "../../services/agentService.js"; +import { getObject } from "../../services/objectService.js"; +import { + DEFAULT_MOUNT_PATH, + sanitizeMountSegment, + adjustFileExtension, + getDefaultAgentMountPath, +} from "../../utils/mount.js"; interface CreateOptions { name?: string; @@ -32,7 +39,7 @@ interface CreateOptions { gateways?: string[]; mcp?: string[]; agent?: string[]; - agentPath?: string; + object?: string[]; output?: string; } @@ -293,28 +300,100 @@ export async function createDevbox(options: CreateOptions = {}) { createRequest.mcp = parseMcpSpecs(options.mcp); } - // Handle agent mount + // Parse agent mounts (format: name_or_id or name_or_id:/mount/path) + const resolvedAgents: { agent: Agent; path?: string }[] = []; if (options.agent && options.agent.length > 0) { - if (options.agent.length > 1) { - throw new Error( - "Mounting multiple agents via rli is not supported yet", - ); + const parsedAgentSpecs: { idOrName: string; path?: string }[] = []; + for (const spec of options.agent) { + const colonIdx = spec.indexOf(":"); + // Only treat colon as separator if what follows looks like an absolute path + if (colonIdx > 0 && spec[colonIdx + 1] === "/") { + parsedAgentSpecs.push({ + idOrName: spec.substring(0, colonIdx), + path: spec.substring(colonIdx + 1), + }); + } else { + parsedAgentSpecs.push({ idOrName: spec }); + } } - const agent = await resolveAgent(options.agent[0]); - const mount: Record = { - type: "agent_mount", - agent_id: agent.id, - agent_name: null, - }; - // agent_path only makes sense for git and object agents. Since - // we don't know at this stage what type of agent it is, - // however, we'll let the server error inform the user if they - // add this option in a case where it doesn't make sense. - if (options.agentPath) { - mount.agent_path = options.agentPath; + const resolved = await Promise.all( + parsedAgentSpecs.map(async ({ idOrName, path }) => ({ + agent: await resolveAgent(idOrName), + path, + })), + ); + resolvedAgents.push(...resolved); + } + + // Parse object mounts (format: object_id or object_id:/mount/path) + const objectMounts: { object_id: string; object_path: string }[] = []; + if (options.object && options.object.length > 0) { + const parsedObjectSpecs: { + objectId: string; + explicitPath?: string; + }[] = []; + for (const spec of options.object) { + const colonIdx = spec.indexOf(":"); + if (colonIdx > 0 && spec[colonIdx + 1] === "/") { + parsedObjectSpecs.push({ + objectId: spec.substring(0, colonIdx), + explicitPath: spec.substring(colonIdx + 1), + }); + } else { + parsedObjectSpecs.push({ objectId: spec }); + } } + const resolved = await Promise.all( + parsedObjectSpecs.map(async ({ objectId, explicitPath }) => { + if (explicitPath) { + return { object_id: objectId, object_path: explicitPath }; + } + // No path specified — fetch object to generate default + const obj = await getObject(objectId); + const name = obj.name; + const contentType = obj.content_type; + if (name) { + const adjusted = adjustFileExtension(name, contentType); + const s = sanitizeMountSegment(adjusted); + const objectPath = s + ? `${DEFAULT_MOUNT_PATH}/${s}` + : `${DEFAULT_MOUNT_PATH}/object_${objectId.slice(-8)}`; + return { object_id: objectId, object_path: objectPath }; + } + return { + object_id: objectId, + object_path: `${DEFAULT_MOUNT_PATH}/object_${objectId.slice(-8)}`, + }; + }), + ); + objectMounts.push(...resolved); + } + + // Add mounts (agents + objects) + if (resolvedAgents.length > 0 || objectMounts.length > 0) { if (!createRequest.mounts) createRequest.mounts = []; - (createRequest.mounts as unknown[]).push(mount); + for (const { agent, path } of resolvedAgents) { + const mount: Record = { + type: "agent_mount", + agent_id: agent.id, + agent_name: null, + }; + const sourceType = agent.source?.type; + const needsPath = sourceType === "git" || sourceType === "object"; + const effectivePath = + path || (needsPath ? getDefaultAgentMountPath(agent) : undefined); + if (effectivePath) { + mount.agent_path = effectivePath; + } + (createRequest.mounts as unknown[]).push(mount); + } + for (const om of objectMounts) { + (createRequest.mounts as unknown[]).push({ + type: "object_mount", + object_id: om.object_id, + object_path: om.object_path, + }); + } } if (Object.keys(launchParameters).length > 0) { diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 1bffd70..988b558 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 {""} } /> @@ -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/components/AgentsObjectsMenu.tsx b/src/components/AgentsObjectsMenu.tsx new file mode 100644 index 0000000..a52a106 --- /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 2c31783..279198d 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 23e51ca..cd44ded 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -15,7 +15,7 @@ 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 { ObjectPicker, type ObjectListItem } from "./ObjectPicker.js"; import { formatTimeAgo } from "./ResourceListView.js"; import { getStatusDisplay } from "./StatusBadge.js"; import { @@ -31,11 +31,6 @@ import { listSnapshots } from "../services/snapshotService.js"; import { listNetworkPolicies } from "../services/networkPolicyService.js"; import { listGatewayConfigs } from "../services/gatewayConfigService.js"; import { listMcpConfigs } from "../services/mcpConfigService.js"; -import { - listAgents, - listPublicAgents, - type Agent, -} from "../services/agentService.js"; import type { Blueprint } from "../store/blueprintStore.js"; import type { Snapshot } from "../store/snapshotStore.js"; import type { NetworkPolicy } from "../store/networkPolicyStore.js"; @@ -44,6 +39,16 @@ import type { McpConfig } from "../store/mcpConfigStore.js"; import { SecretCreatePage } from "./SecretCreatePage.js"; import { GatewayConfigCreatePage } from "./GatewayConfigCreatePage.js"; import { McpConfigCreatePage } from "./McpConfigCreatePage.js"; +import { + getAgent, + listAgents, + listPublicAgents, + type Agent, +} from "../services/agentService.js"; +import { + getDefaultAgentMountPath, + getDefaultObjectMountPath, +} from "../utils/mount.js"; // Secret list interface for the picker interface SecretListItem { @@ -57,6 +62,7 @@ interface DevboxCreatePageProps { onCreate?: (devbox: DevboxView) => void; initialBlueprintId?: string; initialSnapshotId?: string; + initialAgentId?: string; } type FormField = @@ -74,7 +80,8 @@ type FormField = | "tunnel_auth_mode" | "gateways" | "mcpConfigs" - | "agent"; + | "agent" + | "objectMounts"; // Gateway configuration for devbox interface GatewaySpec { @@ -122,7 +129,19 @@ interface FormData { tunnel_auth_mode: "none" | "open" | "authenticated"; gateways: GatewaySpec[]; mcpConfigs: McpSpec[]; - agent_id: string; + agentMounts: Array<{ + agent_id: string; + agent_name: string; + agent_path: string; + source_type?: string; + version?: string; + package_name?: string; + }>; + objectMounts: Array<{ + object_id: string; + object_name: string; + object_path: string; + }>; } const architectures = ["arm64", "x86_64"] as const; @@ -137,11 +156,98 @@ const resourceSizes = [ ] as const; const tunnelAuthModes = ["none", "open", "authenticated"] as const; +// Agent picker wrapper that adds Tab key to switch between private/public +function AgentPickerWithTabs({ + agentTab, + setAgentTab, + buildAgentColumns, + onSelect, + onCancel, + excludeAgentIds, +}: { + agentTab: "private" | "public"; + setAgentTab: (tab: "private" | "public") => void; + buildAgentColumns: (tw: number) => Column[]; + onSelect: (agents: Agent[]) => void; + onCancel: () => void; + excludeAgentIds?: Set; +}) { + useInput((input, key) => { + if (key.tab) { + setAgentTab(agentTab === "private" ? "public" : "private"); + } + }); + + return ( + + + + Private + + | + + Public + + + {" "} + (Tab to switch) + + + + key={`agent-picker-${agentTab}`} + config={{ + title: `Select Agent (${agentTab})`, + fetchPage: async (params) => { + const fetchFn = + agentTab === "public" ? listPublicAgents : listAgents; + const result = await fetchFn({ + limit: params.limit, + startingAfter: params.startingAt, + search: params.search, + privateOnly: agentTab === "private" ? true : undefined, + }); + const filtered = excludeAgentIds?.size + ? result.agents.filter((a) => !excludeAgentIds.has(a.id)) + : result.agents; + return { + items: filtered, + hasMore: result.hasMore, + totalCount: result.totalCount, + }; + }, + getItemId: (a) => a.id, + getItemLabel: (a) => a.name, + columns: buildAgentColumns, + mode: "single", + additionalOverhead: 1, + emptyMessage: `No ${agentTab} agents found`, + searchPlaceholder: "Search agents...", + breadcrumbItems: [ + { label: "Devboxes" }, + { label: "Create" }, + { label: "Select Agent", active: true }, + ], + }} + onSelect={onSelect} + onCancel={onCancel} + initialSelected={[]} + /> + + ); +} + export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initialSnapshotId, + initialAgentId, }: DevboxCreatePageProps) => { const [currentField, setCurrentField] = React.useState("create"); const [formData, setFormData] = React.useState({ @@ -159,7 +265,8 @@ export const DevboxCreatePage = ({ tunnel_auth_mode: "none", gateways: [], mcpConfigs: [], - agent_id: "", + agentMounts: [], + objectMounts: [], }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -243,10 +350,66 @@ export const DevboxCreatePage = ({ // Agent picker states const [showAgentPicker, setShowAgentPicker] = React.useState(false); - const [selectedAgentName, setSelectedAgentName] = React.useState(""); - const [agentPickerTab, setAgentPickerTab] = React.useState< - "private" | "public" - >("private"); + const [agentTab, setAgentTab] = React.useState<"private" | "public">( + "private", + ); + const [inAgentMountSection, setInAgentMountSection] = React.useState(false); + const [selectedAgentMountIndex, setSelectedAgentMountIndex] = + React.useState(0); + const [editingAgentMountPath, setEditingAgentMountPath] = + React.useState(false); + + // Object mount picker states + const [showObjectPicker, setShowObjectPicker] = React.useState(false); + const [inObjectMountSection, setInObjectMountSection] = React.useState(false); + const [selectedObjectMountIndex, setSelectedObjectMountIndex] = + React.useState(0); + const [editingObjectMountPath, setEditingObjectMountPath] = + React.useState(false); + + // Load initial agent if provided (e.g., from "Create Devbox" on agent detail) + React.useEffect(() => { + if (!initialAgentId) return; + let cancelled = false; + getAgent(initialAgentId) + .then((agent) => { + if (cancelled) return; + setFormData((prev) => { + // Skip if this agent is already mounted + if (prev.agentMounts.some((m) => m.agent_id === agent.id)) { + return prev; + } + const source = agent.source; + const sourceType = source?.type; + const needsPath = sourceType === "git" || sourceType === "object"; + return { + ...prev, + agentMounts: [ + ...prev.agentMounts, + { + agent_id: agent.id, + agent_name: agent.name, + agent_path: needsPath ? getDefaultAgentMountPath(agent) : "", + source_type: sourceType, + version: agent.version, + package_name: + sourceType === "npm" + ? source?.npm?.package_name + : sourceType === "pip" + ? source?.pip?.package_name + : undefined, + }, + ], + }; + }); + }) + .catch(() => { + /* silently ignore — agent may not be accessible */ + }); + return () => { + cancelled = true; + }; + }, [initialAgentId]); const baseFields: Array<{ key: FormField; @@ -301,7 +464,9 @@ export const DevboxCreatePage = ({ | "picker" | "source" | "gateways" - | "mcpConfigs"; + | "mcpConfigs" + | "agent" + | "objectMounts"; placeholder?: string; }> = [ { @@ -342,9 +507,15 @@ export const DevboxCreatePage = ({ }, { key: "agent", - label: "Agent (optional)", - type: "picker", - placeholder: "Select an agent to mount...", + label: "Agents (optional)", + type: "agent", + placeholder: "Mount agents...", + }, + { + key: "objectMounts", + label: "Object Mounts (optional)", + type: "objectMounts", + placeholder: "Mount storage objects...", }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; @@ -429,6 +600,28 @@ export const DevboxCreatePage = ({ return; } + // Enter key on agent field to open agent picker or enter section + if (currentField === "agent" && key.return) { + if (formData.agentMounts.length > 0) { + setInAgentMountSection(true); + setSelectedAgentMountIndex(0); + } else { + setShowAgentPicker(true); + } + return; + } + + // Enter key on objectMounts field to open object picker or enter section + if (currentField === "objectMounts" && key.return) { + if (formData.objectMounts.length > 0) { + setInObjectMountSection(true); + setSelectedObjectMountIndex(0); + } else { + setShowObjectPicker(true); + } + return; + } + // Enter key on metadata field to enter metadata section if (currentField === "metadata" && key.return) { setInMetadataSection(true); @@ -480,10 +673,6 @@ export const DevboxCreatePage = ({ setShowNetworkPolicyPicker(true); return; } - if (currentField === "agent" && key.return) { - setShowAgentPicker(true); - return; - } // Enter on the create button to submit if (currentField === "create" && key.return) { @@ -527,10 +716,64 @@ export const DevboxCreatePage = ({ !showMcpSecretPicker && !showInlineMcpSecretCreate && !showInlineMcpConfigCreate && - !showAgentPicker, + !showAgentPicker && + !showObjectPicker && + !inAgentMountSection && + !inObjectMountSection, }, ); + // Handle agent selection - adds agent to agentMounts array + const handleAgentSelect = React.useCallback((agents: Agent[]) => { + if (agents.length > 0) { + const agent = agents[0]; + const sourceType = agent.source?.type; + const needsPath = sourceType === "git" || sourceType === "object"; + const defaultPath = needsPath ? getDefaultAgentMountPath(agent) : ""; + + setFormData((prev) => ({ + ...prev, + agentMounts: [ + ...prev.agentMounts, + { + agent_id: agent.id, + agent_name: agent.name, + agent_path: defaultPath, + source_type: sourceType, + version: agent.version, + package_name: + sourceType === "npm" + ? agent.source?.npm?.package_name + : sourceType === "pip" + ? agent.source?.pip?.package_name + : undefined, + }, + ], + })); + } + setShowAgentPicker(false); + }, []); + + // Handle object selection for mounting + const handleObjectSelect = React.useCallback((objects: ObjectListItem[]) => { + if (objects.length > 0) { + const obj = objects[0]; + const defaultPath = getDefaultObjectMountPath(obj); + setFormData((prev) => ({ + ...prev, + objectMounts: [ + ...prev.objectMounts, + { + object_id: obj.id, + object_name: obj.name || obj.id, + object_path: defaultPath, + }, + ], + })); + } + setShowObjectPicker(false); + }, []); + // Handle blueprint selection const handleBlueprintSelect = React.useCallback((blueprints: Blueprint[]) => { if (blueprints.length > 0) { @@ -574,28 +817,6 @@ export const DevboxCreatePage = ({ [], ); - // Handle agent selection - const handleAgentSelect = React.useCallback((agents: Agent[]) => { - if (agents.length > 0) { - const agent = agents[0]; - setFormData((prev) => ({ ...prev, agent_id: agent.id })); - setSelectedAgentName(agent.name || agent.id); - } - setShowAgentPicker(false); - }, []); - - // Handle tab switching in agent picker - useInput( - (input, key) => { - if (key.tab) { - setAgentPickerTab((prev) => - prev === "private" ? "public" : "private", - ); - } - }, - { isActive: showAgentPicker }, - ); - // Handle gateway config selection const handleGatewaySelect = React.useCallback((configs: GatewayConfig[]) => { if (configs.length > 0) { @@ -1013,8 +1234,163 @@ export const DevboxCreatePage = ({ !showMcpPicker && !showMcpSecretPicker && !showInlineMcpSecretCreate && - !showInlineMcpConfigCreate && - !showAgentPicker, + !showInlineMcpConfigCreate, + }, + ); + + // Agent mount section input handler + useInput( + (input, key) => { + if (editingAgentMountPath) { + // In path editing mode, only handle escape to exit + if (key.escape || key.return) { + setEditingAgentMountPath(false); + return; + } + return; // Let TextInput handle everything else + } + + const maxIndex = formData.agentMounts.length + 1; // items + "Add" + "Done" + + if (key.escape) { + setInAgentMountSection(false); + return; + } + + if (key.upArrow && selectedAgentMountIndex > 0) { + setSelectedAgentMountIndex(selectedAgentMountIndex - 1); + return; + } + + if (key.downArrow && selectedAgentMountIndex < maxIndex) { + setSelectedAgentMountIndex(selectedAgentMountIndex + 1); + return; + } + + if (key.return) { + // "Add" button + if (selectedAgentMountIndex === formData.agentMounts.length) { + setInAgentMountSection(false); + setShowAgentPicker(true); + return; + } + // "Done" button + if (selectedAgentMountIndex === formData.agentMounts.length + 1) { + setInAgentMountSection(false); + return; + } + } + + // Edit mount path (only for git/object agents that have paths) + if ( + input === "e" && + selectedAgentMountIndex < formData.agentMounts.length + ) { + const am = formData.agentMounts[selectedAgentMountIndex]; + if (am.source_type === "git" || am.source_type === "object") { + setEditingAgentMountPath(true); + return; + } + } + + // Delete mount + if ( + input === "d" && + selectedAgentMountIndex < formData.agentMounts.length + ) { + setFormData((prev) => ({ + ...prev, + agentMounts: prev.agentMounts.filter( + (_, idx) => idx !== selectedAgentMountIndex, + ), + })); + if ( + selectedAgentMountIndex >= formData.agentMounts.length - 1 && + selectedAgentMountIndex > 0 + ) { + setSelectedAgentMountIndex(selectedAgentMountIndex - 1); + } + return; + } + }, + { + isActive: inAgentMountSection && !showAgentPicker, + }, + ); + + // Object mount section input handler + useInput( + (input, key) => { + if (editingObjectMountPath) { + if (key.escape || key.return) { + setEditingObjectMountPath(false); + return; + } + return; // Let TextInput handle everything else + } + + const maxIndex = formData.objectMounts.length + 1; // +1 for "Add", +1 for "Done" + + if (key.escape) { + setInObjectMountSection(false); + return; + } + + if (key.upArrow && selectedObjectMountIndex > 0) { + setSelectedObjectMountIndex(selectedObjectMountIndex - 1); + return; + } + + if (key.downArrow && selectedObjectMountIndex < maxIndex) { + setSelectedObjectMountIndex(selectedObjectMountIndex + 1); + return; + } + + if (key.return) { + // "Add" button + if (selectedObjectMountIndex === formData.objectMounts.length) { + setInObjectMountSection(false); + setShowObjectPicker(true); + return; + } + // "Done" button + if (selectedObjectMountIndex === formData.objectMounts.length + 1) { + setInObjectMountSection(false); + return; + } + } + + // Edit mount path + if ( + input === "e" && + selectedObjectMountIndex < formData.objectMounts.length + ) { + setEditingObjectMountPath(true); + return; + } + + // Delete mount + if ( + input === "d" && + selectedObjectMountIndex < formData.objectMounts.length + ) { + setFormData((prev) => ({ + ...prev, + objectMounts: prev.objectMounts.filter( + (_, idx) => idx !== selectedObjectMountIndex, + ), + })); + if ( + selectedObjectMountIndex >= formData.objectMounts.length - 1 && + selectedObjectMountIndex > 0 + ) { + setSelectedObjectMountIndex(selectedObjectMountIndex - 1); + } + return; + } + }, + { + isActive: inObjectMountSection && !showObjectPicker, }, ); @@ -1157,15 +1533,34 @@ export const DevboxCreatePage = ({ }; } - // Add agent mount - if (formData.agent_id) { - if (!createParams.mounts) createParams.mounts = []; - // TODO: remove `as any` once SDK types include agent_mount - createParams.mounts.push({ + // Add mounts (agents + objects) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mounts: any[] = []; + + for (const am of formData.agentMounts) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agentMount: any = { type: "agent_mount", - agent_id: formData.agent_id, - agent_name: undefined, - } as any); + agent_id: am.agent_id, + agent_name: null, + }; + if (am.agent_path) { + agentMount.agent_path = am.agent_path; + } + mounts.push(agentMount); + } + + for (const om of formData.objectMounts) { + mounts.push({ + type: "object_mount", + object_id: om.object_id, + object_path: om.object_path, + }); + } + + if (mounts.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (createParams as any).mounts = mounts; } const devbox = await client.devboxes.create(createParams); @@ -1485,172 +1880,6 @@ export const DevboxCreatePage = ({ ); } - // Agent picker - if (showAgentPicker) { - return ( - - - - {agentPickerTab === "private" ? "▸ " : " "}Private - - | - - {agentPickerTab === "public" ? "▸ " : " "}Public - - - {" "} - [Tab] Switch - - - - extraDeps={[agentPickerTab]} - extraOverhead={2} - config={{ - title: - agentPickerTab === "private" - ? "Select Agent (Private)" - : "Select Agent (Public)", - fetchPage: async (params) => { - if (agentPickerTab === "private") { - // When searching by name (not an exact agent ID), also show public results - const isIdSearch = - params.search && /^agt_/i.test(params.search.trim()); - if (params.search && !isIdSearch) { - // Merged pagination: decode dual cursors from opaque nextCursor - let privateCursor: string | undefined; - let publicCursor: string | undefined; - if (params.startingAt) { - try { - const parsed = JSON.parse(params.startingAt); - privateCursor = parsed.p || undefined; - publicCursor = parsed.q || undefined; - } catch { - privateCursor = params.startingAt; - } - } - - // Fetch private first - const privateResult = await listAgents({ - limit: params.limit, - startingAfter: privateCursor, - search: params.search, - }); - - let publicAgentsConsumed: Agent[] = []; - let publicHasMore = false; - let publicTotalCount = 0; - let lastFetchedPublicId = publicCursor; - - // Only include public agents when private is exhausted, - // preventing cross-page duplicates - if (!privateResult.hasMore) { - const remainingSlots = - params.limit - privateResult.agents.length; - if (remainingSlots > 0) { - const privateIds = new Set( - privateResult.agents.map((a) => a.id), - ); - const publicResult = await listPublicAgents({ - limit: remainingSlots, - startingAfter: publicCursor, - search: params.search, - }); - - const uniquePublic = publicResult.agents.filter( - (a) => !privateIds.has(a.id), - ); - publicAgentsConsumed = uniquePublic.slice( - 0, - remainingSlots, - ); - publicHasMore = publicResult.hasMore; - publicTotalCount = publicResult.totalCount; - - lastFetchedPublicId = - publicResult.agents.length > 0 - ? publicResult.agents[publicResult.agents.length - 1] - .id - : publicCursor; - } - } - - const allItems = [ - ...privateResult.agents, - ...publicAgentsConsumed, - ]; - const lastPrivate = - privateResult.agents.length > 0 - ? privateResult.agents[privateResult.agents.length - 1].id - : privateCursor; - - return { - items: allItems, - hasMore: privateResult.hasMore || publicHasMore, - totalCount: privateResult.totalCount + publicTotalCount, - nextCursor: JSON.stringify({ - p: lastPrivate, - q: lastFetchedPublicId, - }), - }; - } - - // Not searching, or searching by exact agent ID: private-only fetch - - const result = await listAgents({ - limit: params.limit, - startingAfter: params.startingAt, - search: params.search || undefined, - }); - return { - items: result.agents, - hasMore: result.hasMore, - totalCount: result.totalCount, - }; - } else { - // Public tab: only fetch public agents - - const publicResult = await listPublicAgents({ - search: params.search, - limit: params.limit, - startingAfter: params.startingAt, - }); - return { - items: publicResult.agents, - hasMore: publicResult.hasMore, - totalCount: publicResult.totalCount, - }; - } - }, - getItemId: (agent) => agent.id, - getItemLabel: (agent) => agent.name || agent.id, - columns: buildAgentTableColumns, - mode: "single", - emptyMessage: "No agents found", - searchPlaceholder: "Search agents...", - breadcrumbItems: [ - { label: "Devboxes" }, - { label: "Create" }, - { label: "Select Agent", active: true }, - ], - }} - onSelect={handleAgentSelect} - onCancel={() => setShowAgentPicker(false)} - initialSelected={[]} - /> - - ); - } - // Inline gateway config creation screen (from gateway attach flow) if (showInlineGatewayConfigCreate) { return ( @@ -2113,6 +2342,92 @@ export const DevboxCreatePage = ({ ); } + // Agent picker + if (showAgentPicker) { + const formatAgentVersion = (a: Agent): string => { + // Hide version for object-based agents + if (a.source?.type === "object") return ""; + const v = a.version || ""; + // Truncate long versions (git SHAs) like runloop-fe does + if (v.length > 16) return `${v.slice(0, 8)}…${v.slice(-4)}`; + return v; + }; + + const buildAgentColumns = (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", formatAgentVersion, { + width: versionWidth, + color: colors.textDim, + }), + createTextColumn( + "created", + "Created", + (a) => (a.create_time_ms ? formatTimeAgo(a.create_time_ms) : ""), + { width: timeWidth, color: colors.textDim }, + ), + ]; + }; + + return ( + setShowAgentPicker(false)} + excludeAgentIds={new Set(formData.agentMounts.map((m) => m.agent_id))} + /> + ); + } + + // Object picker for mounting + if (showObjectPicker) { + return ( + setShowObjectPicker(false)} + initialSelected={[]} + /> + ); + } + // Form screen return ( <> @@ -2261,9 +2576,7 @@ export const DevboxCreatePage = ({ const displayName = field.key === "network_policy_id" ? selectedNetworkPolicyName || value - : field.key === "agent" - ? selectedAgentName || value - : value; + : value; return ( @@ -3104,6 +3417,291 @@ export const DevboxCreatePage = ({ ); } + if (field.type === "agent") { + const agentCount = formData.agentMounts.length; + return ( + + + + {isActive ? figures.pointer : " "} {field.label}:{" "} + + {agentCount} configured + {isActive && ( + + {agentCount > 0 + ? " [Enter to manage]" + : " [Enter to add]"} + + )} + + {!inAgentMountSection && formData.agentMounts.length > 0 && ( + + {formData.agentMounts.map((am) => { + const showVersion = + am.version && am.source_type !== "object"; + const fmtVersion = showVersion + ? am.version!.length > 16 + ? `${am.version!.slice(0, 8)}…${am.version!.slice(-4)}` + : am.version + : ""; + return ( + + + {figures.pointer} {am.agent_name || am.agent_id} + {am.source_type ? ` [${am.source_type}]` : ""} + {fmtVersion ? ` v${fmtVersion}` : ""} + {am.agent_path ? ` → ${am.agent_path}` : ""} + + + ); + })} + + )} + {inAgentMountSection && ( + + + {figures.hamburger} Agent Mounts + + {formData.agentMounts.map((am, idx) => { + const isSelected = selectedAgentMountIndex === idx; + const showVersion = + am.version && am.source_type !== "object"; + const fmtVersion = showVersion + ? am.version!.length > 16 + ? `${am.version!.slice(0, 8)}…${am.version!.slice(-4)}` + : am.version + : ""; + return ( + + + + {isSelected ? figures.pointer : " "}{" "} + + + {am.agent_name || am.agent_id} + + {editingAgentMountPath && isSelected && ( + [editing] + )} + + {am.source_type ? ` [${am.source_type}]` : ""} + {fmtVersion ? ` v${fmtVersion}` : ""} + + {(am.agent_path || + (editingAgentMountPath && isSelected)) && ( + <> + + {editingAgentMountPath && isSelected ? ( + { + setFormData((prev) => ({ + ...prev, + agentMounts: prev.agentMounts.map( + (m, i) => + i === idx + ? { ...m, agent_path: value } + : m, + ), + })); + }} + placeholder="/home/user/agent" + /> + ) : ( + + {am.agent_path} + + )} + + )} + + + ); + })} + + + {selectedAgentMountIndex === agentCount + ? figures.pointer + : " "}{" "} + + Add agent mount + + + + + {selectedAgentMountIndex === agentCount + 1 + ? figures.pointer + : " "}{" "} + Done + + + + + {editingAgentMountPath + ? "Type to edit path • [Enter/esc] Done" + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Remove • [esc] Back`} + + + + )} + + ); + } + + if (field.type === "objectMounts") { + return ( + + + + {isActive ? figures.pointer : " "} {field.label}:{" "} + + + {formData.objectMounts.length} configured + + {isActive && ( + + {formData.objectMounts.length > 0 + ? " [Enter to manage]" + : " [Enter to add]"} + + )} + + {!inObjectMountSection && formData.objectMounts.length > 0 && ( + + {formData.objectMounts.map((om, idx) => ( + + + {figures.pointer} {om.object_name} → {om.object_path} + + + ))} + + )} + {inObjectMountSection && ( + + + {figures.hamburger} Object Mounts + + {formData.objectMounts.map((om, idx) => { + const isSelected = idx === selectedObjectMountIndex; + return ( + + + + {isSelected ? figures.pointer : " "}{" "} + + {om.object_name} + {editingObjectMountPath && isSelected && ( + [editing] + )} + + {editingObjectMountPath && isSelected ? ( + { + setFormData((prev) => ({ + ...prev, + objectMounts: prev.objectMounts.map( + (m, i) => + i === idx + ? { ...m, object_path: value } + : m, + ), + })); + }} + placeholder="/home/user/object" + /> + ) : ( + {om.object_path} + )} + + + ); + })} + + + {selectedObjectMountIndex === + formData.objectMounts.length + ? figures.pointer + : " "}{" "} + + Add object mount + + + + + {selectedObjectMountIndex === + formData.objectMounts.length + 1 + ? figures.pointer + : " "}{" "} + Done + + + + + {editingObjectMountPath + ? "Type to edit path • [Enter/esc] Done" + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Remove • [esc] Back`} + + + + )} + + ); + } + return null; })} @@ -3127,15 +3725,19 @@ export const DevboxCreatePage = ({ )} - {!inMetadataSection && !inGatewaySection && !inMcpSection && ( - - )} + {!inMetadataSection && + !inGatewaySection && + !inMcpSection && + !inAgentMountSection && + !inObjectMountSection && ( + + )} ); }; diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 14df4a8..4670df7 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/ObjectPicker.tsx b/src/components/ObjectPicker.tsx new file mode 100644 index 0000000..ea913ed --- /dev/null +++ b/src/components/ObjectPicker.tsx @@ -0,0 +1,168 @@ +/** + * ObjectPicker - Reusable component for selecting storage objects + * Wraps ResourcePicker with object-specific configuration + */ +import React from "react"; +import { + ResourcePicker, + createTextColumn, + type Column, +} from "./ResourcePicker.js"; +import { formatTimeAgo } from "./ResourceListView.js"; +import { getClient } from "../utils/client.js"; +import { formatFileSize } from "../services/objectService.js"; +import { colors } from "../utils/theme.js"; +import type { BreadcrumbItem } from "./Breadcrumb.js"; + +export interface ObjectListItem { + id: string; + name?: string; + content_type?: string; + size_bytes?: number; + state?: string; + create_time_ms?: number; +} + +export interface ObjectPickerProps { + /** Called when object(s) are selected */ + onSelect: (objects: ObjectListItem[]) => void; + /** Called when picker is cancelled */ + onCancel: () => void; + /** Selection mode - single or multi */ + mode?: "single" | "multi"; + /** Title for the picker */ + title?: string; + /** Breadcrumb items */ + breadcrumbItems?: BreadcrumbItem[]; + /** Initially selected object IDs */ + initialSelected?: string[]; + /** Additional lines of overhead from wrapper components (e.g., tab headers) */ + additionalOverhead?: number; +} + +/** + * Build columns for object picker table + */ +function buildObjectColumns(tw: number): Column[] { + const fixedWidth = 6; + const idWidth = 25; + const typeWidth = 12; + const stateWidth = 10; + const sizeWidth = 10; + const baseWidth = fixedWidth + idWidth + typeWidth + stateWidth + sizeWidth; + const nameWidth = Math.min( + 30, + Math.max(12, Math.floor((tw - baseWidth) * 0.5)), + ); + const timeWidth = Math.max(18, tw - baseWidth - nameWidth); + + return [ + createTextColumn("id", "ID", (o) => o.id, { + width: idWidth + 1, + color: colors.idColor, + }), + createTextColumn("name", "Name", (o) => o.name || "", { + width: nameWidth, + }), + createTextColumn( + "type", + "Type", + (o) => o.content_type || "", + { width: typeWidth, color: colors.textDim }, + ), + createTextColumn("state", "State", (o) => o.state || "", { + width: stateWidth, + color: colors.textDim, + }), + createTextColumn( + "size", + "Size", + (o) => formatFileSize(o.size_bytes), + { width: sizeWidth, color: colors.textDim }, + ), + createTextColumn( + "created", + "Created", + (o) => (o.create_time_ms ? formatTimeAgo(o.create_time_ms) : ""), + { width: timeWidth, color: colors.textDim }, + ), + ]; +} + +/** + * Fetch a page of objects from the API + */ +async function fetchObjectsPage(params: { + limit: number; + startingAt?: string; + search?: string; +}): Promise<{ + items: ObjectListItem[]; + hasMore: boolean; + totalCount?: number; +}> { + const client = getClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queryParams: Record = { + limit: params.limit, + }; + if (params.startingAt) { + queryParams.starting_after = params.startingAt; + } + if (params.search) { + queryParams.search = params.search; + } + const result = await client.objects.list(queryParams); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pageResult = result as any; + const objects = (pageResult.objects || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (o: any) => ({ + id: o.id, + name: o.name, + content_type: o.content_type, + size_bytes: o.size_bytes, + state: o.state, + create_time_ms: o.create_time_ms, + }), + ); + return { + items: objects, + hasMore: pageResult.has_more || false, + totalCount: pageResult.total_count, + }; +} + +/** + * ObjectPicker component for selecting storage objects + */ +export function ObjectPicker({ + onSelect, + onCancel, + mode = "single", + title = "Select Object", + breadcrumbItems, + initialSelected = [], + additionalOverhead, +}: ObjectPickerProps) { + return ( + + key="object-picker" + config={{ + title, + fetchPage: fetchObjectsPage, + getItemId: (o) => o.id, + getItemLabel: (o) => o.name || o.id, + columns: buildObjectColumns, + mode, + emptyMessage: "No objects found", + searchPlaceholder: "Search objects...", + breadcrumbItems, + additionalOverhead, + }} + onSelect={onSelect} + onCancel={onCancel} + initialSelected={initialSelected} + /> + ); +} diff --git a/src/components/ResourcePicker.tsx b/src/components/ResourcePicker.tsx index 519193a..48a660d 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 da10fe2..9d2feb1 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/AgentCreateScreen.tsx b/src/screens/AgentCreateScreen.tsx index 3d664c2..c1de44d 100644 --- a/src/screens/AgentCreateScreen.tsx +++ b/src/screens/AgentCreateScreen.tsx @@ -22,13 +22,14 @@ import { FormActionButton, useFormSelectNavigation, } from "../components/form/index.js"; +import { ObjectPicker } from "../components/ObjectPicker.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; const SOURCE_TYPES = ["npm", "pip", "git", "object"] as const; type SourceType = (typeof SOURCE_TYPES)[number]; -type FormField = +type AgentFormField = | "name" | "version" | "sourceType" @@ -40,28 +41,30 @@ type FormField = | "create"; interface FieldDef { - key: FormField; + key: AgentFormField; label: string; } /** Fields that are always shown */ const baseFields: FieldDef[] = [ { key: "name", label: "Name (required)" }, - { key: "version", label: "Version (required)" }, { key: "sourceType", label: "Source Type" }, ]; /** Source-type-specific fields */ const sourceFields: Record = { npm: [ + { key: "version", label: "Version (optional)" }, { key: "packageName", label: "Package Name (required)" }, { key: "registryUrl", label: "Registry URL (optional)" }, ], pip: [ + { key: "version", label: "Version (optional)" }, { key: "packageName", label: "Package Name (required)" }, { key: "registryUrl", label: "Registry URL (optional)" }, ], git: [ + { key: "version", label: "Version (optional)" }, { key: "repository", label: "Repository URL (required)" }, { key: "ref", label: "Ref (optional)" }, ], @@ -78,7 +81,8 @@ export function AgentCreateScreen() { const { goBack, navigate } = useNavigation(); useExitOnCtrlC(); - const [currentField, setCurrentField] = React.useState("name"); + const [currentField, setCurrentField] = + React.useState("name"); const [formData, setFormData] = React.useState({ name: "", version: "", @@ -96,6 +100,7 @@ export function AgentCreateScreen() { ); const [success, setSuccess] = React.useState(false); const [createdAgentId, setCreatedAgentId] = React.useState(""); + const [showObjectPicker, setShowObjectPicker] = React.useState(false); const fields = getVisibleFields(formData.sourceType); const currentFieldIndex = fields.findIndex((f) => f.key === currentField); @@ -137,13 +142,8 @@ export function AgentCreateScreen() { setCurrentField("name"); return; } - if (!formData.version.trim()) { - setValidationError("Version is required"); - setCurrentField("version"); - return; - } - const st = formData.sourceType; + if ((st === "npm" || st === "pip") && !formData.packageName.trim()) { setValidationError("Package name is required"); setCurrentField("packageName"); @@ -197,7 +197,9 @@ export function AgentCreateScreen() { const agent = await createAgent({ name: formData.name, - version: formData.version, + ...(formData.version.trim() + ? { version: formData.version.trim() } + : {}), source, }); @@ -246,10 +248,44 @@ export function AgentCreateScreen() { handleSubmit(); return; } + + // Enter on objectId field opens object picker when empty + if ( + key.return && + currentField === "objectId" && + formData.sourceType === "object" && + !formData.objectId + ) { + setShowObjectPicker(true); + return; + } }, - { isActive: !submitting }, + { isActive: !submitting && !showObjectPicker }, ); + // Object picker for selecting object source + if (showObjectPicker) { + return ( + { + if (objects.length > 0) { + setFormData((prev) => ({ ...prev, objectId: objects[0].id })); + } + setShowObjectPicker(false); + }} + onCancel={() => setShowObjectPicker(false)} + initialSelected={formData.objectId ? [formData.objectId] : []} + /> + ); + } + // Submitting spinner if (submitting) { return ( @@ -283,7 +319,7 @@ export function AgentCreateScreen() { } // Determine which field has a validation error - const fieldError = (key: FormField): string | undefined => { + const fieldError = (key: AgentFormField): string | undefined => { if (!validationError) return undefined; if (currentField === key) return validationError; return undefined; @@ -317,14 +353,6 @@ export function AgentCreateScreen() { placeholder="Enter agent name..." error={fieldError("name")} /> - setFormData({ ...formData, version: v })} - isActive={currentField === "version"} - placeholder="e.g. 1.0.0 or a 40-char SHA" - error={fieldError("version")} - /> + setFormData({ ...formData, version: v })} + isActive={currentField === "version"} + placeholder="(optional) e.g. 1.0.0" + error={fieldError("version")} + /> + setFormData({ ...formData, version: v })} + isActive={currentField === "version"} + placeholder="(optional) e.g. branch or tag" + error={fieldError("version")} + /> setFormData({ ...formData, objectId: v })} isActive={currentField === "objectId"} - placeholder="Enter object ID..." + placeholder="Enter object ID or press Enter to pick..." error={fieldError("objectId")} /> )} diff --git a/src/screens/AgentDetailScreen.tsx b/src/screens/AgentDetailScreen.tsx index 61415cd..a8a98b9 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"; @@ -24,14 +28,33 @@ interface AgentDetailScreenProps { } export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { - const { goBack } = useNavigation(); + const { goBack, navigate } = 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,87 +101,127 @@ 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 (source?.type !== "object") { + const versionDisplay = agent.version || source?.git?.ref; + if (versionDisplay) { + basicFields.push({ label: "Version", value: versionDisplay }); + } + } 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, + }); + // 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, + }); + } + } + + // "n" is safe here — detail screens don't use n/p pagination keys + const isPublic = agent.is_public; const operations: ResourceOperation[] = [ { - key: "delete", - label: "Delete", - color: colors.error, - icon: figures.cross, - shortcut: "d", + key: "create-devbox", + label: "Create Devbox with Agent", + color: colors.success, + icon: figures.play, + shortcut: "n", }, + ...(isPublic + ? [] + : [ + { + key: "delete", + label: "Delete Agent", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]), ]; - const handleOperation = async (operation: string) => { - if (operation === "delete") { + const handleOperation = (operation: string) => { + if (operation === "create-devbox") { + navigate("devbox-create", { agentId: agent.id }); + } else if (operation === "delete") { setShowDeleteConfirm(true); } }; @@ -178,6 +241,7 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { const buildDetailLines = (a: Agent): React.ReactElement[] => { const lines: React.ReactElement[] = []; + lines.push( Agent Details @@ -201,12 +265,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 +275,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 +298,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 19be4e0..b72f694 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 0000000..9f35505 --- /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 0000000..4515d82 --- /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 0000000..a8ac810 --- /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/DevboxCreateScreen.tsx b/src/screens/DevboxCreateScreen.tsx index 4ea1d0c..9766131 100644 --- a/src/screens/DevboxCreateScreen.tsx +++ b/src/screens/DevboxCreateScreen.tsx @@ -21,6 +21,7 @@ export function DevboxCreateScreen() { onCreate={handleCreate} initialBlueprintId={params.blueprintId} initialSnapshotId={params.snapshotId} + initialAgentId={params.agentId} /> ); } diff --git a/src/screens/MenuScreen.tsx b/src/screens/MenuScreen.tsx index c6c1cf2..33532c5 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 0616394..46cb625 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/services/agentService.ts b/src/services/agentService.ts index d75387f..79f8d49 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -25,9 +25,13 @@ export interface AgentColumn { */ function agentVersionText(agent: Agent): string { const src = (agent as any).source; + if (src?.type === "object") return "-"; + const pkg: string | undefined = src?.npm?.package_name || src?.pip?.package_name; - const version = agent.version || ""; + const version = agent.version || src?.git?.ref || ""; + + if (!version && !pkg) return "-"; // Strip leading @ and any scope prefix for comparison (e.g. "@scope/pkg" -> "pkg") const barePkg = pkg?.replace(/^@[^/]+\//, "") ?? ""; @@ -39,7 +43,7 @@ function agentVersionText(agent: Agent): string { if (showPkg) { return pkg!; } - return version; + return version || "-"; } // Fixed column widths (content + padding). These values never change. @@ -136,6 +140,7 @@ export interface ListAgentsOptions { name?: string; search?: string; version?: string; + includeTotalCount?: boolean; } export interface ListAgentsResult { @@ -160,10 +165,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 +238,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", { @@ -244,7 +257,7 @@ export async function listPublicAgents( export interface CreateAgentOptions { name: string; - version: string; + version?: string; source?: { type: string; npm?: { @@ -267,7 +280,11 @@ export interface CreateAgentOptions { */ export async function createAgent(options: CreateAgentOptions): Promise { const client = getClient(); - return client.agents.create(options); + const { version, ...rest } = options; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { ...rest }; + if (version) params.version = version; + return client.agents.create(params); } /** diff --git a/src/services/axonService.ts b/src/services/axonService.ts index ef23ae5..71dd4f3 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 b4c2647..656331c 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 f4f02cf..80e0ee8 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 30e5846..a6cc54f 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 f1c4ce8..70f920d 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -86,8 +86,14 @@ 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( + "--object ", + "Objects to mount (format: object_id or object_id:/mount/path)", + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -1176,9 +1182,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"); @@ -1189,10 +1197,7 @@ export function createProgram(): Command { .command("create") .description("Create a new agent") .requiredOption("--name ", "Agent name") - .requiredOption( - "--agent-version ", - "Version string (semver or SHA)", - ) + .option("--agent-version ", "Version string (optional)") .requiredOption("--source ", "Source type: npm|pip|git|object") .option("--package ", "Package name (for npm/pip sources)") .option("--registry-url ", "Registry URL (for npm/pip sources)") diff --git a/src/utils/mount.ts b/src/utils/mount.ts new file mode 100644 index 0000000..75e44e4 --- /dev/null +++ b/src/utils/mount.ts @@ -0,0 +1,68 @@ +/** + * Mount path utilities shared between CLI and TUI devbox creation. + */ +import type { Agent } from "../services/agentService.js"; + +export const DEFAULT_MOUNT_PATH = "/home/user"; + +export function sanitizeMountSegment(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); +} + +export function adjustFileExtension( + name: string, + contentType?: string, +): string { + const archiveExts = /\.(tar\.gz|tar\.bz2|tar\.xz|tgz|gz|bz2|xz|zip|tar)$/i; + const stripped = name.replace(archiveExts, ""); + if (stripped !== name) return stripped; + if (contentType && /tar|gzip|x-compressed/i.test(contentType)) { + const dotIdx = name.lastIndexOf("."); + if (dotIdx > 0) return name.substring(0, dotIdx); + } + return name; +} + +export function repoBasename(repo: string): string | undefined { + const cleaned = repo + .trim() + .replace(/[?#].*$/, "") + .replace(/\/+$/, ""); + const m = cleaned.match(/(?:[/:])([^/:\s]+?)(?:\.git)?$/); + return m?.[1]; +} + +export function getDefaultAgentMountPath(agent: Agent): string { + const source = agent.source; + if (source?.git?.repository) { + const base = repoBasename(source.git.repository); + if (base) { + const s = sanitizeMountSegment(base); + if (s) return `${DEFAULT_MOUNT_PATH}/${s}`; + } + } + if (agent.name) { + const s = sanitizeMountSegment(agent.name); + if (s) return `${DEFAULT_MOUNT_PATH}/${s}`; + } + return `${DEFAULT_MOUNT_PATH}/agent`; +} + +export function getDefaultObjectMountPath(obj: { + id: string; + name?: string; + content_type?: string; +}): string { + if (obj.name) { + const adjusted = adjustFileExtension(obj.name, obj.content_type); + const sanitized = sanitizeMountSegment(adjusted); + if (sanitized) return `${DEFAULT_MOUNT_PATH}/${sanitized}`; + } + const suffix = obj.id.slice(-8); + return `${DEFAULT_MOUNT_PATH}/object_${suffix}`; +} diff --git a/tests/__tests__/commands/agent/list.test.ts b/tests/__tests__/commands/agent/list.test.ts index 8e844ec..4fbc4fd 100644 --- a/tests/__tests__/commands/agent/list.test.ts +++ b/tests/__tests__/commands/agent/list.test.ts @@ -5,15 +5,21 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals"; const mockListAgents = jest.fn(); +const mockListPublicAgents = jest.fn(); +const mockDeleteAgent = jest.fn(); jest.unstable_mockModule("@/services/agentService.js", () => ({ listAgents: mockListAgents, + listPublicAgents: mockListPublicAgents, + deleteAgent: mockDeleteAgent, })); const mockOutput = jest.fn(); const mockOutputError = jest.fn(); +const mockParseLimit = jest.fn().mockReturnValue(50); jest.unstable_mockModule("@/utils/output.js", () => ({ output: mockOutput, outputError: mockOutputError, + parseLimit: mockParseLimit, })); const sampleAgents = [ @@ -125,75 +131,23 @@ describe("listAgentsCommand", () => { ); }); - it("should show PRIVATE banner by default", async () => { + it("should output deduped agents in default (non-TUI) mode", async () => { mockListAgents.mockResolvedValue({ agents: sampleAgents }); const { listAgentsCommand } = await import("@/commands/agent/list.js"); - const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - await listAgentsCommand({}); - - const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(allOutput).toContain("PRIVATE"); - expect(allOutput).toContain("--public"); - - logSpy.mockRestore(); - }); - - it("should show PUBLIC banner with --public flag", async () => { - mockListAgents.mockResolvedValue({ agents: [] }); - - const { listAgentsCommand } = await import("@/commands/agent/list.js"); - const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - await listAgentsCommand({ public: true }); - - const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(allOutput).toContain("PUBLIC"); - expect(allOutput).toContain("--private"); - - logSpy.mockRestore(); - }); - - it("should size columns to fit content", async () => { - const agents = [ - { - id: "agt_short", - name: "a", - version: "1", - is_public: false, - create_time_ms: 1000, - }, - { - id: "agt_a_much_longer_id_value", - name: "a-much-longer-agent-name", - version: "12.345.6789", - is_public: false, - create_time_ms: 2000, - }, - ]; - mockListAgents.mockResolvedValue({ agents }); - - const { listAgentsCommand } = await import("@/commands/agent/list.js"); - const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); await listAgentsCommand({}); - // Find the header line (first line after the banner and blank line) - const lines = logSpy.mock.calls.map((c) => String(c[0])); - // Header row contains all column names - const headerLine = lines.find( - (l) => l.includes("NAME") && l.includes("ID") && l.includes("VERSION"), + // CLI command delegates to output() with deduped agents + expect(mockOutput).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: "claude-code", version: "2.0.65" }), + expect.objectContaining({ name: "my-agent", version: "0.1.0" }), + ]), + expect.any(Object), ); - expect(headerLine).toBeDefined(); - - // The two data rows should have their IDs starting at the same column offset - const dataLines = lines.filter((l) => l.includes("agt_")); - expect(dataLines).toHaveLength(2); - - // Both IDs should be at the same column position (aligned) - const idPos0 = dataLines[0].indexOf("agt_"); - const idPos1 = dataLines[1].indexOf("agt_"); - expect(idPos0).toBe(idPos1); - - logSpy.mockRestore(); + // Should have deduped claude-code to only the latest version + const outputAgents = mockOutput.mock.calls[0][0] as typeof sampleAgents; + expect(outputAgents).toHaveLength(2); }); it("should handle API errors gracefully", async () => { diff --git a/tests/__tests__/services/objectService.test.ts b/tests/__tests__/services/objectService.test.ts new file mode 100644 index 0000000..e17a886 --- /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"); + }); +}); diff --git a/tests/__tests__/utils/mount.test.ts b/tests/__tests__/utils/mount.test.ts new file mode 100644 index 0000000..66bb51a --- /dev/null +++ b/tests/__tests__/utils/mount.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "@jest/globals"; +import { + sanitizeMountSegment, + adjustFileExtension, + repoBasename, + getDefaultAgentMountPath, + getDefaultObjectMountPath, + DEFAULT_MOUNT_PATH, +} from "../../../src/utils/mount.js"; +import type { Agent } from "../../../src/services/agentService.js"; + +describe("sanitizeMountSegment", () => { + it("lowercases and replaces invalid characters", () => { + expect(sanitizeMountSegment("My Agent Name")).toBe("my_agent_name"); + }); + + it("collapses consecutive underscores but preserves hyphens", () => { + expect(sanitizeMountSegment("foo---bar___baz")).toBe("foo---bar_baz"); + expect(sanitizeMountSegment("a___b")).toBe("a_b"); + }); + + it("strips leading and trailing underscores", () => { + expect(sanitizeMountSegment("__hello__")).toBe("hello"); + }); + + it("preserves dots and hyphens", () => { + expect(sanitizeMountSegment("my-file.txt")).toBe("my-file.txt"); + }); + + it("returns empty string for all-special-character input", () => { + expect(sanitizeMountSegment("!!!@@@###")).toBe(""); + }); + + it("trims whitespace", () => { + expect(sanitizeMountSegment(" spaced ")).toBe("spaced"); + }); + + it("handles empty string", () => { + expect(sanitizeMountSegment("")).toBe(""); + }); +}); + +describe("adjustFileExtension", () => { + it("strips .tar.gz", () => { + expect(adjustFileExtension("archive.tar.gz")).toBe("archive"); + }); + + it("strips .tgz", () => { + expect(adjustFileExtension("package.tgz")).toBe("package"); + }); + + it("strips .zip", () => { + expect(adjustFileExtension("bundle.zip")).toBe("bundle"); + }); + + it("strips .tar.bz2", () => { + expect(adjustFileExtension("data.tar.bz2")).toBe("data"); + }); + + it("strips .tar.xz", () => { + expect(adjustFileExtension("data.tar.xz")).toBe("data"); + }); + + it("strips .gz", () => { + expect(adjustFileExtension("file.gz")).toBe("file"); + }); + + it("does not strip non-archive extensions", () => { + expect(adjustFileExtension("readme.md")).toBe("readme.md"); + }); + + it("strips extension based on contentType when no archive ext found", () => { + expect(adjustFileExtension("file.dat", "application/gzip")).toBe("file"); + }); + + it("does not strip when contentType does not match archive pattern", () => { + expect(adjustFileExtension("file.dat", "text/plain")).toBe("file.dat"); + }); + + it("case insensitive for archive extensions", () => { + expect(adjustFileExtension("archive.TAR.GZ")).toBe("archive"); + }); + + it("does not strip extension from dotless file via contentType", () => { + expect(adjustFileExtension("archive", "application/gzip")).toBe("archive"); + }); +}); + +describe("repoBasename", () => { + it("extracts from HTTPS URL", () => { + expect(repoBasename("https://github.com/owner/repo")).toBe("repo"); + }); + + it("extracts from HTTPS URL with .git suffix", () => { + expect(repoBasename("https://github.com/owner/repo.git")).toBe("repo"); + }); + + it("extracts from SSH URL", () => { + expect(repoBasename("git@github.com:owner/repo.git")).toBe("repo"); + }); + + it("strips trailing slashes", () => { + expect(repoBasename("https://github.com/owner/repo/")).toBe("repo"); + }); + + it("strips query string and fragment", () => { + expect(repoBasename("https://github.com/owner/repo?ref=main#readme")).toBe( + "repo", + ); + }); + + it("handles whitespace around URL", () => { + expect(repoBasename(" https://github.com/owner/repo ")).toBe("repo"); + }); + + it("returns undefined for empty string", () => { + expect(repoBasename("")).toBeUndefined(); + }); + + it("returns undefined for a bare word without path separator", () => { + expect(repoBasename("justarepo")).toBeUndefined(); + }); +}); + +describe("getDefaultAgentMountPath", () => { + const makeAgent = (overrides: Partial): Agent => ({ + id: "agt_test", + name: "test-agent", + version: "1.0.0", + is_public: false, + create_time_ms: Date.now(), + ...overrides, + }); + + it("uses repo basename for git agents", () => { + const agent = makeAgent({ + source: { + type: "git", + git: { repository: "https://github.com/org/my-repo.git" }, + }, + }); + expect(getDefaultAgentMountPath(agent)).toBe(`${DEFAULT_MOUNT_PATH}/my-repo`); + }); + + it("falls back to agent name when no git source", () => { + const agent = makeAgent({ + name: "My Agent", + source: { type: "npm", npm: { package_name: "my-pkg" } }, + }); + expect(getDefaultAgentMountPath(agent)).toBe( + `${DEFAULT_MOUNT_PATH}/my_agent`, + ); + }); + + it("falls back to /agent when name sanitizes to empty", () => { + const agent = makeAgent({ + name: "!!!", + source: { type: "npm" }, + }); + expect(getDefaultAgentMountPath(agent)).toBe(`${DEFAULT_MOUNT_PATH}/agent`); + }); + + it("falls back to name when git repo basename fails", () => { + const agent = makeAgent({ + name: "fallback-agent", + source: { type: "git", git: { repository: "" } }, + }); + expect(getDefaultAgentMountPath(agent)).toBe( + `${DEFAULT_MOUNT_PATH}/fallback-agent`, + ); + }); +}); + +describe("getDefaultObjectMountPath", () => { + it("uses sanitized object name", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_12345678", + name: "My Data File", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/my_data_file`); + }); + + it("strips archive extensions from name", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_12345678", + name: "dataset.tar.gz", + content_type: "application/gzip", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/dataset`); + }); + + it("falls back to object ID suffix when no name", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_abcd1234efgh5678", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_efgh5678`); + }); + + it("falls back to object ID suffix when name sanitizes to empty", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_abcd1234efgh5678", + name: "!!!", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_efgh5678`); + }); + + it("uses last 8 chars of id for fallback", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_short", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_bj_short`); + }); +});