diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts index 0ec84fd4..2aa6bab6 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, false); // 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..23e51cad 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -15,6 +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 { formatTimeAgo } from "./ResourceListView.js"; import { getStatusDisplay } from "./StatusBadge.js"; import { @@ -30,6 +31,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 +73,8 @@ type FormField = | "network_policy_id" | "tunnel_auth_mode" | "gateways" - | "mcpConfigs"; + | "mcpConfigs" + | "agent"; // Gateway configuration for devbox interface GatewaySpec { @@ -115,6 +122,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 +159,7 @@ export const DevboxCreatePage = ({ tunnel_auth_mode: "none", gateways: [], mcpConfigs: [], + agent_id: "", }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -232,6 +241,13 @@ 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 baseFields: Array<{ key: FormField; label: string; @@ -324,6 +340,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 +480,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 +526,8 @@ export const DevboxCreatePage = ({ !showMcpPicker && !showMcpSecretPicker && !showInlineMcpSecretCreate && - !showInlineMcpConfigCreate, + !showInlineMcpConfigCreate && + !showAgentPicker, }, ); @@ -547,6 +574,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 +1013,8 @@ export const DevboxCreatePage = ({ !showMcpPicker && !showMcpSecretPicker && !showInlineMcpSecretCreate && - !showInlineMcpConfigCreate, + !showInlineMcpConfigCreate && + !showAgentPicker, }, ); @@ -1107,6 +1157,17 @@ 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: undefined, + } as any); + } + const devbox = await client.devboxes.create(createParams); setResult(devbox); } catch (err) { @@ -1424,6 +1485,172 @@ 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 ( @@ -2034,7 +2261,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..dd87de1e --- /dev/null +++ b/src/components/agentColumns.ts @@ -0,0 +1,45 @@ +/** + * 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..3d664c2a --- /dev/null +++ b/src/screens/AgentCreateScreen.tsx @@ -0,0 +1,409 @@ +/** + * 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 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 { 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"; + +const SOURCE_TYPES = ["npm", "pip", "git", "object"] as const; +type SourceType = (typeof SOURCE_TYPES)[number]; + +type FormField = + | "name" + | "version" + | "sourceType" + | "packageName" + | "registryUrl" + | "repository" + | "ref" + | "objectId" + | "create"; + +interface FieldDef { + key: FormField; + 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: "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({ + name: "", + version: "", + sourceType: "npm" as SourceType, + packageName: "", + registryUrl: "", + repository: "", + ref: "", + objectId: "", + }); + 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 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"); + } + }, + [currentField], + ); + + const handleSourceTypeNav = useFormSelectNavigation( + formData.sourceType, + SOURCE_TYPES, + handleSourceTypeChange, + currentField === "sourceType", + ); + + const handleSubmit = async () => { + 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 { + 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); + setSubmitting(false); + setSuccess(true); + } catch (err) { + setError(err as Error); + setSubmitting(false); + } + }; + + useInput( + (_input, key) => { + if (success) { + if (key.return) { + navigate("agent-detail", { agentId: createdAgentId }); + } else if (key.escape) { + navigate("agent-list"); + } + return; + } + + if (key.escape) { + goBack(); + return; + } + + // 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 (key.downArrow && currentFieldIndex < fields.length - 1) { + setCurrentField(fields[currentFieldIndex + 1].key); + setValidationError(null); + return; + } + + // Enter on create button submits + if (key.return && currentField === "create") { + handleSubmit(); + return; + } + }, + { isActive: !submitting }, + ); + + // Submitting spinner + if (submitting) { + return ( + <> + + + + ); + } + + // Success screen + if (success) { + return ( + <> + + + + + ); + } + + // Determine which field has a validation error + const fieldError = (key: FormField): string | undefined => { + if (!validationError) return undefined; + if (currentField === key) return validationError; + return undefined; + }; + + return ( + + + + {/* Server error banner */} + {error && ( + + + {figures.cross} {error.message} + + + )} + + + + Create Agent + + + 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 */} + {(formData.sourceType === "npm" || formData.sourceType === "pip") && ( + <> + 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)" + /> + + )} + {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")} + /> + )} + + + + + + + + + + ); +} 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..d75387f8 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -2,11 +2,132 @@ * 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; + +/** 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. + * + * 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[], + availableWidth: number, + trimToFit = true, +): AgentColumn[] { + 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 { limit?: number; startingAfter?: string; @@ -65,7 +186,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 +194,7 @@ export async function listAgents( return { agents, - totalCount: agents.length, + totalCount: response.total_count ?? agents.length, hasMore: response.has_more || false, }; } @@ -116,7 +237,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; }