From 50bfcef7cf591f24450e71a253eb83ad5260ce27 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Thu, 16 Apr 2026 03:16:40 -0700 Subject: [PATCH 1/7] Agents tab, private + public agents fix list display fix list view refactor to consolidate shared rendering code remove dead agentStore code adjust labels remove multi selection for benchmark job agents. UX was too ugly so simplifying for now --- src/commands/agent/list.ts | 101 ++---- src/components/BenchmarkMenu.tsx | 6 +- src/components/DevboxCreatePage.tsx | 245 ++++++++++++- src/components/MainMenu.tsx | 9 + src/components/ResourcePicker.tsx | 109 +++--- src/components/agentColumns.ts | 47 +++ src/hooks/useCursorPagination.ts | 13 +- src/router/Router.tsx | 20 + src/screens/AgentCreateScreen.tsx | 444 +++++++++++++++++++++++ src/screens/AgentDetailScreen.tsx | 331 +++++++++++++++++ src/screens/AgentListScreen.tsx | 279 ++++++++++++++ src/screens/BenchmarkJobCreateScreen.tsx | 29 +- src/screens/MenuScreen.tsx | 3 + src/services/agentService.ts | 73 +++- src/store/navigationStore.tsx | 6 +- 15 files changed, 1558 insertions(+), 157 deletions(-) create mode 100644 src/components/agentColumns.ts create mode 100644 src/screens/AgentCreateScreen.tsx create mode 100644 src/screens/AgentDetailScreen.tsx create mode 100644 src/screens/AgentListScreen.tsx diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts index 0ec84fd4..24730752 100644 --- a/src/commands/agent/list.ts +++ b/src/commands/agent/list.ts @@ -3,9 +3,12 @@ */ import chalk from "chalk"; -import { listAgents, type Agent } from "../../services/agentService.js"; +import { + listAgents, + getAgentColumns, + type Agent, +} from "../../services/agentService.js"; import { output, outputError } from "../../utils/output.js"; -import { formatTimeAgo } from "../../utils/time.js"; interface ListOptions { full?: boolean; @@ -16,81 +19,16 @@ interface ListOptions { 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 as any).source?.type || "-", - styled(a) { - return this.raw(a); - }, - }, - { - header: "VERSION", - raw: (a) => { - const pkg = - (a as any).source?.npm?.package_name || - (a as any).source?.pip?.package_name; - return pkg ? `${pkg}@${a.version}` : a.version; - }, - styled(a) { - const pkg = - (a as any).source?.npm?.package_name || - (a as any).source?.pip?.package_name; - return pkg ? chalk.dim(pkg + "@") + a.version : a.version; - }, +/** 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; }, - { - 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)); @@ -105,18 +43,23 @@ export function printAgentTable(agents: Agent[]): void { return; } - const widths = computeColumnWidths(agents); const termWidth = process.stdout.columns || 120; + const columns = getAgentColumns(agents, termWidth); // Header - const header = columns.map((col, i) => col.header.padEnd(widths[i])).join(""); + 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, i) => padStyled(col.raw(agent), col.styled(agent), widths[i])) + .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); } diff --git a/src/components/BenchmarkMenu.tsx b/src/components/BenchmarkMenu.tsx index 1cdae0eb..2c31783a 100644 --- a/src/components/BenchmarkMenu.tsx +++ b/src/components/BenchmarkMenu.tsx @@ -67,21 +67,21 @@ interface BenchmarkMenuItem { const benchmarkMenuItems: BenchmarkMenuItem[] = [ { key: "benchmarks", - label: "Benchmark Defs", + label: "Available Benchmarks", description: "View benchmark definitions", icon: "◉", color: colors.primary, }, { key: "benchmark-jobs", - label: "Orchestrator Jobs", + label: "Benchmark Orchestrator Jobs", description: "Run and manage benchmark jobs", icon: "▲", color: colors.warning, }, { key: "benchmark-runs", - label: "Legacy Runs", + label: "Manual Benchmark Runs", description: "View and manage benchmark executions", icon: "◇", color: colors.success, diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 9fe8828c..835f98ce 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -14,7 +14,12 @@ import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { MetadataDisplay } from "./MetadataDisplay.js"; -import { ResourcePicker, createTextColumn, Column } from "./ResourcePicker.js"; +import { + ResourcePicker, + createTextColumn, + Column, +} from "./ResourcePicker.js"; +import { buildAgentTableColumns } from "./agentColumns.js"; import { formatTimeAgo } from "./ResourceListView.js"; import { getStatusDisplay } from "./StatusBadge.js"; import { @@ -30,6 +35,11 @@ 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"; @@ -67,7 +77,8 @@ type FormField = | "network_policy_id" | "tunnel_auth_mode" | "gateways" - | "mcpConfigs"; + | "mcpConfigs" + | "agent"; // Gateway configuration for devbox interface GatewaySpec { @@ -115,6 +126,7 @@ interface FormData { tunnel_auth_mode: "none" | "open" | "authenticated"; gateways: GatewaySpec[]; mcpConfigs: McpSpec[]; + agent_id: string; } const architectures = ["arm64", "x86_64"] as const; @@ -151,6 +163,7 @@ export const DevboxCreatePage = ({ tunnel_auth_mode: "none", gateways: [], mcpConfigs: [], + agent_id: "", }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -232,6 +245,14 @@ export const DevboxCreatePage = ({ const mcpFormFields = ["attach", "mcpConfig", "secret"] as const; const mcpFormFieldIndex = mcpFormFields.indexOf(mcpFormField); + // Agent picker states + const [showAgentPicker, setShowAgentPicker] = React.useState(false); + const [selectedAgentName, setSelectedAgentName] = React.useState(""); + const [agentPickerTab, setAgentPickerTab] = React.useState< + "private" | "public" + >("private"); + const [agentPickerMerged, setAgentPickerMerged] = React.useState(false); + const baseFields: Array<{ key: FormField; label: string; @@ -324,6 +345,12 @@ export const DevboxCreatePage = ({ type: "mcpConfigs", placeholder: "Configure MCP server connections...", }, + { + key: "agent", + label: "Agent (optional)", + type: "picker", + placeholder: "Select an agent to mount...", + }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; @@ -458,6 +485,10 @@ 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) { @@ -500,7 +531,8 @@ export const DevboxCreatePage = ({ !showMcpPicker && !showMcpSecretPicker && !showInlineMcpSecretCreate && - !showInlineMcpConfigCreate, + !showInlineMcpConfigCreate && + !showAgentPicker, }, ); @@ -547,6 +579,28 @@ 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) { @@ -964,7 +1018,8 @@ export const DevboxCreatePage = ({ !showMcpPicker && !showMcpSecretPicker && !showInlineMcpSecretCreate && - !showInlineMcpConfigCreate, + !showInlineMcpConfigCreate && + !showAgentPicker, }, ); @@ -1107,6 +1162,16 @@ export const DevboxCreatePage = ({ }; } + // Add agent mount + if (formData.agent_id) { + if (!createParams.mounts) createParams.mounts = []; + createParams.mounts.push({ + type: "agent_mount", + agent_id: formData.agent_id, + agent_name: null, + } as any); + } + const devbox = await client.devboxes.create(createParams); setResult(devbox); } catch (err) { @@ -1424,6 +1489,174 @@ export const DevboxCreatePage = ({ ); } + // Agent picker + if (showAgentPicker) { + return ( + + + + {agentPickerTab === "private" ? "▸ " : " "}Private + + | + + {agentPickerTab === "public" ? "▸ " : " "}Public + + + {" "} + [Tab] Switch + + + + extraDeps={[agentPickerTab, agentPickerMerged]} + 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) { + setAgentPickerMerged(true); + // 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 || + uniquePublic.length > remainingSlots; + 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 + setAgentPickerMerged(false); + 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 + setAgentPickerMerged(false); + 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={formData.agent_id ? [formData.agent_id] : []} + /> + + ); + } + // Inline gateway config creation screen (from gateway attach flow) if (showInlineGatewayConfigCreate) { return ( @@ -2034,7 +2267,9 @@ export const DevboxCreatePage = ({ const displayName = field.key === "network_policy_id" ? selectedNetworkPolicyName || value - : value; + : field.key === "agent" + ? selectedAgentName || value + : value; return ( diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 6e456835..14df4a8e 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -53,6 +53,13 @@ const allMenuItems: MenuItem[] = [ icon: "◈", color: colors.accent3, }, + { + key: "agents", + label: "Agents", + description: "Manage AI agents for devboxes", + icon: "◆", + color: colors.warning, + }, { key: "objects", label: "Storage Objects", @@ -203,6 +210,8 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { selectByKey("blueprints"); } else if (input === "s") { selectByKey("snapshots"); + } else if (input === "a") { + selectByKey("agents"); } else if (input === "o") { selectByKey("objects"); } else if (input === "e") { diff --git a/src/components/ResourcePicker.tsx b/src/components/ResourcePicker.tsx index 5d23759f..519193ae 100644 --- a/src/components/ResourcePicker.tsx +++ b/src/components/ResourcePicker.tsx @@ -43,6 +43,8 @@ export interface ResourcePickerConfig { items: T[]; hasMore: boolean; totalCount?: number; + /** Opaque cursor for the next page. If omitted, last item ID is used. */ + nextCursor?: string; }>; /** Extract unique ID from an item */ @@ -94,6 +96,12 @@ export interface ResourcePickerProps { /** Initially selected item IDs */ initialSelected?: string[]; + + /** Extra dependencies that reset pagination when changed (e.g., tab switches) */ + extraDeps?: unknown[]; + + /** Extra lines of overhead to account for (e.g., tab bar rendered above the picker) */ + extraOverhead?: number; } /** @@ -104,11 +112,19 @@ export function ResourcePicker({ onSelect, onCancel, initialSelected = [], + extraDeps = [], + extraOverhead = 0, }: ResourcePickerProps) { const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIds, setSelectedIds] = React.useState>( new Set(initialSelected), ); + // Track full item objects for cross-page multi-select + const selectedItemsRef = React.useRef>(new Map()); + // Keep a ref to selectedIds so checkbox render closures stay current + // without needing selectedIds in the columns memo deps + const selectedIdsRef = React.useRef(selectedIds); + selectedIdsRef.current = selectedIds; // Search state const search = useListSearch({ @@ -118,7 +134,7 @@ 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(); + const overhead = 13 + search.getSearchOverhead() + extraOverhead; const { viewportHeight, terminalWidth } = useViewportHeight({ overhead, minHeight: 5, @@ -126,14 +142,33 @@ export function ResourcePicker({ const PAGE_SIZE = viewportHeight; - // Resolve columns - support both static array and function that receives terminalWidth + // Resolve columns - support both static array and function that receives terminalWidth. + // For multi-select, prepend a checkbox column. The render function reads selectedIds + // via ref so the columns array stays stable across selection changes and arrow navigation. const resolvedColumns = React.useMemo(() => { if (!config.columns) return undefined; - if (typeof config.columns === "function") { - return config.columns(terminalWidth); - } - return config.columns; - }, [config.columns, terminalWidth]); + const baseCols = + typeof config.columns === "function" + ? config.columns(terminalWidth) + : config.columns; + + if (config.mode !== "multi") return baseCols; + + const checkboxCol = createComponentColumn( + "_selection", + "", + (row) => { + const isChecked = selectedIdsRef.current.has(config.getItemId(row)); + return ( + + {isChecked ? figures.checkboxOn : figures.checkboxOff}{" "} + + ); + }, + { width: 3 }, + ); + return [checkboxCol, ...baseCols]; + }, [config.columns, config.mode, config.getItemId, terminalWidth]); // Store fetchPage in a ref to avoid dependency issues const fetchPageRef = React.useRef(config.fetchPage); @@ -171,7 +206,7 @@ export function ResourcePicker({ getItemId: config.getItemId, pollInterval: 0, // No polling for picker pollingEnabled: false, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [PAGE_SIZE, search.submittedSearchQuery, ...extraDeps], }); // Handle Ctrl+C to exit @@ -184,6 +219,17 @@ export function ResourcePicker({ } }, [items.length, selectedIndex]); + // Backfill selectedItemsRef with loaded items that match initialSelected IDs. + // This ensures items selected before re-entering the picker are tracked for confirm. + React.useEffect(() => { + for (const item of items) { + const id = config.getItemId(item); + if (selectedIds.has(id) && !selectedItemsRef.current.has(id)) { + selectedItemsRef.current.set(id, item); + } + } + }, [items, config.getItemId, selectedIds]); + const selectedItem = items[selectedIndex]; const minSelection = config.minSelection ?? 1; const canConfirm = @@ -191,7 +237,7 @@ export function ResourcePicker({ ? selectedItem !== undefined : selectedIds.size >= minSelection; - // Toggle selection for multi-select + // Toggle selection for multi-select, tracking full item objects const toggleSelection = React.useCallback( (item: T) => { const id = config.getItemId(item); @@ -199,11 +245,13 @@ export function ResourcePicker({ const next = new Set(prev); if (next.has(id)) { next.delete(id); + selectedItemsRef.current.delete(id); } else { if (config.maxSelection && next.size >= config.maxSelection) { - return prev; // Don't add if at max + return prev; } next.add(id); + selectedItemsRef.current.set(id, item); } return next; }); @@ -211,24 +259,19 @@ export function ResourcePicker({ [config.getItemId, config.maxSelection], ); - // Handle confirmation + // Handle confirmation — returns all selected items across all pages const handleConfirm = React.useCallback(() => { if (config.mode === "single") { if (selectedItem) { onSelect([selectedItem]); } } else { - const selectedItems = items.filter((item) => - selectedIds.has(config.getItemId(item)), - ); - // Also include items from previous pages that were selected - // For now, we only return items from current view - // A more complete implementation would track full item objects - if (selectedItems.length >= minSelection) { - onSelect(selectedItems); + const allSelected = Array.from(selectedItemsRef.current.values()); + if (allSelected.length >= minSelection) { + onSelect(allSelected); } } - }, [config, selectedItem, selectedIds, items, minSelection, onSelect]); + }, [config.mode, selectedItem, minSelection, onSelect]); // Calculate pagination info for display const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); @@ -343,31 +386,7 @@ export function ResourcePicker({ keyExtractor={config.getItemId} selectedIndex={selectedIndex} title={`${config.title.toLowerCase()}[${totalCount}]${config.mode === "multi" ? ` (${selectedIds.size} selected)` : ""}`} - columns={ - config.mode === "multi" - ? [ - // Prepend checkbox column for multi-select mode - createComponentColumn( - "_selection", - "", - (row) => { - const isChecked = selectedIds.has(config.getItemId(row)); - return ( - - {isChecked - ? figures.checkboxOn - : figures.checkboxOff}{" "} - - ); - }, - { width: 3 }, - ), - ...resolvedColumns, - ] - : resolvedColumns - } + columns={resolvedColumns} emptyState={ {figures.info} {config.emptyMessage || "No items found"} diff --git a/src/components/agentColumns.ts b/src/components/agentColumns.ts new file mode 100644 index 00000000..295881ec --- /dev/null +++ b/src/components/agentColumns.ts @@ -0,0 +1,47 @@ +/** + * Shared agent table column builder for interactive views. + * + * Wraps the data-layer `getAgentColumns()` with Table Column styling + * so the agent list screen, devbox create picker, and benchmark job create + * picker all render agents consistently. + */ +import { createTextColumn, type Column } from "./Table.js"; +import { getAgentColumns, type Agent } from "../services/agentService.js"; +import { colors } from "../utils/theme.js"; + +/** Per-column styling overrides. Columns not listed use Table defaults. */ +const columnStyles: Record< + string, + { color?: string; dimColor?: boolean; bold?: boolean } +> = { + id: { color: colors.idColor, dimColor: false, bold: false }, + source: { color: colors.textDim, dimColor: false, bold: false }, + version: { color: colors.accent3, dimColor: false, bold: false }, + created: { color: colors.textDim, dimColor: false, bold: false }, +}; + +// Space consumed by table chrome: borders (2) + selection pointer (2) + border paddingX (2) +const TABLE_CHROME = 6; + +/** + * Build Table Column[] for a given terminal width. + * + * Accounts for table border/pointer chrome so columns fill the available + * content area exactly. Pass as the `columns` prop to Table or ResourcePicker. + */ +export function buildAgentTableColumns( + terminalWidth: number, +): Column[] { + const availableWidth = terminalWidth - TABLE_CHROME; + const agentCols = getAgentColumns([], availableWidth); + + return agentCols.map((col) => { + const style = columnStyles[col.key] ?? {}; + return createTextColumn( + col.key, + col.label, + (a: Agent) => col.getValue(a), + { width: col.width, ...style }, + ); + }); +} diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index f509a29f..0cafde29 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -5,7 +5,11 @@ import React from "react"; */ export interface UsePaginatedListConfig { /** - * Fetch function that takes pagination params and returns a page of results + * Fetch function that takes pagination params and returns a page of results. + * + * If the result includes `nextCursor`, it will be used as `startingAt` for the + * next page instead of deriving it from the last item's ID. This supports + * merged/multi-source pagination where the cursor is opaque. */ fetchPage: (params: { limit: number; @@ -15,6 +19,8 @@ export interface UsePaginatedListConfig { items: T[]; hasMore: boolean; totalCount?: number; + /** Opaque cursor for the next page. If omitted, last item ID is used. */ + nextCursor?: string; }>; /** Number of items per page */ @@ -197,7 +203,10 @@ export function useCursorPagination( setItems(result.items); // Update cursor history for this page - if (result.items.length > 0) { + if (result.nextCursor !== undefined) { + // Use explicit cursor from fetchPage (supports merged/multi-source pagination) + cursorHistoryRef.current[page] = result.nextCursor; + } else if (result.items.length > 0) { const lastItemId = getItemIdRef.current( result.items[result.items.length - 1], ); diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 20dab9b0..da10fe24 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -58,6 +58,9 @@ const KNOWN_SCREENS: Set = new Set([ "benchmark-job-list", "benchmark-job-detail", "benchmark-job-create", + "agent-list", + "agent-detail", + "agent-create", ]); /** @@ -134,6 +137,9 @@ 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 @@ -233,6 +239,11 @@ export function Router() { useBenchmarkJobStore.getState().clearAll(); } break; + + case "agent-list": + case "agent-detail": + case "agent-create": + break; } } @@ -353,6 +364,15 @@ 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 new file mode 100644 index 00000000..9c9e4130 --- /dev/null +++ b/src/screens/AgentCreateScreen.tsx @@ -0,0 +1,444 @@ +/** + * AgentCreateScreen - Screen wrapper for agent creation + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { + createAgent, + type CreateAgentOptions, +} 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 { SuccessMessage } from "../components/SuccessMessage.js"; +import { colors } from "../utils/theme.js"; +import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; + +type SourceType = "npm" | "pip" | "git" | "object"; +type FormField = + | "name" + | "version" + | "sourceType" + | "packageName" + | "registryUrl" + | "repository" + | "ref" + | "objectId" + | "confirm"; + +interface FormData { + name: string; + version: string; + sourceType: SourceType; + packageName: string; + registryUrl: string; + repository: string; + ref: string; + objectId: string; +} + +const SOURCE_TYPES: SourceType[] = ["npm", "pip", "git", "object"]; + +const fieldOrder: FormField[] = [ + "name", + "version", + "sourceType", + "packageName", + "registryUrl", + "repository", + "ref", + "objectId", + "confirm", +]; +const getFieldOrder = (f: FormField) => fieldOrder.indexOf(f); + +export function AgentCreateScreen() { + const { goBack, navigate } = useNavigation(); + useExitOnCtrlC(); + + const [currentField, setCurrentField] = React.useState("name"); + const [formData, setFormData] = React.useState({ + name: "", + version: "", + sourceType: "npm", + packageName: "", + registryUrl: "", + repository: "", + ref: "", + objectId: "", + }); + const [sourceTypeIndex, setSourceTypeIndex] = React.useState(0); + const [creating, setCreating] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + const [createdAgentId, setCreatedAgentId] = React.useState(""); + + const getNextField = (): FormField | null => { + switch (currentField) { + case "name": + return "version"; + case "version": + return "sourceType"; + case "sourceType": { + const st = SOURCE_TYPES[sourceTypeIndex]; + if (st === "npm" || st === "pip") return "packageName"; + if (st === "git") return "repository"; + if (st === "object") return "objectId"; + return "confirm"; + } + case "packageName": + return "registryUrl"; + case "registryUrl": + return "confirm"; + case "repository": + return "ref"; + case "ref": + return "confirm"; + case "objectId": + return "confirm"; + default: + return null; + } + }; + + const handleSubmit = async () => { + setCreating(true); + setError(null); + try { + const st = SOURCE_TYPES[sourceTypeIndex]; + let source: CreateAgentOptions["source"]; + + if (st === "npm") { + source = { + type: "npm", + npm: { + package_name: formData.packageName, + registry_url: formData.registryUrl || undefined, + }, + }; + } else if (st === "pip") { + source = { + type: "pip", + pip: { + package_name: formData.packageName, + registry_url: formData.registryUrl || undefined, + }, + }; + } else if (st === "git") { + source = { + type: "git", + git: { + repository: formData.repository, + ref: formData.ref || undefined, + }, + }; + } else { + source = { + type: "object", + object: { object_id: formData.objectId }, + }; + } + + const agent = await createAgent({ + name: formData.name, + version: formData.version, + source, + }); + + setCreatedAgentId(agent.id); + setSuccess(true); + } catch (err) { + setError(err as Error); + setCreating(false); + } + }; + + useInput( + (input, key) => { + if (success) { + if (key.return) { + navigate("agent-detail", { agentId: createdAgentId }); + } else if (key.escape) { + goBack(); + } + return; + } + + if (error) { + if (key.return || key.escape) { + setError(null); + } + return; + } + + if (creating) return; + + if (key.escape) { + goBack(); + return; + } + + if (currentField === "sourceType") { + if (key.upArrow && sourceTypeIndex > 0) { + setSourceTypeIndex(sourceTypeIndex - 1); + } else if (key.downArrow && sourceTypeIndex < SOURCE_TYPES.length - 1) { + setSourceTypeIndex(sourceTypeIndex + 1); + } else if (key.return) { + setFormData({ + ...formData, + sourceType: SOURCE_TYPES[sourceTypeIndex], + }); + const next = getNextField(); + if (next) setCurrentField(next); + } + return; + } + + if (currentField === "confirm") { + if (key.return) { + handleSubmit(); + } + return; + } + + if (key.return) { + const next = getNextField(); + if (next) setCurrentField(next); + } + }, + { isActive: !creating }, + ); + + if (creating && !success && !error) { + return ( + <> + + + + ); + } + + if (success) { + return ( + <> + + + + + Press [Enter] to view details or [Esc] to go back + + + + ); + } + + if (error) { + return ( + <> + + + + + Press [Enter] or [Esc] to try again + + + + ); + } + + const renderField = ( + field: FormField, + label: string, + value: string, + onChange: (v: string) => void, + ) => { + const isActive = currentField === field; + const isCompleted = getFieldOrder(currentField) > getFieldOrder(field); + return ( + + + {isActive ? figures.pointer : isCompleted ? figures.tick : " "} + + + + {label}:{" "} + + {isActive ? ( + + ) : ( + + {value || "-"} + + )} + + ); + }; + + const st = SOURCE_TYPES[sourceTypeIndex]; + + return ( + + + + + + Create Agent + + + {renderField("name", "Name", formData.name, (v) => + setFormData({ ...formData, name: v }), + )} + {renderField("version", "Version", formData.version, (v) => + setFormData({ ...formData, version: v }), + )} + + {/* Source type selector */} + {getFieldOrder(currentField) >= getFieldOrder("sourceType") && ( + + + + getFieldOrder("sourceType") + ? colors.success + : colors.textDim + } + > + {currentField === "sourceType" + ? figures.pointer + : getFieldOrder(currentField) > getFieldOrder("sourceType") + ? figures.tick + : " "} + + + + Source Type:{" "} + + {currentField !== "sourceType" && ( + {SOURCE_TYPES[sourceTypeIndex]} + )} + + {currentField === "sourceType" && ( + + {SOURCE_TYPES.map((s, i) => ( + + + {i === sourceTypeIndex + ? figures.radioOn + : figures.radioOff} + + + {" "} + {s} + + + ))} + + )} + + )} + + {/* Source-specific fields */} + {getFieldOrder(currentField) > getFieldOrder("sourceType") && ( + <> + {(st === "npm" || st === "pip") && + renderField( + "packageName", + "Package Name", + formData.packageName, + (v) => setFormData({ ...formData, packageName: v }), + )} + {(st === "npm" || st === "pip") && + getFieldOrder(currentField) >= getFieldOrder("registryUrl") && + renderField( + "registryUrl", + "Registry URL (optional)", + formData.registryUrl, + (v) => setFormData({ ...formData, registryUrl: v }), + )} + {st === "git" && + renderField( + "repository", + "Repository URL", + formData.repository, + (v) => setFormData({ ...formData, repository: v }), + )} + {st === "git" && + getFieldOrder(currentField) >= getFieldOrder("ref") && + renderField("ref", "Ref (optional)", formData.ref, (v) => + setFormData({ ...formData, ref: v }), + )} + {st === "object" && + renderField("objectId", "Object ID", formData.objectId, (v) => + setFormData({ ...formData, objectId: v }), + )} + + )} + + {/* Confirm */} + {currentField === "confirm" && ( + + + {figures.pointer} Press [Enter] to create agent + + + )} + + + + + + ); +} diff --git a/src/screens/AgentDetailScreen.tsx b/src/screens/AgentDetailScreen.tsx new file mode 100644 index 00000000..61415cd8 --- /dev/null +++ b/src/screens/AgentDetailScreen.tsx @@ -0,0 +1,331 @@ +/** + * AgentDetailScreen - Detail page for agents + */ +import React from "react"; +import { Box, 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 { useResourceDetail } from "../hooks/useResourceDetail.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; +import { colors } from "../utils/theme.js"; + +interface AgentDetailScreenProps { + agentId?: string; +} + +export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { + const { goBack } = useNavigation(); + + const { data: agent, error } = useResourceDetail({ + id: agentId, + fetch: getAgent, + pollInterval: 5000, + }); + + const [deleting, setDeleting] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const [operationError, setOperationError] = React.useState( + null, + ); + const displayError = error ?? operationError; + + if (!agent && agentId && !error) { + return ( + <> + + + + ); + } + + if (displayError && !agent) { + return ( + <> + + + + ); + } + + if (!agent) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + const basicFields = []; + basicFields.push({ label: "Version", value: agent.version }); + basicFields.push({ + label: "Public", + value: agent.is_public ? "Yes" : "No", + }); + if (agent.create_time_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(agent.create_time_ms), + }); + } + + if (agent.source) { + basicFields.push({ + label: "Source Type", + value: agent.source.type || "-", + }); + if (agent.source.npm) { + basicFields.push({ + label: "Package", + value: agent.source.npm.package_name, + }); + if (agent.source.npm.registry_url) { + basicFields.push({ + label: "Registry", + value: agent.source.npm.registry_url, + }); + } + } + if (agent.source.pip) { + basicFields.push({ + label: "Package", + value: agent.source.pip.package_name, + }); + if (agent.source.pip.registry_url) { + basicFields.push({ + label: "Registry", + value: agent.source.pip.registry_url, + }); + } + } + if (agent.source.git) { + basicFields.push({ + label: "Repository", + value: agent.source.git.repository, + }); + if (agent.source.git.ref) { + basicFields.push({ label: "Ref", value: agent.source.git.ref }); + } + } + if (agent.source.object) { + basicFields.push({ + label: "Object ID", + value: agent.source.object.object_id, + }); + } + } + + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + + const operations: ResourceOperation[] = [ + { + key: "delete", + label: "Delete", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + const handleOperation = async (operation: string) => { + if (operation === "delete") { + setShowDeleteConfirm(true); + } + }; + + const executeDelete = async () => { + if (!agent) return; + setShowDeleteConfirm(false); + setDeleting(true); + try { + await deleteAgent(agent.id); + goBack(); + } catch (err) { + setOperationError(err as Error); + setDeleting(false); + } + }; + + const buildDetailLines = (a: Agent): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + lines.push( + + Agent Details + , + ); + lines.push( + + {" "} + ID: {a.id} + , + ); + lines.push( + + {" "} + Name: {a.name} + , + ); + lines.push( + + {" "} + Version: {a.version} + , + ); + lines.push( + + {" "} + Public: {a.is_public ? "Yes" : "No"} + , + ); + if (a.create_time_ms) { + lines.push( + + {" "} + Created: {new Date(a.create_time_ms).toLocaleString()} + , + ); + } + 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( ); + } + + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(a, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + if (showDeleteConfirm && agent) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + + if (deleting) { + return ( + <> + + + + ); + } + + return ( + a.name} + getId={(a) => a.id} + getStatus={(a) => (a.is_public ? "public" : "private")} + detailSections={detailSections} + operations={operations} + onOperation={handleOperation} + onBack={goBack} + buildDetailLines={buildDetailLines} + /> + ); +} diff --git a/src/screens/AgentListScreen.tsx b/src/screens/AgentListScreen.tsx new file mode 100644 index 00000000..19be4e0c --- /dev/null +++ b/src/screens/AgentListScreen.tsx @@ -0,0 +1,279 @@ +/** + * AgentListScreen - List and manage agents + */ +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"; + +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} + + + + {/* Help Bar */} + + + ); +} diff --git a/src/screens/BenchmarkJobCreateScreen.tsx b/src/screens/BenchmarkJobCreateScreen.tsx index 6da16d8c..693e3a87 100644 --- a/src/screens/BenchmarkJobCreateScreen.tsx +++ b/src/screens/BenchmarkJobCreateScreen.tsx @@ -22,6 +22,7 @@ import { type Scenario, } from "../services/scenarioService.js"; import { listAgents, type Agent } from "../services/agentService.js"; +import { buildAgentTableColumns } from "../components/agentColumns.js"; import { createBenchmarkJob, type BenchmarkJob, @@ -588,21 +589,12 @@ export function BenchmarkJobCreateScreen({ const result = await listAgents({ limit: params.limit, startingAfter: params.startingAt, + search: params.search || undefined, }); - // Apply search filter if provided - let filteredAgents = result.agents; - if (params.search) { - const searchLower = params.search.toLowerCase(); - filteredAgents = result.agents.filter( - (agent) => - agent.name.toLowerCase().includes(searchLower) || - agent.id.toLowerCase().includes(searchLower), - ); - } return { - items: filteredAgents, + items: result.agents, hasMore: result.hasMore, - totalCount: filteredAgents.length, + totalCount: result.totalCount, }; }, [], @@ -672,16 +664,16 @@ export function BenchmarkJobCreateScreen({ [fetchScenariosPage], ); - // Memoize agent picker config (multi-select) + // Memoize agent picker config (single-select) const agentPickerConfig = React.useMemo( () => ({ - title: "Select Agents", + title: "Select Agent", fetchPage: fetchAgentsPage, getItemId: (agent: Agent) => agent.id, getItemLabel: (agent: Agent) => agent.name, getItemStatus: (agent: Agent) => (agent.is_public ? "public" : "private"), - mode: "multi" as const, - minSelection: 1, + columns: buildAgentTableColumns, + mode: "single" as const, emptyMessage: "No agents found", searchPlaceholder: "Search agents...", breadcrumbItems: [ @@ -689,7 +681,7 @@ export function BenchmarkJobCreateScreen({ { label: "Benchmarks" }, { label: "Jobs" }, { label: "Create" }, - { label: "Select Agents", active: true }, + { label: "Select Agent", active: true }, ], }), [fetchAgentsPage], @@ -1027,14 +1019,13 @@ export function BenchmarkJobCreateScreen({ ); } - // Show agent picker (multi-select) + // Show agent picker (single-select) if (screenState === "picking_agents") { return ( config={agentPickerConfig} onSelect={handleAgentSelect} onCancel={() => setScreenState("form")} - initialSelected={formData.agentIds} /> ); } diff --git a/src/screens/MenuScreen.tsx b/src/screens/MenuScreen.tsx index b84d4e8c..c6c1cf2a 100644 --- a/src/screens/MenuScreen.tsx +++ b/src/screens/MenuScreen.tsx @@ -25,6 +25,9 @@ export function MenuScreen() { case "objects": navigate("object-list"); break; + case "agents": + navigate("agent-list"); + break; case "benchmarks": navigate("benchmark-menu"); break; diff --git a/src/services/agentService.ts b/src/services/agentService.ts index b2ec9fe7..9a425a6c 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -2,11 +2,78 @@ * Agent Service - Handles API calls for agents */ import { getClient } from "../utils/client.js"; +import { formatTimeAgo } from "../utils/time.js"; import type { AgentView } from "@runloop/api-client/resources/agents"; // Re-export types export type Agent = AgentView; +// --------------------------------------------------------------------------- +// Shared agent column definitions +// --------------------------------------------------------------------------- + +export interface AgentColumn { + key: string; + label: string; + width: number; + getValue: (agent: Agent) => string; +} + +/** + * Return the version string, prefixed with the package name when it differs + * from the agent name. Handles scoped packages (e.g. @scope/pkg) correctly. + */ +function agentVersionText(agent: Agent): string { + const src = (agent as any).source; + const pkg: string | undefined = + src?.npm?.package_name || src?.pip?.package_name; + const version = agent.version || ""; + + // Strip leading @ and any scope prefix for comparison (e.g. "@scope/pkg" -> "pkg") + const barePkg = pkg?.replace(/^@[^/]+\//, "") ?? ""; + const showPkg = pkg && barePkg !== agent.name; + + if (showPkg && version) { + return `${pkg}@${version}`; + } + if (showPkg) { + return pkg!; + } + return version; +} + +// Fixed column widths (content + padding). These values never change. +const SOURCE_WIDTH = 10; // values: "npm", "pip", "git", "object", "-" +const ID_WIDTH = 27; // agent IDs are ~25 chars +const CREATED_WIDTH = 12; // e.g. "3d ago", "2mo ago" +const MIN_FLEX_WIDTH = 10; // minimum for each flexible column (name, version) +const FIXED_TOTAL = SOURCE_WIDTH + ID_WIDTH + CREATED_WIDTH; + +/** + * Build agent column definitions with widths fitted to `availableWidth`. + * + * Column order: NAME, SOURCE, VERSION, ID, CREATED. + * SOURCE, ID, and CREATED have fixed widths. The remaining space is split + * evenly between NAME and VERSION so the row fills `availableWidth` exactly. + * No data scanning is needed — widths depend only on terminal size. + */ +export function getAgentColumns( + _agents: Agent[], + availableWidth: number, +): AgentColumn[] { + const flexSpace = Math.max(MIN_FLEX_WIDTH * 2, availableWidth - FIXED_TOTAL); + const nameWidth = Math.ceil(flexSpace / 2); + const versionWidth = Math.floor(flexSpace / 2); + + return [ + { key: "name", label: "NAME", width: nameWidth, getValue: (a) => a.name }, + { key: "source", label: "SOURCE", width: SOURCE_WIDTH, getValue: (a) => (a as any).source?.type || "-" }, + { key: "version", label: "VERSION", width: versionWidth, getValue: agentVersionText }, + { key: "id", label: "ID", width: ID_WIDTH, getValue: (a) => a.id }, + { key: "created", label: "CREATED", width: CREATED_WIDTH, getValue: (a) => formatTimeAgo(a.create_time_ms) }, + ]; +} + export interface ListAgentsOptions { limit?: number; startingAfter?: string; @@ -65,7 +132,7 @@ export async function listAgents( queryParams.version = options.version; } - // Use raw HTTP to get has_more from the API response directly + // Use raw HTTP to get has_more and total_count from the API response directly const response = await (client as any).get("/v1/agents", { query: queryParams, }); @@ -73,7 +140,7 @@ export async function listAgents( return { agents, - totalCount: agents.length, + totalCount: response.total_count ?? agents.length, hasMore: response.has_more || false, }; } @@ -116,7 +183,7 @@ export async function listPublicAgents( return { agents, - totalCount: agents.length, + totalCount: response.total_count ?? agents.length, hasMore: response.has_more || false, }; } diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index f9eb3765..30e58466 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -47,7 +47,10 @@ export type ScreenName = | "scenario-run-detail" | "benchmark-job-list" | "benchmark-job-detail" - | "benchmark-job-create"; + | "benchmark-job-create" + | "agent-list" + | "agent-detail" + | "agent-create"; export interface RouteParams { devboxId?: string; @@ -79,6 +82,7 @@ export interface RouteParams { scenarioRunId?: string; benchmarkJobId?: string; initialBenchmarkIds?: string; + agentId?: string; [key: string]: string | ScreenName | RouteParams | undefined; } From 5518903c08e8616442e5da3d018a7dc98fc19630 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Thu, 16 Apr 2026 08:23:36 -0700 Subject: [PATCH 2/7] fmt --- src/components/DevboxCreatePage.tsx | 11 ++++------- src/components/agentColumns.ts | 4 +--- src/services/agentService.ts | 23 +++++++++++++++++++---- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 835f98ce..4ee36e67 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -14,11 +14,7 @@ import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { MetadataDisplay } from "./MetadataDisplay.js"; -import { - ResourcePicker, - createTextColumn, - Column, -} from "./ResourcePicker.js"; +import { ResourcePicker, createTextColumn, Column } from "./ResourcePicker.js"; import { buildAgentTableColumns } from "./agentColumns.js"; import { formatTimeAgo } from "./ResourceListView.js"; import { getStatusDisplay } from "./StatusBadge.js"; @@ -1571,8 +1567,9 @@ export const DevboxCreatePage = ({ search: params.search, }); - const uniquePublic = publicResult.agents - .filter((a) => !privateIds.has(a.id)); + const uniquePublic = publicResult.agents.filter( + (a) => !privateIds.has(a.id), + ); publicAgentsConsumed = uniquePublic.slice( 0, remainingSlots, diff --git a/src/components/agentColumns.ts b/src/components/agentColumns.ts index 295881ec..dd87de1e 100644 --- a/src/components/agentColumns.ts +++ b/src/components/agentColumns.ts @@ -29,9 +29,7 @@ const TABLE_CHROME = 6; * Accounts for table border/pointer chrome so columns fill the available * content area exactly. Pass as the `columns` prop to Table or ResourcePicker. */ -export function buildAgentTableColumns( - terminalWidth: number, -): Column[] { +export function buildAgentTableColumns(terminalWidth: number): Column[] { const availableWidth = terminalWidth - TABLE_CHROME; const agentCols = getAgentColumns([], availableWidth); diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 9a425a6c..44d9e624 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -44,7 +44,7 @@ function agentVersionText(agent: Agent): string { // Fixed column widths (content + padding). These values never change. const SOURCE_WIDTH = 10; // values: "npm", "pip", "git", "object", "-" -const ID_WIDTH = 27; // agent IDs are ~25 chars +const ID_WIDTH = 27; // agent IDs are ~25 chars const CREATED_WIDTH = 12; // e.g. "3d ago", "2mo ago" const MIN_FLEX_WIDTH = 10; // minimum for each flexible column (name, version) const FIXED_TOTAL = SOURCE_WIDTH + ID_WIDTH + CREATED_WIDTH; @@ -67,10 +67,25 @@ export function getAgentColumns( return [ { key: "name", label: "NAME", width: nameWidth, getValue: (a) => a.name }, - { key: "source", label: "SOURCE", width: SOURCE_WIDTH, getValue: (a) => (a as any).source?.type || "-" }, - { key: "version", label: "VERSION", width: versionWidth, getValue: agentVersionText }, + { + key: "source", + label: "SOURCE", + width: SOURCE_WIDTH, + getValue: (a) => (a as any).source?.type || "-", + }, + { + key: "version", + label: "VERSION", + width: versionWidth, + getValue: agentVersionText, + }, { key: "id", label: "ID", width: ID_WIDTH, getValue: (a) => a.id }, - { key: "created", label: "CREATED", width: CREATED_WIDTH, getValue: (a) => formatTimeAgo(a.create_time_ms) }, + { + key: "created", + label: "CREATED", + width: CREATED_WIDTH, + getValue: (a) => formatTimeAgo(a.create_time_ms), + }, ]; } From 7f40bf6238a8c1831da473f6a51e67721a8649a4 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Thu, 16 Apr 2026 13:35:11 -0700 Subject: [PATCH 3/7] switch to more standard way of entering deets in create agent page --- src/screens/AgentCreateScreen.tsx | 504 ++++++++++++++---------------- 1 file changed, 233 insertions(+), 271 deletions(-) diff --git a/src/screens/AgentCreateScreen.tsx b/src/screens/AgentCreateScreen.tsx index 9c9e4130..fbd0cd0f 100644 --- a/src/screens/AgentCreateScreen.tsx +++ b/src/screens/AgentCreateScreen.tsx @@ -1,9 +1,11 @@ /** - * AgentCreateScreen - Screen wrapper for agent creation + * AgentCreateScreen - Form for creating a new agent + * + * Uses the standard form pattern: all visible fields with arrow key navigation, + * left/right to change source type, Enter on Create button to submit. */ import React from "react"; import { Box, Text, useInput } from "ink"; -import TextInput from "ink-text-input"; import figures from "figures"; import { useNavigation } from "../store/navigationStore.js"; import { @@ -13,12 +15,19 @@ import { 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 { SuccessMessage } from "../components/SuccessMessage.js"; +import { + FormTextInput, + FormSelect, + FormActionButton, + useFormSelectNavigation, +} from "../components/form/index.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; -type SourceType = "npm" | "pip" | "git" | "object"; +const SOURCE_TYPES = ["npm", "pip", "git", "object"] as const; +type SourceType = (typeof SOURCE_TYPES)[number]; + type FormField = | "name" | "version" @@ -28,90 +37,133 @@ type FormField = | "repository" | "ref" | "objectId" - | "confirm"; - -interface FormData { - name: string; - version: string; - sourceType: SourceType; - packageName: string; - registryUrl: string; - repository: string; - ref: string; - objectId: string; + | "create"; + +interface FieldDef { + key: FormField; + label: string; } -const SOURCE_TYPES: SourceType[] = ["npm", "pip", "git", "object"]; - -const fieldOrder: FormField[] = [ - "name", - "version", - "sourceType", - "packageName", - "registryUrl", - "repository", - "ref", - "objectId", - "confirm", +/** Fields that are always shown */ +const baseFields: FieldDef[] = [ + { key: "name", label: "Name (required)" }, + { key: "version", label: "Version (required)" }, + { key: "sourceType", label: "Source Type" }, ]; -const getFieldOrder = (f: FormField) => fieldOrder.indexOf(f); + +/** Source-type-specific fields */ +const sourceFields: Record = { + npm: [ + { key: "packageName", label: "Package Name (required)" }, + { key: "registryUrl", label: "Registry URL (optional)" }, + ], + pip: [ + { key: "packageName", label: "Package Name (required)" }, + { key: "registryUrl", label: "Registry URL (optional)" }, + ], + git: [ + { key: "repository", label: "Repository URL (required)" }, + { key: "ref", label: "Ref (optional)" }, + ], + object: [{ key: "objectId", label: "Object ID (required)" }], +}; + +const createButton: FieldDef = { key: "create", label: "Create Agent" }; + +function getVisibleFields(sourceType: SourceType): FieldDef[] { + return [...baseFields, ...sourceFields[sourceType], createButton]; +} export function AgentCreateScreen() { const { goBack, navigate } = useNavigation(); useExitOnCtrlC(); const [currentField, setCurrentField] = React.useState("name"); - const [formData, setFormData] = React.useState({ + const [formData, setFormData] = React.useState({ name: "", version: "", - sourceType: "npm", + sourceType: "npm" as SourceType, packageName: "", registryUrl: "", repository: "", ref: "", objectId: "", }); - const [sourceTypeIndex, setSourceTypeIndex] = React.useState(0); - const [creating, setCreating] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); + const [validationError, setValidationError] = React.useState( + null, + ); const [success, setSuccess] = React.useState(false); - const [createdAgentId, setCreatedAgentId] = React.useState(""); - - const getNextField = (): FormField | null => { - switch (currentField) { - case "name": - return "version"; - case "version": - return "sourceType"; - case "sourceType": { - const st = SOURCE_TYPES[sourceTypeIndex]; - if (st === "npm" || st === "pip") return "packageName"; - if (st === "git") return "repository"; - if (st === "object") return "objectId"; - return "confirm"; + const [createdAgentId, setCreatedAgentId] = React.useState(""); + + const fields = getVisibleFields(formData.sourceType); + const currentFieldIndex = fields.findIndex((f) => f.key === currentField); + + // When source type changes, clear source-specific fields and ensure + // currentField is still valid (it may have been a field from the old type) + const handleSourceTypeChange = React.useCallback( + (newType: SourceType) => { + setFormData((prev) => ({ + ...prev, + sourceType: newType, + packageName: "", + registryUrl: "", + repository: "", + ref: "", + objectId: "", + })); + // If current field isn't in the new field set, stay on sourceType + const newFields = getVisibleFields(newType); + if (!newFields.some((f) => f.key === currentField)) { + setCurrentField("sourceType"); } - case "packageName": - return "registryUrl"; - case "registryUrl": - return "confirm"; - case "repository": - return "ref"; - case "ref": - return "confirm"; - case "objectId": - return "confirm"; - default: - return null; - } - }; + }, + [currentField], + ); + + const handleSourceTypeNav = useFormSelectNavigation( + formData.sourceType, + SOURCE_TYPES, + handleSourceTypeChange, + currentField === "sourceType", + ); const handleSubmit = async () => { - setCreating(true); + setValidationError(null); + + if (!formData.name.trim()) { + setValidationError("Name is required"); + 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"); + return; + } + if (st === "git" && !formData.repository.trim()) { + setValidationError("Repository URL is required"); + setCurrentField("repository"); + return; + } + if (st === "object" && !formData.objectId.trim()) { + setValidationError("Object ID is required"); + setCurrentField("objectId"); + return; + } + + setSubmitting(true); setError(null); try { - const st = SOURCE_TYPES[sourceTypeIndex]; let source: CreateAgentOptions["source"]; - if (st === "npm") { source = { type: "npm", @@ -150,70 +202,56 @@ export function AgentCreateScreen() { }); setCreatedAgentId(agent.id); + setSubmitting(false); setSuccess(true); } catch (err) { setError(err as Error); - setCreating(false); + setSubmitting(false); } }; useInput( - (input, key) => { + (_input, key) => { if (success) { if (key.return) { navigate("agent-detail", { agentId: createdAgentId }); } else if (key.escape) { - goBack(); - } - return; - } - - if (error) { - if (key.return || key.escape) { - setError(null); + navigate("agent-list"); } return; } - if (creating) return; - if (key.escape) { goBack(); return; } - if (currentField === "sourceType") { - if (key.upArrow && sourceTypeIndex > 0) { - setSourceTypeIndex(sourceTypeIndex - 1); - } else if (key.downArrow && sourceTypeIndex < SOURCE_TYPES.length - 1) { - setSourceTypeIndex(sourceTypeIndex + 1); - } else if (key.return) { - setFormData({ - ...formData, - sourceType: SOURCE_TYPES[sourceTypeIndex], - }); - const next = getNextField(); - if (next) setCurrentField(next); - } + // Source type left/right navigation + if (handleSourceTypeNav(_input, key)) return; + + // Arrow up/down field navigation + if (key.upArrow && currentFieldIndex > 0) { + setCurrentField(fields[currentFieldIndex - 1].key); + setValidationError(null); return; } - - if (currentField === "confirm") { - if (key.return) { - handleSubmit(); - } + if (key.downArrow && currentFieldIndex < fields.length - 1) { + setCurrentField(fields[currentFieldIndex + 1].key); + setValidationError(null); return; } - if (key.return) { - const next = getNextField(); - if (next) setCurrentField(next); + // Enter on create button submits + if (key.return && currentField === "create") { + handleSubmit(); + return; } }, - { isActive: !creating }, + { isActive: !submitting }, ); - if (creating && !success && !error) { + // Submitting spinner + if (submitting) { return ( <> @@ -233,209 +272,132 @@ export function AgentCreateScreen() { - - - Press [Enter] to view details or [Esc] to go back - - - - ); - } - - if (error) { - return ( - <> - - - - - Press [Enter] or [Esc] to try again - - ); } - const renderField = ( - field: FormField, - label: string, - value: string, - onChange: (v: string) => void, - ) => { - const isActive = currentField === field; - const isCompleted = getFieldOrder(currentField) > getFieldOrder(field); - return ( - - - {isActive ? figures.pointer : isCompleted ? figures.tick : " "} - - - - {label}:{" "} - - {isActive ? ( - - ) : ( - - {value || "-"} - - )} - - ); + // Determine which field has a validation error + const fieldError = (key: FormField): string | undefined => { + if (!validationError) return undefined; + if (currentField === key) return validationError; + return undefined; }; - const st = SOURCE_TYPES[sourceTypeIndex]; - return ( + {/* Server error banner */} + {error && ( + + + {figures.cross} {error.message} + + + )} + Create Agent - {renderField("name", "Name", formData.name, (v) => - setFormData({ ...formData, name: v }), - )} - {renderField("version", "Version", formData.version, (v) => - setFormData({ ...formData, version: v }), - )} - - {/* Source type selector */} - {getFieldOrder(currentField) >= getFieldOrder("sourceType") && ( - - - - getFieldOrder("sourceType") - ? colors.success - : colors.textDim - } - > - {currentField === "sourceType" - ? figures.pointer - : getFieldOrder(currentField) > getFieldOrder("sourceType") - ? figures.tick - : " "} - - - - Source Type:{" "} - - {currentField !== "sourceType" && ( - {SOURCE_TYPES[sourceTypeIndex]} - )} - - {currentField === "sourceType" && ( - - {SOURCE_TYPES.map((s, i) => ( - - - {i === sourceTypeIndex - ? figures.radioOn - : figures.radioOff} - - - {" "} - {s} - - - ))} - - )} - - )} + setFormData({ ...formData, name: v })} + isActive={currentField === "name"} + 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")} + /> + {/* Source-specific fields */} - {getFieldOrder(currentField) > getFieldOrder("sourceType") && ( + {(formData.sourceType === "npm" || + formData.sourceType === "pip") && ( <> - {(st === "npm" || st === "pip") && - renderField( - "packageName", - "Package Name", - formData.packageName, - (v) => setFormData({ ...formData, packageName: v }), - )} - {(st === "npm" || st === "pip") && - getFieldOrder(currentField) >= getFieldOrder("registryUrl") && - renderField( - "registryUrl", - "Registry URL (optional)", - formData.registryUrl, - (v) => setFormData({ ...formData, registryUrl: v }), - )} - {st === "git" && - renderField( - "repository", - "Repository URL", - formData.repository, - (v) => setFormData({ ...formData, repository: v }), - )} - {st === "git" && - getFieldOrder(currentField) >= getFieldOrder("ref") && - renderField("ref", "Ref (optional)", formData.ref, (v) => - setFormData({ ...formData, ref: v }), - )} - {st === "object" && - renderField("objectId", "Object ID", formData.objectId, (v) => - setFormData({ ...formData, objectId: v }), - )} + setFormData({ ...formData, packageName: v })} + isActive={currentField === "packageName"} + placeholder="e.g. @scope/my-agent" + error={fieldError("packageName")} + /> + setFormData({ ...formData, registryUrl: v })} + isActive={currentField === "registryUrl"} + placeholder="(optional)" + /> )} - - {/* Confirm */} - {currentField === "confirm" && ( - - - {figures.pointer} Press [Enter] to create agent - - + {formData.sourceType === "git" && ( + <> + setFormData({ ...formData, repository: v })} + isActive={currentField === "repository"} + placeholder="e.g. https://github.com/org/repo" + error={fieldError("repository")} + /> + setFormData({ ...formData, ref: v })} + isActive={currentField === "ref"} + placeholder="(optional) branch, tag, or commit" + /> + + )} + {formData.sourceType === "object" && ( + setFormData({ ...formData, objectId: v })} + isActive={currentField === "objectId"} + placeholder="Enter object ID..." + error={fieldError("objectId")} + /> )} + + + + From 9f5a8b360e12f818e52e94ac0d32cdda38e898e3 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Thu, 16 Apr 2026 15:48:24 -0700 Subject: [PATCH 4/7] fix agent column widths --- src/commands/agent/list.ts | 2 +- src/services/agentService.ts | 98 +++++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts index 24730752..2aa6bab6 100644 --- a/src/commands/agent/list.ts +++ b/src/commands/agent/list.ts @@ -44,7 +44,7 @@ export function printAgentTable(agents: Agent[]): void { } const termWidth = process.stdout.columns || 120; - const columns = getAgentColumns(agents, termWidth); + const columns = getAgentColumns(agents, termWidth, false); // Header const header = columns.map((col) => col.label.padEnd(col.width)).join(""); diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 44d9e624..186dcab1 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -49,44 +49,82 @@ const CREATED_WIDTH = 12; // e.g. "3d ago", "2mo ago" const MIN_FLEX_WIDTH = 10; // minimum for each flexible column (name, version) const FIXED_TOTAL = SOURCE_WIDTH + ID_WIDTH + CREATED_WIDTH; +/** Column spec before width calculation */ +interface AgentColumnSpec { + key: string; + label: string; + getValue: (agent: Agent) => string; + /** Fixed width (used in trimToFit mode). Flex columns leave this undefined. */ + fixedWidth?: number; +} + +const columnSpecs: AgentColumnSpec[] = [ + { key: "name", label: "NAME", getValue: (a) => a.name }, + { + key: "source", + label: "SOURCE", + fixedWidth: SOURCE_WIDTH, + getValue: (a) => (a as any).source?.type || "-", + }, + { key: "version", label: "VERSION", getValue: agentVersionText }, + { key: "id", label: "ID", fixedWidth: ID_WIDTH, getValue: (a) => a.id }, + { + key: "created", + label: "CREATED", + fixedWidth: CREATED_WIDTH, + getValue: (a) => formatTimeAgo(a.create_time_ms), + }, +]; + /** * Build agent column definitions with widths fitted to `availableWidth`. * * Column order: NAME, SOURCE, VERSION, ID, CREATED. - * SOURCE, ID, and CREATED have fixed widths. The remaining space is split - * evenly between NAME and VERSION so the row fills `availableWidth` exactly. - * No data scanning is needed — widths depend only on terminal size. + * + * When `trimToFit` is true (TUI), SOURCE/ID/CREATED use fixed widths and + * the remaining space is split evenly between NAME and VERSION. Content + * that overflows is truncated by the Table component. + * + * When `trimToFit` is false (CLI), each column is sized to its widest + * value (or header) plus padding. Columns may exceed `availableWidth` + * so that all content is visible and columns always align. */ export function getAgentColumns( - _agents: Agent[], + agents: Agent[], availableWidth: number, + trimToFit = true, ): AgentColumn[] { - const flexSpace = Math.max(MIN_FLEX_WIDTH * 2, availableWidth - FIXED_TOTAL); - const nameWidth = Math.ceil(flexSpace / 2); - const versionWidth = Math.floor(flexSpace / 2); - - return [ - { key: "name", label: "NAME", width: nameWidth, getValue: (a) => a.name }, - { - key: "source", - label: "SOURCE", - width: SOURCE_WIDTH, - getValue: (a) => (a as any).source?.type || "-", - }, - { - key: "version", - label: "VERSION", - width: versionWidth, - getValue: agentVersionText, - }, - { key: "id", label: "ID", width: ID_WIDTH, getValue: (a) => a.id }, - { - key: "created", - label: "CREATED", - width: CREATED_WIDTH, - getValue: (a) => formatTimeAgo(a.create_time_ms), - }, - ]; + if (trimToFit) { + // TUI mode: fixed widths with flex split + const flexSpace = Math.max( + MIN_FLEX_WIDTH * 2, + availableWidth - FIXED_TOTAL, + ); + const nameWidth = Math.ceil(flexSpace / 2); + const versionWidth = Math.floor(flexSpace / 2); + + return columnSpecs.map((spec) => ({ + key: spec.key, + label: spec.label, + width: spec.fixedWidth ?? (spec.key === "name" ? nameWidth : versionWidth), + getValue: spec.getValue, + })); + } + + // CLI mode: size each column to its content + const COL_PAD = 2; + return columnSpecs.map((spec) => { + let maxLen = spec.label.length; + for (const agent of agents) { + maxLen = Math.max(maxLen, spec.getValue(agent).length); + } + return { + key: spec.key, + label: spec.label, + width: maxLen + COL_PAD, + getValue: spec.getValue, + }; + }); } export interface ListAgentsOptions { From 19cd8c9595842ca028a27d689102b2a1a814362f Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Thu, 16 Apr 2026 15:57:53 -0700 Subject: [PATCH 5/7] address comments --- src/components/DevboxCreatePage.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 4ee36e67..0de638fe 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -1161,10 +1161,11 @@ 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({ type: "agent_mount", agent_id: formData.agent_id, - agent_name: null, + agent_name: undefined, } as any); } @@ -1574,9 +1575,7 @@ export const DevboxCreatePage = ({ 0, remainingSlots, ); - publicHasMore = - publicResult.hasMore || - uniquePublic.length > remainingSlots; + publicHasMore = publicResult.hasMore; publicTotalCount = publicResult.totalCount; lastFetchedPublicId = @@ -1648,7 +1647,7 @@ export const DevboxCreatePage = ({ }} onSelect={handleAgentSelect} onCancel={() => setShowAgentPicker(false)} - initialSelected={formData.agent_id ? [formData.agent_id] : []} + initialSelected={[]} /> ); From 3d5dc5c24cd22a9bed2ce22fd9400bed1d901467 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Fri, 17 Apr 2026 11:55:50 -0700 Subject: [PATCH 6/7] remove unused merged view code --- src/components/DevboxCreatePage.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 0de638fe..c54edca5 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -247,7 +247,6 @@ export const DevboxCreatePage = ({ const [agentPickerTab, setAgentPickerTab] = React.useState< "private" | "public" >("private"); - const [agentPickerMerged, setAgentPickerMerged] = React.useState(false); const baseFields: Array<{ key: FormField; @@ -1514,7 +1513,7 @@ export const DevboxCreatePage = ({ - extraDeps={[agentPickerTab, agentPickerMerged]} + extraDeps={[agentPickerTab]} extraOverhead={2} config={{ title: @@ -1527,7 +1526,7 @@ export const DevboxCreatePage = ({ const isIdSearch = params.search && /^agt_/i.test(params.search.trim()); if (params.search && !isIdSearch) { - setAgentPickerMerged(true); + // Merged pagination: decode dual cursors from opaque nextCursor let privateCursor: string | undefined; let publicCursor: string | undefined; @@ -1607,7 +1606,7 @@ export const DevboxCreatePage = ({ } // Not searching, or searching by exact agent ID: private-only fetch - setAgentPickerMerged(false); + const result = await listAgents({ limit: params.limit, startingAfter: params.startingAt, @@ -1620,7 +1619,7 @@ export const DevboxCreatePage = ({ }; } else { // Public tab: only fetch public agents - setAgentPickerMerged(false); + const publicResult = await listPublicAgents({ search: params.search, limit: params.limit, From 97c70c5c510773573b675c66c90fe1d0fb062494 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Fri, 17 Apr 2026 11:57:04 -0700 Subject: [PATCH 7/7] fmt --- src/components/DevboxCreatePage.tsx | 1 - src/screens/AgentCreateScreen.tsx | 9 ++++++--- src/services/agentService.ts | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index c54edca5..23e51cad 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -1526,7 +1526,6 @@ export const DevboxCreatePage = ({ 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; diff --git a/src/screens/AgentCreateScreen.tsx b/src/screens/AgentCreateScreen.tsx index fbd0cd0f..3d664c2a 100644 --- a/src/screens/AgentCreateScreen.tsx +++ b/src/screens/AgentCreateScreen.tsx @@ -334,8 +334,7 @@ export function AgentCreateScreen() { /> {/* Source-specific fields */} - {(formData.sourceType === "npm" || - formData.sourceType === "pip") && ( + {(formData.sourceType === "npm" || formData.sourceType === "pip") && ( <> diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 186dcab1..d75387f8 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -106,7 +106,8 @@ export function getAgentColumns( return columnSpecs.map((spec) => ({ key: spec.key, label: spec.label, - width: spec.fixedWidth ?? (spec.key === "name" ? nameWidth : versionWidth), + width: + spec.fixedWidth ?? (spec.key === "name" ? nameWidth : versionWidth), getValue: spec.getValue, })); }