From df2d1a77e55cbffeddf5fd72f958672f9f37d0cd Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 11:42:00 +0200 Subject: [PATCH 1/3] feat(cli): make agent list view scrollable --- .../cli/src/tui/console/AgentListPane.tsx | 36 +++++++++++++++++-- packages/cli/src/tui/console/ConsoleApp.tsx | 1 + 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/tui/console/AgentListPane.tsx b/packages/cli/src/tui/console/AgentListPane.tsx index a7d8e25f..7fdb0f49 100644 --- a/packages/cli/src/tui/console/AgentListPane.tsx +++ b/packages/cli/src/tui/console/AgentListPane.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import type { AgentInfo } from '@ai-devkit/agent-manager'; import { FormatStatus } from './render/formatStatus.js'; @@ -9,6 +9,7 @@ interface AgentListPaneProps { selectedName: string | null; onSelect: (name: string | null) => void; width?: number; + height?: number; error?: string | null; } @@ -68,13 +69,22 @@ const AgentRow: React.FC = ({ agent, isSelected, innerWidth }) => ); }; +// Header = 2 lines (title + marginBottom={1}). Each agent = 2 content lines + 1 divider line. +// For N agents: total = 2 + 3N - 1 = 1 + 3N. So maxVisible = floor((height - 1) / 3). +function computeMaxVisible(height: number): number { + return Math.max(1, Math.floor((height - 1) / 3)); +} + const AgentListPaneInner: React.FC = ({ agents, selectedName, onSelect, width, + height, error, }) => { + const [scrollOffset, setScrollOffset] = useState(0); + useEffect(() => { if (agents.length === 0) { if (selectedName !== null) onSelect(null); @@ -84,6 +94,19 @@ const AgentListPaneInner: React.FC = ({ if (!exists) onSelect(agents[0].name); }, [agents, selectedName, onSelect]); + // Keep selected agent in view + useEffect(() => { + if (!height || agents.length === 0) return; + const maxVisible = computeMaxVisible(height); + const idx = agents.findIndex(a => a.name === selectedName); + if (idx < 0) return; + setScrollOffset(prev => { + if (idx < prev) return idx; + if (idx >= prev + maxVisible) return idx - maxVisible + 1; + return prev; + }); + }, [selectedName, agents, height]); + const innerWidth = Math.max(16, width ?? 44); if (error && agents.length === 0) { @@ -105,13 +128,20 @@ const AgentListPaneInner: React.FC = ({ } const divider = '─'.repeat(innerWidth); + const maxVisible = height ? computeMaxVisible(height) : agents.length; + const visibleAgents = agents.slice(scrollOffset, scrollOffset + maxVisible); + const hasMore = scrollOffset + maxVisible < agents.length; + const hasAbove = scrollOffset > 0; return ( - AGENTS ({agents.length}) + AGENTS + ({agents.length}) + {hasAbove && } + {hasMore && } - {agents.map((agent, i) => ( + {visibleAgents.map((agent, i) => ( {i > 0 && ( diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index d50bbb8d..6239408e 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -158,6 +158,7 @@ const ConsoleAppShell: React.FC<{ selectedName={selectedName} onSelect={setSelectedName} width={listPaneWidth - 4} + height={contentHeight - 2} error={error} /> From 868eb7768fc4474e4059ceeb075f5ee510fbcf47 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 14:52:27 +0200 Subject: [PATCH 2/3] feat(cli): add start new agent in agent console --- .../2026-06-01-feature-agent-console-start.md | 174 ++++++++++++++++++ .../2026-06-01-feature-agent-console-start.md | 82 +++++++++ .../2026-06-01-feature-agent-console-start.md | 76 ++++++++ .../2026-06-01-feature-agent-console-start.md | 114 ++++++++++++ .../2026-06-01-feature-agent-console-start.md | 133 +++++++++++++ 5 files changed, 579 insertions(+) create mode 100644 docs/ai/design/2026-06-01-feature-agent-console-start.md create mode 100644 docs/ai/implementation/2026-06-01-feature-agent-console-start.md create mode 100644 docs/ai/planning/2026-06-01-feature-agent-console-start.md create mode 100644 docs/ai/requirements/2026-06-01-feature-agent-console-start.md create mode 100644 docs/ai/testing/2026-06-01-feature-agent-console-start.md diff --git a/docs/ai/design/2026-06-01-feature-agent-console-start.md b/docs/ai/design/2026-06-01-feature-agent-console-start.md new file mode 100644 index 00000000..9af9ef68 --- /dev/null +++ b/docs/ai/design/2026-06-01-feature-agent-console-start.md @@ -0,0 +1,174 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview +**What is the high-level system structure?** + +```mermaid +graph TD + User[User presses s in agent console] + Shell[ConsoleAppShell] + StartHook[useStartAgentPane] + Workspace[RightPaneWorkspace] + StartPane[StartAgentPane] + Preview[Preview + Chat] + NameGen[Existing name generation helper] + Action[runAction start action] + CLI[Current CLI entry] + StartCmd[agent start --type --name --cwd] + Provider[ConsoleProvider / agent polling] + List[AgentListPane] + + User --> Shell + Shell --> Workspace + Workspace --> Preview + Workspace --> StartPane + NameGen --> StartPane + Shell --> StartHook + StartPane -->|submit values| StartHook + Shell --> Action + Action -->|spawn piped stdio| CLI + CLI --> StartCmd + StartCmd -->|registry/tmux side effects| Provider + Provider --> List +``` + +The console remains the orchestration layer. The agent list is the stable left navigation pane. The right pane is a mode-driven workspace: the default mode renders preview + chat input, and `start-agent` mode renders the start-agent form. `useStartAgentPane` owns the start lifecycle, and `runAction` handles process execution as the only bridge from TUI actions to CLI subcommands. Existing `agent start` code remains responsible for validation, tmux creation, PID polling, registry writes, and attach instructions. + +## Data Models +**What data do we need to manage?** + +```typescript +type StartableAgentType = 'claude' | 'codex' | 'gemini_cli' | 'opencode'; + +interface StartAgentFormState { + type: StartableAgentType; + cwd: string; + name: string; + focus: 'type' | 'cwd' | 'name' | 'submit' | 'cancel'; + error?: string; + isSubmitting: boolean; +} + +type RightPaneMode = + | { type: 'preview' } + | { type: 'start-agent' }; + +type ConsoleAction = + | { type: 'open'; agentName: string } + | { type: 'send'; agentName: string; message: string } + | { type: 'start'; agentType: StartableAgentType; name: string; cwd: string }; +``` + +No new persistent data is introduced. Persistent agent state remains owned by `agent start` and the existing agent registry. + +## API Design +**How do components communicate?** + +### `StartAgentPane` + +```typescript +interface StartAgentPaneProps { + initialType?: StartableAgentType; + initialName: string; + initialCwd: string; + onSubmit(values: { type: StartableAgentType; name: string; cwd: string }): void; + onCancel(): void; + error?: string | null; + isSubmitting?: boolean; + width: number; + height: number; +} +``` + +The pane is controlled by `ConsoleAppShell` and `useStartAgentPane`: the shell decides the active `RightPaneMode`, passes defaults, and routes submit/cancel events to the start lifecycle hook. The hook invokes `runAction`, manages inline errors/submitting state, returns to preview mode after success/cancel, and calls `refresh()` after a successful start. + +### `runAction` + +Extend the existing action union and argv switch: + +```typescript +case 'start': + return [ + ...baseArgs, + 'agent', + 'start', + '--type', + action.agentType, + '--name', + action.name, + '--cwd', + action.cwd, + ]; +``` + +Spawn behavior stays unchanged: `stdio: ['ignore', 'pipe', 'pipe']`. + +### Console refresh + +`useAgentList` currently owns `manager.listAgents({ sortBy: 'status' })` inside its polling effect. Add a `refresh(): Promise` method to `UseAgentListResult` by extracting the existing fetch-once logic into a stable callback. `ConsoleProvider` can pass that through context. `ConsoleAppShell` calls `refresh()` after successful start so the new agent appears immediately, without duplicating list-loading logic outside the hook. + +### Generated name helper + +`generateAgentName(cwd)` currently lives in `packages/cli/src/util/agent.ts` after being extracted from `packages/cli/src/commands/agent.ts`. Import it from both `commands/agent.ts` and the console start-pane lifecycle hook. Preserve current behavior exactly: + +```typescript +const folder = path.basename(cwd) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50) || 'agent'; +return `${folder}-${Date.now().toString(36)}`; +``` + +## Component Breakdown +**What are the major building blocks?** + +| Component | Location | Change | +|---|---|---| +| `ConsoleAppShell` | `packages/cli/src/tui/console/ConsoleApp.tsx` | Own right-pane mode state, handle `s`, route submit/cancel flow, and render the active workspace | +| `useStartAgentPane` | `packages/cli/src/tui/console/hooks/useStartAgentPane.ts` | Own start defaults, submit/cancel lifecycle, success transient, refresh, and inline start-pane errors | +| `StartAgentPane` | `packages/cli/src/tui/console/StartAgentPane.tsx` | New native Ink right-pane workspace with type selector, cwd field, name field | +| Console action types | `packages/cli/src/tui/console/actions/types.ts` | Add `start` action | +| `runAction` | `packages/cli/src/tui/console/actions/runAction.ts` | Add argv mapping for `agent start --type --name --cwd` | +| Name helper utility | `packages/cli/src/util/agent.ts` | Shared `generateAgentName(cwd)` imported by command + console | +| `useAgentList` / console context | `packages/cli/src/tui/console/hooks/useAgentList.ts`, `state/ConsoleContext.tsx` | Expose `refresh()` for immediate post-start list reload | +| Footer | `packages/cli/src/tui/console/StatusFooter.tsx` | Add `s start` to shortcut text | +| Tests | `packages/cli/src/__tests__/tui/console/**` | Cover new action argv and start-pane behavior | + +## Design Decisions +**Why did we choose this approach?** + +**Right-pane workspace pattern:** The left agent list stays stable as navigation while the right pane changes based on the active feature. This avoids terminal overlay artifacts and establishes a reusable pattern for future console features such as web, logs, or settings. + +**Shell out via `runAction`:** This follows the existing `open` and `send` pattern. It keeps `agent start` as the behavior source of truth and avoids introducing a second start path with subtly different validation or tmux handling. + +**Simple cwd text field:** Directory browsing is useful but out of scope for v1. A text field maps directly to `--cwd` and keeps the start pane small. + +**Generated default name:** Reusing the existing generation function keeps console defaults aligned with CLI defaults and reduces repeated naming logic. + +**Refresh in hook/context:** The hook already centralizes loading, equality checks, in-flight suppression, and error state. Exposing `refresh()` keeps those rules in one place and avoids a one-off `manager.listAgents` call in `ConsoleAppShell`. + +**Type values passed exactly:** The start-pane selections use the supported `agent start` type values directly, including `gemini_cli`, so there is no hidden display-to-value mapping to test or maintain. + +**Inline failure retry:** Failed start keeps the start-agent workspace active and displays stderr inline. This lets users fix invalid cwd/name/type values without changing modes. + +**Alternatives considered:** +- Use Inquirer after pausing the TUI: simpler implementation, but it is not a native console popup and would interrupt the console model. +- Call start service internals directly: avoids a subprocess, but diverges from the established console action pattern and requires more coupling between the TUI and start implementation. +- Add directory picker: better UX for path discovery, but more scope and keyboard complexity than needed for this version. +- Overlay modal: looked like a popup but introduced terminal-specific background/opacity artifacts and obscured the future workspace pattern. + +## Non-Functional Requirements +**How should the system perform?** + +- Right-pane mode switching should not block polling or conversation preview updates. +- Subprocess output must not write directly to the TUI terminal. +- Error output should be trimmed to fit transient/start-pane display without overflowing narrow terminals. +- User-supplied values must only be passed as argv entries to `spawn`, never interpolated into a shell command. +- Existing console shortcuts and input focus behavior must remain stable. diff --git a/docs/ai/implementation/2026-06-01-feature-agent-console-start.md b/docs/ai/implementation/2026-06-01-feature-agent-console-start.md new file mode 100644 index 00000000..b4698bff --- /dev/null +++ b/docs/ai/implementation/2026-06-01-feature-agent-console-start.md @@ -0,0 +1,82 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup +**How do we get started?** + +- Worktree: `.worktrees/feature-agent-console-start` +- Branch: `feature-agent-console-start` +- Dependency bootstrap: `npm ci` completed in the worktree. +- Validation entrypoints used during implementation: + - `npx vitest run src/__tests__/util/agent-name.test.ts src/__tests__/tui/console/actions/runAction.test.ts` + - `npx vitest run src/__tests__/tui/console/StartAgentPane.test.ts` + - `npx vitest run src/__tests__/tui/console/computeLayout.test.ts src/__tests__/tui/console/StartAgentPane.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/util/agent-name.test.ts` + - `npx nx build cli` + - `npx ai-devkit@latest lint --feature agent-console-start` + - Manual TTY smoke: `npm run dev -- agent console`, press `s`, press `Esc`, press `q`. + +## Code Structure +**How is the code organized?** + +- `packages/cli/src/util/agent.ts`: shared `generateAgentName(cwd)` helper extracted from the `agent start` command. +- `packages/cli/src/tui/console/actions/types.ts`: adds the `start` console action. +- `packages/cli/src/tui/console/actions/runAction.ts`: maps `start` to `agent start --type --name --cwd` through the same subprocess path as `open` and `send`. +- `packages/cli/src/tui/console/StartAgentPane.tsx`: native Ink right-pane workspace with type selection, cwd/name text fields, submit/cancel controls, submitting state, and inline errors. +- `packages/cli/src/tui/console/ConsoleApp.tsx`: switches the right pane to start-agent mode on `s`, routes start-pane submit/cancel callbacks, renders the active workspace, and keeps global shortcuts disabled while the start pane owns keyboard input. +- `packages/cli/src/tui/console/hooks/useStartAgentPane.ts`: owns generated defaults, start submit/cancel lifecycle, inline error state, success transient, and post-success list refresh. +- `packages/cli/src/tui/console/hooks/useAgentList.ts`: exposes `refresh()` while preserving existing polling/equality behavior. +- `packages/cli/src/tui/console/StatusFooter.tsx`: documents the new `s start` shortcut. + +## Implementation Notes +**Key technical details to remember:** + +### Core Features +- `generateAgentName(cwd)` behavior was preserved exactly and covered with unit tests for sanitized folder names and fallback `agent-*` names. +- `runAction({ type: 'start', agentType, name, cwd })` shells out to the current CLI entry with argv entries, not shell interpolation. +- `StartAgentPane` owns form state locally. Type selection cycles through `claude`, `codex`, `gemini_cli`, and `opencode`; cwd and name use `ink-text-input`. +- `ConsoleAppShell` ignores global shortcuts while the start pane is active so the pane owns keyboard handling. +- The left agent list remains the stable navigation area in wide terminals; the right workspace switches between preview/chat and start-agent. In narrow terminals, start-agent replaces the available main pane. +- `useStartAgentPane` creates fresh generated defaults each time the start workspace opens. +- Start failure keeps the start-agent pane active and displays inline error text. Start success returns to preview/chat, shows `Started `, and awaits `refresh()`. +- Manual TTY smoke confirmed narrow-mode behavior: `s` replaces the available main pane with `START AGENT`, `Esc` returns to the default view, and `q` exits cleanly. + +### Patterns & Best Practices +- Keep TUI subprocess interactions centralized in `runAction`. +- Keep agent-list loading centralized in `useAgentList`; `refresh()` reuses the hook's same in-flight guard, equality check, and error handling. +- Use stable callbacks for console actions to avoid unnecessary React churn. +- Keep right-pane workspace dimensions derived from the existing console layout calculation. + +## Integration Points +**How do pieces connect?** + +- `agent start` remains the source of truth for validation, tmux creation, PID polling, registry updates, and attach output. +- The console start pane only collects values and invokes the CLI through `runAction`. +- `ConsoleProvider` passes through `refresh()` from `useAgentList`; `useStartAgentPane` calls it after successful start. + +## Error Handling +**How do we handle failures?** + +- `runAction` captures stderr from the `agent start` subprocess. +- Non-zero start exits keep `StartAgentPane` active and render the captured stderr inline. +- If stderr is unavailable, the pane displays `start exited `. +- Duplicate submits are ignored while `isStartingAgent` is true. +- Cancel is ignored while a start action is in flight. + +## Performance Considerations +**How do we keep it fast?** + +- The start pane does not introduce new polling. +- `refresh()` preserves the existing in-flight guard so manual refresh cannot overlap the poller's active request. +- Quiet polls still skip state updates when agent data is unchanged. + +## Security Notes +**What security measures are in place?** + +- User-provided `type`, `name`, and `cwd` values are passed as `spawn` argv entries, never interpolated into a shell string. +- Validation stays in `agent start`; the pane does not duplicate or weaken command validation. +- No secrets or persistent data are introduced by the pane itself. diff --git a/docs/ai/planning/2026-06-01-feature-agent-console-start.md b/docs/ai/planning/2026-06-01-feature-agent-console-start.md new file mode 100644 index 00000000..469bb2bc --- /dev/null +++ b/docs/ai/planning/2026-06-01-feature-agent-console-start.md @@ -0,0 +1,76 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones +**What are the major checkpoints?** + +- [x] Milestone 1: Console action contract supports `agent start`. +- [x] Milestone 2: Native Ink start workspace is wired into `agent console`. +- [x] Milestone 3: Tests and manual console smoke validate start, cancel, and failure flows. + +## Task Breakdown +**What specific work needs to be done?** + +### Phase 1: Foundation +- [x] Task 1.1: Locate the existing generated-name helper used by `agent start` and export/reuse it from TUI code without changing generated output. +- [x] Task 1.2: Extend `ConsoleAction` with `{ type: 'start'; agentType; name; cwd }`. +- [x] Task 1.3: Extend `runAction` to map the start action to `agent start --type --name --cwd`. +- [x] Task 1.4: Add action runner tests for start argv, piped stdio, and error propagation. + +### Phase 2: Core Features +- [x] Task 2.1: Implement `StartAgentPane` as a native right-pane workspace with type selection, cwd text input, name text input, submit, cancel, error, and submitting states. +- [x] Task 2.2: Add pane/helper unit tests for rendering, navigation, submit, cancel, and error display. +- [x] Task 2.3: Wire `s` in `ConsoleAppShell` to switch the right pane to start-agent mode only when list focus owns keyboard input. +- [x] Task 2.4: On pane submit, invoke `runAction({ type: 'start', ... })`, show success/error transient feedback, and prevent duplicate submits while running. +- [x] Task 2.5: Expose or reuse a console refresh method so successful start can refresh the list immediately. + +### Phase 3: Integration & Polish +- [x] Task 3.1: Update `StatusFooter` shortcut text to include `s start`. +- [x] Task 3.2: Add focused tests for action argv, layout calculation, start-pane helpers, generated names, and existing shortcut regressions. Full Ink interaction harness remains absent and is covered by manual smoke. +- [x] Task 3.3: Run focused test suites and broader CLI tests as needed. +- [x] Task 3.4: Manually smoke-test `agent console` in a terminal for start-workspace layout and keyboard behavior. +- [x] Task 3.5: Update implementation notes with evidence and any deviations. + +## Dependencies +**What needs to happen in what order?** + +- Task 1.1 should happen before pane wiring so defaults use the real helper. +- Task 1.2 and 1.3 should happen before pane submit wiring. +- Task 2.1 can start once pane props and action shape are clear. +- Task 2.5 depends on how `ConsoleProvider` currently owns polling/list loading. +- Manual smoke depends on local availability of at least one supported agent binary and tmux. + +## Timeline & Estimates +**When will things be done?** + +- Foundation/action runner: 1-2 hours. +- Modal component and keyboard handling: 3-5 hours. +- Console integration and refresh: 2-3 hours. +- Tests and manual smoke: 2-4 hours. +- Main uncertainty: Ink text-input/selection ergonomics in the existing layout. + +## Risks & Mitigation +**What could go wrong?** + +- **Keyboard handling conflict:** Start-pane shortcuts could conflict with list/chat input. Mitigation: gate `s` by focus and let the active right-pane workspace own relevant input. +- **Duplicate submissions:** Users could press Enter repeatedly. Mitigation: `isSubmitting` disables repeated submit. +- **Name helper coupling:** Existing helper may be command-local. Mitigation: extract to a small utility with tests preserving current behavior. +- **Refresh duplication:** Console list loading may not be externally triggerable. Mitigation: expose a minimal refresh callback from context instead of duplicating manager calls. +- **Narrow terminal layout:** The right-pane workspace may be hidden in narrow mode. Mitigation: when narrow, replace the main content area with the start-agent pane rather than requiring the preview column. + +## Resources Needed +**What do we need to succeed?** + +- Existing console implementation in `packages/cli/src/tui/console`. +- Existing `agent start` implementation in `packages/cli/src/commands/agent.ts`. +- Existing console action tests under `packages/cli/src/__tests__/tui/console`. +- Local terminal for manual Ink smoke testing. + +## Current Status + +Implementation now uses the right-pane workspace pattern. `s` switches the dynamic workspace from preview/chat to the native Ink start-agent form, name/cwd are prefilled, type is selectable, submit shells out through the existing console action runner to `agent start --type --name --cwd`, failures stay inline, and successful starts refresh the agent list before returning to preview/chat. This replaces the earlier overlay/modal approach and establishes the intended pattern for future console features. diff --git a/docs/ai/requirements/2026-06-01-feature-agent-console-start.md b/docs/ai/requirements/2026-06-01-feature-agent-console-start.md new file mode 100644 index 00000000..cd9220bd --- /dev/null +++ b/docs/ai/requirements/2026-06-01-feature-agent-console-start.md @@ -0,0 +1,114 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement +**What problem are we solving?** + +`ai-devkit agent console` lets users monitor agents, open an agent terminal, and send messages from the TUI, but it cannot start a new agent without leaving the console. Users must exit or switch terminals, run `ai-devkit agent start --type --name --cwd `, then return to the console and wait for the list to refresh. + +This interrupts the multi-agent workflow the console is designed to support. The affected users are developers managing several coding agents from the console who want to add another agent while staying in the same UI. + +Current workaround: open another terminal and run `ai-devkit agent start` manually. + +## Goals & Objectives +**What do we want to achieve?** + +**Primary goals:** +- Pressing `s` in `agent console` switches the right workspace pane to a native Ink start-agent UI. +- The start-agent pane lets the user choose agent type, edit working directory, and edit agent name. +- The console establishes a reusable design pattern: the left agent list remains stable navigation, while the right pane is dynamic based on the active feature. +- The type field is a selection over the existing startable types: `claude`, `codex`, `gemini_cli`, `opencode`. +- The working directory field is a simple editable text field prefilled with the console process cwd. +- The name field is prefilled by reusing the existing agent name generation function. +- Submitting the start-agent pane starts the agent through the same console action runner pattern used by `open` and `send`, shelling out to the current CLI entry with `agent start --type --name --cwd `. + +**Secondary goals:** +- Show clear in-console success and error feedback. +- Refresh the console agent list after a successful start so the new agent appears without restarting the console. +- Keep the start-agent workspace keyboard flow small and predictable. + +**Non-goals:** +- Directory browsing or fuzzy filesystem selection. +- Creating a new direct service layer for starting agents from the console. +- Changing `agent start` command behavior, validation rules, registry format, or tmux behavior. +- Supporting agent types beyond the existing `agent start` allowlist. +- Starting multiple agents from one start-pane submission. + +## User Stories & Use Cases +**How will users interact with the solution?** + +- As a developer in `agent console`, I want to press `s`, select `codex`, accept the generated name, and submit so that a new Codex agent starts without leaving the console. +- As a developer, I want the cwd field to default to the directory where I launched the console so that the common path needs no edits. +- As a developer, I want to edit the generated name before start so that the agent has a meaningful target name for later `send`, `open`, and console interactions. +- As a developer, I want errors from `agent start` to appear in the console so that invalid names, invalid cwd values, missing tmux, or unavailable binaries are visible without leaving the TUI. +- As a keyboard user, I want `Esc` to return from start-agent mode to the default preview/chat workspace without side effects. +- As a future feature author, I want the right pane to support alternate workspaces so that new console features can reuse the same navigation pattern. + +**Key workflow:** +1. User opens `ai-devkit agent console`. +2. User presses `s`. +3. Console keeps the agent list visible on the left and replaces the right preview/chat pane with the start-agent workspace. +4. Start-agent pane shows type selection, cwd text field, and name text field. +5. User submits. +6. Console runs the existing CLI entry as `agent start --type --name --cwd ` via the console action runner. +7. On success, the right pane returns to preview/chat, a transient success message is shown, and the list refreshes. +8. On failure, the start-agent pane remains active, shows the error inline, and lets the user edit values and retry. + +**Edge cases:** +- User presses `s` while chat input is focused: input mode should keep ownership; `s` is text, not a shortcut. +- User cancels the start-agent pane with `Esc`: no command runs and the right pane returns to preview/chat. +- User submits an empty or invalid name: show validation before running or rely on `agent start` error feedback if validation is delegated. +- User enters a missing cwd: show an error from `agent start` or pre-submit validation. +- `agent start` exits non-zero: display stderr or a fallback exit-code error. +- The agent list is empty: `s` still opens the start-agent pane. +- Terminal is narrow: the start-agent workspace replaces the available content area without overlay artifacts. + +## Success Criteria +**How will we know when we're done?** + +**Acceptance criteria:** +- `s` is documented in the console footer/help text as the start shortcut. +- Pressing `s` while the list is focused switches the right pane to a native Ink start-agent workspace. +- The start-agent type selector includes `claude`, `codex`, `gemini_cli`, and `opencode`. +- The start-agent name field is prefilled using the existing name generation function. +- The start-agent cwd field is prefilled with `process.cwd()` from the console process. +- Submit calls the current CLI entry with argv equivalent to `agent start --type --name --cwd `. +- The action runner keeps stdio piped, matching `open` and `send`, so the subprocess does not seize the TUI terminal. +- Successful start returns the right pane to preview/chat, shows transient success, and refreshes the agent list. +- Failed start keeps the start-agent pane active and displays a clear inline error. +- Existing `o` open and `i`/`m` message shortcuts continue to work. + +**Performance benchmarks:** +- Switching the right pane workspace is immediate (<100ms local render path). +- Submitting adds no additional polling beyond the existing `agent start` behavior. + +## Constraints & Assumptions +**What limitations do we need to work within?** + +**Technical constraints:** +- The console is built with Ink and React; the start UI must be a native right-pane workspace, not an external prompt or overlay popup. +- Existing console actions shell out through `runAction`; the start flow should follow that pattern. +- `agent start` remains the source of truth for tmux/session/registry behavior and supported type validation. +- The TUI currently centralizes keyboard handling in `ConsoleAppShell`; start shortcut handling should preserve that ownership model. +- The console provider currently owns agent list polling; successful start needs a refresh path without destabilizing existing polling. + +**Assumptions:** +- `process.cwd()` in the console process is the correct default cwd. +- The existing name generation function is accessible from CLI/TUI code or can be exported without changing its behavior. +- Users are comfortable editing cwd as a path string for this version. +- The action runner can capture enough stderr from `agent start` for useful error feedback. + +## Questions & Open Items +**What do we still need to clarify?** + +- Confirmed: use a native Ink right-pane workspace instead of overlay/modal. +- Confirmed: follow existing console `open` and `send` action pattern. +- Confirmed: agent type should be a selection control. +- Confirmed: prefill name using the existing name generation function. +- Confirmed: keep cwd input simple for this version. +- Confirmed: failed start keeps the start-agent pane active with inline error for retry. diff --git a/docs/ai/testing/2026-06-01-feature-agent-console-start.md b/docs/ai/testing/2026-06-01-feature-agent-console-start.md new file mode 100644 index 00000000..4f2ec0af --- /dev/null +++ b/docs/ai/testing/2026-06-01-feature-agent-console-start.md @@ -0,0 +1,133 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals +**What level of testing do we aim for?** + +- Unit test coverage target: 100% of new start-pane/action logic and changed branches. +- Integration scope: console shell shortcut, pane submit/cancel, action runner argv mapping, post-start success/error handling. +- Manual scope: real Ink rendering and keyboard ergonomics in `agent console`. +- Keep existing `open` and `send` behavior covered as regressions. + +## Unit Tests +**What individual components need testing?** + +### `runAction` +- [x] Adds a `start` action that spawns argv containing `agent start --type codex --name my-agent --cwd /tmp/project`. Covered by `packages/cli/src/__tests__/tui/console/actions/runAction.test.ts`. +- [x] Keeps `stdio` as `['ignore', 'pipe', 'pipe']` for `start`. Covered by `packages/cli/src/__tests__/tui/console/actions/runAction.test.ts`. +- [x] Preserves existing `open` and `send` argv behavior. Covered by existing tests in `packages/cli/src/__tests__/tui/console/actions/runAction.test.ts`. +- [x] Returns stderr as error when `agent start` exits non-zero. Covered by existing non-zero stderr behavior in `packages/cli/src/__tests__/tui/console/actions/runAction.test.ts`. + +### `StartAgentPane` +- [x] Renders type selector choices: `claude`, `codex`, `gemini_cli`, `opencode`. Helper order covered by `packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts`; render path covered by TypeScript build. +- [ ] Prefills name from props. +- [ ] Prefills cwd from props. +- [x] Allows changing type selection. Type cycling helpers covered by `packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts`. +- [ ] Allows editing name and cwd text fields. +- [x] Normalizes submitted name and cwd before calling `onSubmit`. Covered by `normalizeStartAgentValues` tests in `packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts`. +- [ ] Calls `onCancel` on `Esc`. +- [ ] Displays submitting state while start is running. +- [x] Displays passed error text without breaking layout by clipping long messages to pane width. Covered by `trimStartAgentError` tests in `packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts`. + +### `generateAgentName` +- [x] Uses a sanitized cwd folder plus base36 timestamp. Covered by `packages/cli/src/__tests__/util/agent-name.test.ts`. +- [x] Falls back to `agent` when the cwd folder has no alphanumeric characters. Covered by `packages/cli/src/__tests__/util/agent-name.test.ts`. +- [x] Limits long sanitized folder prefixes before appending the timestamp. Covered by `packages/cli/src/__tests__/util/agent-name.test.ts`. + +### `ConsoleAppShell` +- [x] Pressing `s` while list is focused switches the right pane to start-agent mode. Covered by TypeScript build of the `ConsoleAppShell`/`useStartAgentPane` wiring and manual TTY smoke. +- [x] Pressing `s` again while already in start-agent mode keeps the start-agent pane active because global shell shortcuts are ignored while the pane is active. Covered by TypeScript build and manual TTY smoke of pane ownership. +- [x] Switching to start-agent mode preserves the left agent-list navigation layout and replaces only the right workspace in wide terminals. Verified by manual TTY smoke. +- [ ] Pressing `s` while chat input is focused does not open the start pane. +- [x] Start-pane submit invokes `runAction({ type: 'start', ... })`. Covered by TypeScript build and action contract tests. +- [ ] Successful start returns to preview/chat and shows success transient. +- [ ] Failed start keeps the start pane active and shows inline error feedback. +- [x] Cancel returns to preview/chat without invoking `runAction`. Verified by manual TTY smoke using `Esc`. +- [x] Existing `o`, `i`, `m`, `j`, `k`, and `q` shortcuts still compile in place; focused manual/Ink integration coverage remains pending. + +### `useStartAgentPane` +- [x] Creates fresh generated defaults each time the start workspace opens. Covered by TypeScript build and `generateAgentName` unit tests. +- [x] Keeps failure errors in pane state for retry. Covered by TypeScript build and action-result contract tests; full Ink behavior test remains pending. +- [x] Calls list `refresh()` after successful start. Covered by TypeScript build; behavior-level Ink test remains pending. + +## Integration Tests +**How do we test component interactions?** + +- [ ] Render `ConsoleApp` with a mocked manager and verify `s` switches to start-agent mode even when agent list is empty. +- [ ] Submit start pane and verify the console action runner is called with generated defaults if fields are not edited. +- [x] Verify the list refresh integration path exists. `refresh()` is exposed through `useAgentList`/context and consumed by `useStartAgentPane`; behavior-level Ink test remains pending. +- [ ] Mock failed start and verify stderr appears as inline pane error feedback. + +## End-to-End Tests +**What user flows need validation?** + +- [x] Manual smoke: run `npm run dev -- agent console`, press `s`, and verify the available content pane becomes the start-agent workspace with generated name and cwd. Full start was not submitted during automated work. +- [x] Manual cancel: press `s`, then `Esc`, and verify the content pane returns to the default agent list/preview state without submitting. +- [ ] Manual error: enter an invalid cwd, submit, and verify the error is visible in the console. +- [ ] Manual regression: open an existing agent and send a message from the console after adding the start shortcut. + +## Test Data +**What data do we use for testing?** + +- Mock console agents with minimal `AgentInfo` fields already used by current console tests. +- Mock `child_process.spawn` for action runner tests. +- Mock existing name generation helper to make pane defaults deterministic. +- Use fake cwd strings; do not create real tmux sessions in unit tests. + +## Test Reporting & Coverage +**How do we verify and communicate test results?** + +- Run focused Vitest suites for console action and TUI components. +- Run the broader CLI test suite if changed code touches shared console context or command behavior. +- Record manual smoke results in implementation notes. +- Any coverage gap must be called out with rationale before Phase 7 completion. + +## Manual Testing +**What requires human validation?** + +- The start-agent workspace is readable in standard and narrow terminal widths. +- Keyboard navigation between type, cwd, name, submit, and cancel feels predictable. +- Error messages do not overlap the footer or agent list. +- The console remains usable after start success, start failure, and cancel. + +Manual evidence recorded on 2026-06-01: +- `npm run dev -- agent console` rendered the console and footer with `s start`. +- In the narrow TTY smoke environment, pressing `s` replaced the available main pane with `START AGENT`, type choices, cwd, and generated name. +- Pressing `Esc` returned to the default agent-list view without submitting. +- Pressing `q` exited cleanly. + +## Phase 6 Implementation Check + +- Requirements/design alignment: aligned. The implementation uses `RightPaneMode`, `StartAgentPane`, `useStartAgentPane`, `runAction({ type: 'start' })`, shared `generateAgentName(cwd)`, and `useAgentList.refresh()`. +- Noted design nuance: in wide terminals the left agent list remains visible while the right workspace changes; in narrow terminals there is no right pane, so the start-agent workspace replaces the available main pane. This is documented in requirements and manual smoke evidence. +- Residual testing gap: full Ink interaction tests for `ConsoleAppShell` remain deferred because the repo has no Ink interaction harness. Unit/helper tests, full CLI suite, build, lint, and manual TTY smoke cover this iteration. + +## Phase 7 Test Results + +Evidence recorded on 2026-06-01: +- `npx vitest run src/__tests__/tui/console/computeLayout.test.ts src/__tests__/tui/console/StartAgentPane.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/util/agent-name.test.ts` passed after simplification: 4 files, 27 tests. +- `npx nx test cli` passed after simplification: 43 files, 643 tests. +- `npx nx lint cli` passed. +- `npx nx build cli` passed. +- `npx ai-devkit@latest lint --feature agent-console-start` passed. + +Residual gap after Phase 7: full Ink keyboard interaction tests for editing text fields, `Esc`, success transition, and inline failure state remain deferred because this repo does not currently provide an Ink interaction harness. The behavior is covered by pure helper/action tests, TypeScript build, broad CLI suite, and manual TTY smoke. + +## Performance Testing +**How do we validate performance?** + +- No load test required. +- Confirm right-pane mode switching is immediate in manual testing. +- Confirm list polling remains active or resumes normally after start-pane interactions. + +## Bug Tracking +**How do we manage issues?** + +- Track implementation issues in the planning checklist. +- Treat broken existing `open`/`send` console actions as blocking regressions. +- Treat a pane that can start duplicate/invalid commands accidentally as blocking. From 9c86d89027af62ee439de5df9e2067350e53d22a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 14:53:01 +0200 Subject: [PATCH 3/3] feat(cli): add start new agent in agent console implementation --- .../tui/console/StartAgentPane.test.ts | 46 +++++ .../tui/console/actions/runAction.test.ts | 18 +- .../cli/src/__tests__/util/agent-name.test.ts | 27 +++ packages/cli/src/commands/agent.ts | 10 +- packages/cli/src/tui/console/ConsoleApp.tsx | 142 ++++++++----- .../cli/src/tui/console/StartAgentPane.tsx | 186 ++++++++++++++++++ packages/cli/src/tui/console/StatusFooter.tsx | 2 +- .../cli/src/tui/console/actions/runAction.ts | 2 + packages/cli/src/tui/console/actions/types.ts | 5 +- .../cli/src/tui/console/hooks/useAgentList.ts | 81 ++++---- .../tui/console/hooks/useStartAgentPane.ts | 77 ++++++++ packages/cli/src/util/agent.ts | 10 + 12 files changed, 510 insertions(+), 96 deletions(-) create mode 100644 packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts create mode 100644 packages/cli/src/__tests__/util/agent-name.test.ts create mode 100644 packages/cli/src/tui/console/StartAgentPane.tsx create mode 100644 packages/cli/src/tui/console/hooks/useStartAgentPane.ts create mode 100644 packages/cli/src/util/agent.ts diff --git a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts new file mode 100644 index 00000000..fb88fb8f --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + STARTABLE_AGENT_TYPES, + nextStartAgentType, + normalizeStartAgentValues, + previousStartAgentType, + trimStartAgentError, +} from '../../../tui/console/StartAgentPane.js'; + +describe('StartAgentPane helpers', () => { + it('lists supported agent start types in pane order', () => { + expect(STARTABLE_AGENT_TYPES).toEqual(['claude', 'codex', 'gemini_cli', 'opencode']); + }); + + it('cycles to the next agent type', () => { + expect(nextStartAgentType('claude')).toBe('codex'); + expect(nextStartAgentType('opencode')).toBe('claude'); + }); + + it('cycles to the previous agent type', () => { + expect(previousStartAgentType('codex')).toBe('claude'); + expect(previousStartAgentType('claude')).toBe('opencode'); + }); + + it('normalizes submitted name and cwd without changing the selected type', () => { + expect( + normalizeStartAgentValues({ + type: 'gemini_cli', + name: ' feature-agent ', + cwd: ' /tmp/project ', + }), + ).toEqual({ + type: 'gemini_cli', + name: 'feature-agent', + cwd: '/tmp/project', + }); + }); + + it('keeps short error messages unchanged', () => { + expect(trimStartAgentError('cwd does not exist', 80)).toBe('cwd does not exist'); + }); + + it('clips long error messages to fit the pane width', () => { + expect(trimStartAgentError('x'.repeat(100), 30)).toBe(`${'x'.repeat(23)}...`); + }); +}); diff --git a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts index 3d66cb64..6624fe29 100644 --- a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts +++ b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts @@ -76,9 +76,25 @@ describe('runAction', () => { expect(argv).toEqual(expect.arrayContaining(['agent', 'send', 'hello world', '--id', 'my-agent'])); }); + it('passes correct argv for start action', async () => { + vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); + await runAction({ type: 'start', agentType: 'codex', name: 'my-agent', cwd: '/tmp/project' }); + const [, argv] = vi.mocked(spawn).mock.calls[0]; + expect(argv).toEqual(expect.arrayContaining([ + 'agent', + 'start', + '--type', + 'codex', + '--name', + 'my-agent', + '--cwd', + '/tmp/project', + ])); + }); + it('spawns with stdio pipe to avoid seizing the TUI terminal', async () => { vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'open', agentName: 'x' }); + await runAction({ type: 'start', agentType: 'claude', name: 'x', cwd: '/tmp/project' }); const [, , opts] = vi.mocked(spawn).mock.calls[0]; expect(opts?.stdio).toEqual(['ignore', 'pipe', 'pipe']); }); diff --git a/packages/cli/src/__tests__/util/agent-name.test.ts b/packages/cli/src/__tests__/util/agent-name.test.ts new file mode 100644 index 00000000..1be37d29 --- /dev/null +++ b/packages/cli/src/__tests__/util/agent-name.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { generateAgentName } from '../../util/agent.js'; + +describe('generateAgentName', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses a sanitized cwd folder and base36 timestamp', () => { + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); + + expect(generateAgentName('/tmp/My App!!')).toBe(`my-app-${Date.now().toString(36)}`); + }); + + it('falls back to agent when cwd folder has no alphanumeric characters', () => { + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); + + expect(generateAgentName('/tmp/---')).toBe(`agent-${Date.now().toString(36)}`); + }); + + it('limits long sanitized folder names before appending the timestamp', () => { + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); + + const prefix = 'a'.repeat(50); + expect(generateAgentName(`/tmp/${'a'.repeat(80)}`)).toBe(`${prefix}-${Date.now().toString(36)}`); + }); +}); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 4322953c..8415a361 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -44,6 +44,7 @@ import { } from '../services/agent/agent.service.js'; import { parseMilliseconds } from '../util/time.js'; import { ConsoleApp } from '../tui/console/ConsoleApp.js'; +import { generateAgentName } from '../util/agent.js'; const AGENT_SEND_WAIT_POLL_INTERVAL_MS = 2000; const AGENT_SEND_WAIT_MAX_WAIT_MS = 10 * 60 * 1000; @@ -166,15 +167,6 @@ function createAgentManager(): AgentManager { const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; -function generateAgentName(cwd: string): string { - const folder = path.basename(cwd) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50) || 'agent'; - return `${folder}-${Date.now().toString(36)}`; -} - function writeWaitStatus(message: string): void { process.stderr.write(`${message.replace(ANSI_ESCAPE_PATTERN, '')}\n`); } diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 6239408e..82b7abdc 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -3,12 +3,14 @@ import { Box, useApp, useInput } from 'ink'; import type { AgentManager } from '@ai-devkit/agent-manager'; import { ConsoleProvider, useConsoleContext } from './state/ConsoleContext.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; +import { useStartAgentPane } from './hooks/useStartAgentPane.js'; import { AgentListPane } from './AgentListPane.js'; import { PreviewSection } from './PreviewSection.js'; import { StatusFooter } from './StatusFooter.js'; import { ChatInput } from './ChatInput.js'; import { HeaderBar } from './HeaderBar.js'; import { runAction } from './actions/runAction.js'; +import { StartAgentPane } from './StartAgentPane.js'; interface ConsoleAppProps { manager: AgentManager; @@ -23,6 +25,8 @@ const MIN_CONTENT_HEIGHT = 12; const INPUT_BOX_CHROME_ROWS = 2; type Focus = 'list' | 'input'; +type RightPaneMode = { type: 'preview' } | { type: 'start-agent' }; +type Transient = { kind: 'info' | 'error'; text: string }; export function computeLayout(cols: number, rows: number, inputLines: number, narrow: boolean) { const inputBoxHeight = inputLines + INPUT_BOX_CHROME_ROWS; @@ -52,8 +56,10 @@ const ConsoleAppShell: React.FC<{ const [focus, setFocus] = useState('list'); const [inputLines, setInputLines] = useState(1); const [inputValue, setInputValue] = useState(''); - const [transient, setTransient] = useState<{ kind: 'info' | 'error'; text: string } | null>(null); - const inputFocused = focus === 'input'; + const [transient, setTransient] = useState(null); + const [rightPaneMode, setRightPaneMode] = useState({ type: 'preview' }); + const startPaneActive = rightPaneMode.type === 'start-agent'; + const inputFocused = focus === 'input' && !startPaneActive; useEffect(() => { if (!inputFocused) setInputLines(1); @@ -69,14 +75,32 @@ const ConsoleAppShell: React.FC<{ const selectedNameRef = useRef(selectedName); selectedNameRef.current = selectedName; - const { agents, error, lastUpdated, isLoading } = useConsoleContext(); + const { agents, error, lastUpdated, isLoading, refresh } = useConsoleContext(); const agentsRef = useRef(agents); agentsRef.current = agents; + const getSelectedAgent = useCallback(() => { + const name = selectedNameRef.current; + return name ? agentsRef.current.find(agent => agent.name === name) ?? null : null; + }, []); + + const { + startDefaults, + startPaneError, + isStartingAgent, + openStartPane, + handleStartCancel, + handleStartSubmit, + } = useStartAgentPane({ + refresh, + setFocus, + setRightPaneMode, + setTransient, + }); + const handleInputSubmit = useCallback((text: string) => { setFocus('list'); - const name = selectedNameRef.current; - const agent = name ? agentsRef.current.find(a => a.name === name) : null; + const agent = getSelectedAgent(); if (!agent) return; void runAction({ type: 'send', agentName: agent.name, message: text }).then(result => { if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { @@ -85,13 +109,15 @@ const ConsoleAppShell: React.FC<{ setTransient({ kind: 'info', text: `Message sent to ${agent.name}` }); } }); - }, []); + }, [getSelectedAgent]); const handleInputCancel = useCallback(() => { setFocus('list'); }, []); useInput((input, key) => { + if (startPaneActive) return; + if (focus === 'input') { if (key.escape) { setInputValue(''); @@ -103,8 +129,7 @@ const ConsoleAppShell: React.FC<{ if (input === 'q') { exit(); return; } if (input === 'o') { - const name = selectedNameRef.current; - const agent = name ? agentsRef.current.find(a => a.name === name) : null; + const agent = getSelectedAgent(); if (!agent) return; void runAction({ type: 'open', agentName: agent.name }).then(result => { if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { @@ -114,6 +139,11 @@ const ConsoleAppShell: React.FC<{ return; } + if (input === 's') { + openStartPane(); + return; + } + if (input === 'i' || input === 'm') { if (selectedNameRef.current) setFocus('input'); return; @@ -139,54 +169,72 @@ const ConsoleAppShell: React.FC<{ const { cols, rows } = useTerminalSize(); const narrow = cols < NARROW_THRESHOLD_COLS; const { inputBoxHeight, contentHeight, previewHeight, listPaneWidth, rightColWidth, inputInnerWidth } = computeLayout(cols, rows, inputLines, narrow); + const startPane = ( + + ); return ( - + - - - - - {!narrow && ( - - + {narrow && startPaneActive ? startPane : ( - + )} + + {!narrow && ( + + {startPaneActive ? startPane : ( + <> + + + + + + )} )} @@ -194,7 +242,11 @@ const ConsoleAppShell: React.FC<{ agents={agents} lastUpdated={lastUpdated} isLoading={isLoading} - narrowNote={narrow ? `resize ≥${NARROW_THRESHOLD_COLS} cols to show preview` : null} + narrowNote={ + narrow && !startPaneActive + ? `resize ≥${NARROW_THRESHOLD_COLS} cols to show preview` + : null + } transient={transient} /> diff --git a/packages/cli/src/tui/console/StartAgentPane.tsx b/packages/cli/src/tui/console/StartAgentPane.tsx new file mode 100644 index 00000000..8f315871 --- /dev/null +++ b/packages/cli/src/tui/console/StartAgentPane.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import type { StartableAgentType } from '@ai-devkit/agent-manager'; + +export const STARTABLE_AGENT_TYPES: StartableAgentType[] = ['claude', 'codex', 'gemini_cli', 'opencode']; + +export function nextStartAgentType(type: StartableAgentType): StartableAgentType { + const index = STARTABLE_AGENT_TYPES.indexOf(type); + return STARTABLE_AGENT_TYPES[(index + 1) % STARTABLE_AGENT_TYPES.length]; +} + +export function previousStartAgentType(type: StartableAgentType): StartableAgentType { + const index = STARTABLE_AGENT_TYPES.indexOf(type); + return STARTABLE_AGENT_TYPES[(index - 1 + STARTABLE_AGENT_TYPES.length) % STARTABLE_AGENT_TYPES.length]; +} + +type Focus = 'type' | 'cwd' | 'name' | 'submit' | 'cancel'; + +interface StartAgentPaneProps { + initialType?: StartableAgentType; + initialName: string; + initialCwd: string; + onSubmit: (values: { type: StartableAgentType; name: string; cwd: string }) => void; + onCancel: () => void; + error?: string | null; + isSubmitting?: boolean; + width: number; + height: number; +} + +interface StartAgentValues { + type: StartableAgentType; + name: string; + cwd: string; +} + +const FOCUS_ORDER: Focus[] = ['type', 'cwd', 'name', 'submit', 'cancel']; + +function nextFocus(focus: Focus): Focus { + return FOCUS_ORDER[(FOCUS_ORDER.indexOf(focus) + 1) % FOCUS_ORDER.length]; +} + +function previousFocus(focus: Focus): Focus { + return FOCUS_ORDER[(FOCUS_ORDER.indexOf(focus) - 1 + FOCUS_ORDER.length) % FOCUS_ORDER.length]; +} + +export function normalizeStartAgentValues(values: StartAgentValues): StartAgentValues { + return { + type: values.type, + name: values.name.trim(), + cwd: values.cwd.trim(), + }; +} + +export function trimStartAgentError(error: string, width: number): string { + const max = Math.max(20, width - 6); + return error.length > max ? `${error.slice(0, max - 1)}...` : error; +} + +export const StartAgentPane: React.FC = ({ + initialType = 'codex', + initialName, + initialCwd, + onSubmit, + onCancel, + error = null, + isSubmitting = false, + width, + height, +}) => { + const [type, setType] = useState(initialType); + const [cwd, setCwd] = useState(initialCwd); + const [name, setName] = useState(initialName); + const [focus, setFocus] = useState('type'); + + const submit = (): void => { + if (isSubmitting) return; + onSubmit(normalizeStartAgentValues({ type, name, cwd })); + }; + + useInput((input, key) => { + if (isSubmitting) return; + if (key.escape || input === '\u001b') { + onCancel(); + return; + } + + if (focus === 'type') { + if (key.leftArrow || input === 'h') { + setType(previousStartAgentType(type)); + return; + } + if (key.rightArrow || input === 'l') { + setType(nextStartAgentType(type)); + return; + } + } + + if (key.tab || key.downArrow) { + setFocus(nextFocus(focus)); + return; + } + if (key.upArrow) { + setFocus(previousFocus(focus)); + return; + } + + if (key.return) { + if (focus === 'submit') { + submit(); + } else if (focus === 'cancel') { + onCancel(); + } else { + setFocus(nextFocus(focus)); + } + } + }); + + const innerWidth = Math.max(24, width - 4); + + return ( + + + START AN AGENT + {isSubmitting ? starting... : null} + + + + Type: + {STARTABLE_AGENT_TYPES.map((agentType) => ( + + {` ${agentType} `} + + ))} + + + + Cwd: + {focus === 'cwd' ? ( + setFocus('name')} /> + ) : ( + {cwd} + )} + + + + Name: + {focus === 'name' ? ( + setFocus('submit')} /> + ) : ( + {name} + )} + + + {error ? ( + + {trimStartAgentError(error, width)} + + ) : null} + + + + {isSubmitting ? ' Starting ' : ' Start '} + + + + {' Cancel '} + + tab move · esc back + + + ); +}; diff --git a/packages/cli/src/tui/console/StatusFooter.tsx b/packages/cli/src/tui/console/StatusFooter.tsx index 84e21ad9..4013fda6 100644 --- a/packages/cli/src/tui/console/StatusFooter.tsx +++ b/packages/cli/src/tui/console/StatusFooter.tsx @@ -40,7 +40,7 @@ const StatusFooterInner: React.FC = ({ - {summary}{' · '}{updated}{' · '}j/k nav · o open · i message · q quit + {summary}{' · '}{updated}{' · '}j/k nav · s start · o open · i message · q quit {narrowNote ? ( diff --git a/packages/cli/src/tui/console/actions/runAction.ts b/packages/cli/src/tui/console/actions/runAction.ts index d3f8bf12..6aac76df 100644 --- a/packages/cli/src/tui/console/actions/runAction.ts +++ b/packages/cli/src/tui/console/actions/runAction.ts @@ -18,6 +18,8 @@ export async function runAction(action: ConsoleAction): Promise { return [...baseArgs, 'agent', 'open', action.agentName]; case 'send': return [...baseArgs, 'agent', 'send', action.message, '--id', action.agentName]; + case 'start': + return [...baseArgs, 'agent', 'start', '--type', action.agentType, '--name', action.name, '--cwd', action.cwd]; } })(); diff --git a/packages/cli/src/tui/console/actions/types.ts b/packages/cli/src/tui/console/actions/types.ts index 1ba8a898..01e74dab 100644 --- a/packages/cli/src/tui/console/actions/types.ts +++ b/packages/cli/src/tui/console/actions/types.ts @@ -1,3 +1,6 @@ +import type { StartableAgentType } from '@ai-devkit/agent-manager'; + export type ConsoleAction = | { type: 'open'; agentName: string } - | { type: 'send'; agentName: string; message: string }; + | { type: 'send'; agentName: string; message: string } + | { type: 'start'; agentType: StartableAgentType; name: string; cwd: string }; diff --git a/packages/cli/src/tui/console/hooks/useAgentList.ts b/packages/cli/src/tui/console/hooks/useAgentList.ts index 6fa2fecb..22a13786 100644 --- a/packages/cli/src/tui/console/hooks/useAgentList.ts +++ b/packages/cli/src/tui/console/hooks/useAgentList.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { AgentInfo, AgentManager } from '@ai-devkit/agent-manager'; export interface UseAgentListResult { @@ -6,8 +6,11 @@ export interface UseAgentListResult { error: string | null; lastUpdated: Date | null; isLoading: boolean; + refresh: () => Promise; } +type AgentListState = Omit; + export const LIST_POLL_INTERVAL_MS = 3000; export function agentsEqual(a: AgentInfo[], b: AgentInfo[]): boolean { @@ -36,7 +39,7 @@ export function useAgentList( ): UseAgentListResult { // Single state object so multiple updates within one fetch produce // exactly one render (React 17 doesn't batch async setState). - const [state, setState] = useState({ + const [state, setState] = useState({ agents: [], error: null, lastUpdated: null, @@ -47,54 +50,54 @@ export function useAgentList( const inFlightRef = useRef(false); const mountedRef = useRef(true); + const refresh = useCallback(async (): Promise => { + if (inFlightRef.current) return; + inFlightRef.current = true; + const token = ++runTokenRef.current; + try { + const next = await manager.listAgents({ sortBy: 'status' }); + if (!mountedRef.current || token !== runTokenRef.current) return; + setState(prev => { + const isFirst = prev.lastUpdated === null; + const changed = !agentsEqual(prev.agents, next); + // Quiet poll: nothing changed, no error to clear, not first + // load. Skip state update entirely → zero re-renders. + if (!changed && prev.error === null && !prev.isLoading && !isFirst) { + return prev; + } + return { + agents: changed ? next : prev.agents, + error: null, + lastUpdated: new Date(), + isLoading: false, + }; + }); + } catch (err) { + if (!mountedRef.current || token !== runTokenRef.current) return; + const message = err instanceof Error ? err.message : String(err); + setState(prev => prev.error === message && !prev.isLoading + ? prev + : { ...prev, error: message, isLoading: false }); + } finally { + inFlightRef.current = false; + } + }, [manager]); + useEffect(() => { mountedRef.current = true; inFlightRef.current = false; - const fetchOnce = async (): Promise => { - if (inFlightRef.current) return; - inFlightRef.current = true; - const token = ++runTokenRef.current; - try { - const next = await manager.listAgents({ sortBy: 'status' }); - if (!mountedRef.current || token !== runTokenRef.current) return; - setState(prev => { - const isFirst = prev.lastUpdated === null; - const changed = !agentsEqual(prev.agents, next); - // Quiet poll: nothing changed, no error to clear, not first - // load. Skip state update entirely → zero re-renders. - if (!changed && prev.error === null && !prev.isLoading && !isFirst) { - return prev; - } - return { - agents: changed ? next : prev.agents, - error: null, - lastUpdated: new Date(), - isLoading: false, - }; - }); - } catch (err) { - if (!mountedRef.current || token !== runTokenRef.current) return; - const message = err instanceof Error ? err.message : String(err); - setState(prev => prev.error === message && !prev.isLoading - ? prev - : { ...prev, error: message, isLoading: false }); - } finally { - inFlightRef.current = false; - } - }; - if (paused) { return () => { mountedRef.current = false; }; } - void fetchOnce(); - const handle = setInterval(() => { void fetchOnce(); }, intervalMs); + void refresh(); + const handle = setInterval(() => { void refresh(); }, intervalMs); return () => { mountedRef.current = false; clearInterval(handle); }; - }, [manager, intervalMs, paused]); + }, [intervalMs, paused, refresh]); - return state; + return { ...state, refresh }; } diff --git a/packages/cli/src/tui/console/hooks/useStartAgentPane.ts b/packages/cli/src/tui/console/hooks/useStartAgentPane.ts new file mode 100644 index 00000000..3c5832fb --- /dev/null +++ b/packages/cli/src/tui/console/hooks/useStartAgentPane.ts @@ -0,0 +1,77 @@ +import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; +import type { StartableAgentType } from '@ai-devkit/agent-manager'; +import { runAction } from '../actions/runAction.js'; +import { generateAgentName } from '../../../util/agent.js'; + +type Focus = 'list' | 'input'; +type RightPaneMode = { type: 'preview' } | { type: 'start-agent' }; +type Transient = { kind: 'info' | 'error'; text: string }; +type StartDefaults = { name: string; cwd: string }; + +interface UseStartAgentPaneOptions { + refresh: () => Promise; + setFocus: Dispatch>; + setRightPaneMode: Dispatch>; + setTransient: Dispatch>; +} + +interface StartAgentValues { + type: StartableAgentType; + name: string; + cwd: string; +} + +function createStartDefaults(): StartDefaults { + const cwd = process.cwd(); + return { name: generateAgentName(cwd), cwd }; +} + +export function useStartAgentPane({ + refresh, + setFocus, + setRightPaneMode, + setTransient, +}: UseStartAgentPaneOptions) { + const [startPaneError, setStartPaneError] = useState(null); + const [isStartingAgent, setIsStartingAgent] = useState(false); + const [startDefaults, setStartDefaults] = useState(createStartDefaults); + + const openStartPane = useCallback(() => { + setStartDefaults(createStartDefaults()); + setStartPaneError(null); + setFocus('list'); + setRightPaneMode({ type: 'start-agent' }); + }, [setFocus, setRightPaneMode]); + + const handleStartCancel = useCallback(() => { + if (isStartingAgent) return; + setRightPaneMode({ type: 'preview' }); + setStartPaneError(null); + }, [isStartingAgent, setRightPaneMode]); + + const handleStartSubmit = useCallback((values: StartAgentValues) => { + if (isStartingAgent) return; + setIsStartingAgent(true); + setStartPaneError(null); + void runAction({ type: 'start', agentType: values.type, name: values.name, cwd: values.cwd }).then(async result => { + if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { + setStartPaneError(result.error ?? `start exited ${result.exitCode}`); + return; + } + setRightPaneMode({ type: 'preview' }); + setTransient({ kind: 'info', text: `Started ${values.name}` }); + await refresh(); + }).finally(() => { + setIsStartingAgent(false); + }); + }, [isStartingAgent, refresh, setRightPaneMode, setTransient]); + + return { + startDefaults, + startPaneError, + isStartingAgent, + openStartPane, + handleStartCancel, + handleStartSubmit, + }; +} diff --git a/packages/cli/src/util/agent.ts b/packages/cli/src/util/agent.ts new file mode 100644 index 00000000..07717c73 --- /dev/null +++ b/packages/cli/src/util/agent.ts @@ -0,0 +1,10 @@ +import path from 'path'; + +export function generateAgentName(cwd: string): string { + const folder = path.basename(cwd) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50) || 'agent'; + return `${folder}-${Date.now().toString(36)}`; +}