diff --git a/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-7-fleet-list.md b/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-7-fleet-list.md index d57acd9..fc291b9 100644 --- a/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-7-fleet-list.md +++ b/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-7-fleet-list.md @@ -2,39 +2,440 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Restyle FleetList and align its viewer overlay sizing without changing keyboard capture or agent activation. +**Goal:** Restyle the below-editor FleetList and prevent its global terminal listener from stealing keys when Pi has focused a dialog, without changing the roster, activation keys, or conversation-viewer behavior. -**Architecture:** Keep the below-editor fleet as an unboxed compact list; use shared overlay sizing only when it opens the conversation viewer. Preserve the current roster and activation state machine. +**Architecture:** Keep `FleetList` as the existing render-only `belowEditor` widget with one `onTerminalInput()` listener. Harden that listener at its shared boundary by checking Pi/TUI's public `getFocusedComponent()` result for the editor contract, then change only the list's text composition and the overlay options passed when an agent opens. The roster, five-agent window, timers, activation state, viewer construction, and completion linger remain in the existing class. -**Tech Stack:** TypeScript, Pi/TUI `0.84.3`, Vitest. +**Tech Stack:** TypeScript, `@earendil-works/pi-coding-agent@0.84.3`, `@earendil-works/pi-tui@0.84.3`, Vitest, pnpm, Node `24.15.0`. -**Parent plan:** `docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui.md` +**Spec:** `docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui.md#approved-design` -**Prerequisite:** Phase 6 inline renderers. +**Prerequisite:** Phase 6 is merged at `0c236ff`. -**Usable result:** Background-agent navigation matches the dashboard language and still opens the correct conversation reliably. +**Usable result:** An unboxed, width-safe fleet uses the dashboard visual language, consumes navigation only while Pi's prompt editor owns focus, and opens the selected agent with the shared overlay dimensions. -## Constraints +## Reference Decisions -- FleetList remains below the editor and displays five rows. -- Preserve input capture, key-release handling, right-aligned metadata, roster filtering, activation, and cleanup. -- Do not put the persistent fleet inside a box. +- `/Users/lanh/Developer/pi-packages/pi` at tag `v0.84.3` (`4e58f324f`): use `matchesKey()` and `isKeyRelease()` for terminal encodings; use public `TUI.getFocusedComponent()` rather than reading private TUI state. `ctx.ui.custom()` must receive both `overlay: true` and `overlayOptions` for overlay sizing. +- `/Users/lanh/Developer/pi-vault/pi-status` at `b47dadc`: reuse the established centered `92%` width / `85%` max-height options and semantic theme roles. Do not import its dashboard state, palette, sidebar, frame, or configuration. +- `/Users/lanh/Developer/pi-packages/nicobailon-pi-subagents` at `2e23ae7`: retain the useful editor-focus boundary, selected-row-aware bounded window, ANSI-width clamping, and concise key hints. Do not add its nested fleet tree, configurable shortcuts, compact/expanded modes, history, inspector, or action system. +- `/Users/lanh/Developer/pi-packages/tintinweb-pi-subagents` at `86c72ae`: retain the proven lifecycle cases—real Down-release filtering, earliest-first rows, pending-session filtering, viewer identity after reorder, linger, timer cleanup, and steering to the selected id. Do not add its agent badges, color registry, separate activity map, private `focusedComponent` access, or assumptions about `Editor` class identity. -### Task 1: Add failing fleet regressions +## Global Constraints -**Files:** `tests/fleet-list.test.ts` +- Modify only `src/tui/fleet-list.ts` and `tests/fleet-list.test.ts`. The plan file belongs to the planning change and is not staged with implementation commits. +- Preserve the `FleetList` constructor and public methods, `FLEET_KEY = "fleet"`, `belowEditor` placement, `MAX_AGENT_ROWS = 5`, `TICK_MS = 200`, and `FINISHED_LINGER_MS = 4000`. +- Preserve the roster: `main`, then session-backed running/queued agents in earliest-started order, the viewed agent, and recently completed agents during the existing linger window. +- Preserve activation on Down or Left at an empty prompt, clamped Up/Down navigation, Enter behavior, Escape behavior, other-key pass-through, key-release suppression, viewer steering/stopping, and cleanup. +- Consume no key unless the Pi prompt editor owns focus. Unknown, absent, selector, input-dialog, and overlay focus all pass through; switching away while FleetList is active deactivates it. +- Keep the persistent fleet unboxed. Do not call `renderDashboardFrame()` or add a component/controller/helper file. +- Render exactly five agent rows at once; `main`, heading, footer, and optional `↑/↓ N more` rows are outside that five-agent budget. +- Use `✦ Agents` as the heading, `▸` only for the active selected row, blank marker space for unselected/inactive rows, and dim `•` separators. Do not use ambiguous circle glyphs as selection markers. +- Keep elapsed time and token usage right-aligned and more important than long left-hand text. Normalize dynamic CR/LF to spaces and ensure every returned line has `visibleWidth(line) <= allocatedWidth`. +- Pass `DASHBOARD_OVERLAY_OPTIONS` from `src/tui/dashboard-style.ts` when opening `ConversationViewer`. Phase 8 still owns the viewer frame, `Focusable`, composer focus, internal viewport, and `VIEWPORT_HEIGHT_PCT` change from `70` to `85`. +- Add no dependency, setting, exported generic UI abstraction, new command, new keybinding, wrapping navigation, search, filtering, history, or nested-agent presentation. +- Existing repository-wide Biome diagnostics are out of scope; do not edit unrelated files to silence them. -- [ ] Assert an unboxed `✦ Agents` heading, `▸` selected row, dim `•` unselected rows, dashboard footer hints, five-row viewport, right-aligned metadata, and shared `92%`/`85%` viewer overlay options. -- [ ] Retain/add behavior assertions for CSI-u and legacy navigation, key-release suppression, correct agent activation, roster changes, and cleanup. Run the file; expected: new visual/overlay assertions fail. +## File Structure -### Task 2: Restyle the existing component +- Modify `src/tui/fleet-list.ts`: own the prompt-focus boundary, unboxed fleet composition, ANSI-safe alignment, and shared viewer overlay options. +- Modify `tests/fleet-list.test.ts`: own a realistic FleetList driver plus input, focus, roster, rendering, overlay, timer, and cleanup regressions. -**Files:** `src/tui/fleet-list.ts` +--- -- [ ] Change render composition only: add the heading, markers, compact metadata, and footer; keep existing five-row viewport and ANSI-safe width calculations. -- [ ] When activating a row, open the conversation viewer with `DASHBOARD_OVERLAY_OPTIONS`. Do not move focus or input handling into a new controller. +### Task 1: Harden the Terminal-Input Boundary -### Task 3: Verify and commit +**Files:** -- [ ] Run `pnpm vitest run tests/fleet-list.test.ts`, then `pnpm check` and `git diff --check`. Manually start a background agent, navigate the fleet, open it, close it, and confirm editor input is restored. -- [ ] Commit with `git add src/tui/fleet-list.ts tests/fleet-list.test.ts && git commit -m "feat: restyle background agent fleet"`. +- Modify: `tests/fleet-list.test.ts` +- Modify: `src/tui/fleet-list.ts:12-18,31-36,90-292` + +**Interfaces:** + +- Consume from Pi/TUI: + - `TUI.getFocusedComponent(): Component | null` + - `EditorComponent`'s structural `render`, `invalidate`, `handleInput`, `getText`, and `setText` methods + - `matchesKey(data, key)` and `isKeyRelease(data)` +- Preserve: + - `FleetList.handleKey(data): { consume?: boolean; data?: string } | undefined` + - `FleetUICtx.onTerminalInput()` and its unsubscribe function + - the current activation, navigation, deactivation, and viewer-open state machine +- Add no exported runtime symbol. + +- [ ] **Step 1: Replace the isolated mocks with one realistic FleetList driver.** + + In `tests/fleet-list.test.ts`, import `visibleWidth`, `type EditorComponent`, and `DASHBOARD_OVERLAY_OPTIONS`. Keep the existing format-helper tests, then add these key constants: + + ```ts + const LEGACY_DOWN = "\x1b[B"; + const LEGACY_UP = "\x1b[A"; + const LEGACY_LEFT = "\x1b[D"; + const LEGACY_ENTER = "\r"; + const LEGACY_ESCAPE = "\x1b"; + const KITTY_DOWN = "\x1b[1;1B"; + const KITTY_UP = "\x1b[1;1A"; + const KITTY_LEFT = "\x1b[1;1D"; + const CSI_U_ENTER = "\x1b[13u"; + const CSI_U_ESCAPE = "\x1b[27u"; + const KITTY_DOWN_RELEASE = "\x1b[1;1:3B"; + ``` + + Build one `harness(records)` that: + - registers and captures the `onTerminalInput` handler and unsubscribe spy, + - captures the `setWidget` factory and instantiates it with a fake TUI, + - exposes `press(data)`, `render(width)`, `setEditorText(text)`, `setFocus(component)`, `closeOverlay()`, captured custom options, and manager spies, + - gives the fake TUI `requestRender()`, `getFocusedComponent()`, and a normal editor-shaped focused component by default, + - gives default sessions `messages: []` and `subscribe: () => () => {}` so every visible record is openable, and + - keeps the custom promise pending until `done()` is called so overlay-open behavior is realistic. + + Use this structural editor fixture rather than constructing Pi internals: + + ```ts + const editorComponent = (): EditorComponent => ({ + render: () => [], + invalidate: () => {}, + handleInput: () => {}, + getText: () => "", + setText: () => {}, + }); + ``` + + A dialog fixture is only `{ render: () => [], invalidate: () => {}, handleInput: () => {} }`; it deliberately lacks `getText()` and `setText()`. + +- [ ] **Step 2: Add failing legacy, CSI-u/Kitty, release, and focus regressions.** + + Add table-driven assertions that both legacy and modern Down/Left encodings activate from an empty focused editor and return `{ consume: true }`. Add navigation assertions using both legacy and Kitty Up/Down plus both Enter/Escape encodings. + + Add these boundary cases: + + ```ts + it("does not steal activation keys from a focused dialog", () => { + const h = harness([makeRecord()]); + h.setFocus(dialogComponent()); + expect(h.press(KITTY_DOWN)).toBeUndefined(); + }); + + it("deactivates and passes through when focus leaves the editor", () => { + const h = harness([makeRecord()]); + expect(h.press(LEGACY_DOWN)).toEqual({ consume: true }); + h.setFocus(dialogComponent()); + expect(h.press(LEGACY_DOWN)).toBeUndefined(); + h.setFocus(editorComponent()); + expect(h.press(LEGACY_UP)).toBeUndefined(); + }); + + it("does not consume input while focused component is unknown", () => { + const h = harness([makeRecord()]); + h.setFocus(null); + expect(h.press(LEGACY_DOWN)).toBeUndefined(); + }); + + it("ignores the release half of a Down key", () => { + const h = harness([makeRecord(), makeRecord({ id: "a2" })]); + h.press(KITTY_DOWN); + expect(h.press(KITTY_DOWN_RELEASE)).toBeUndefined(); + h.press(CSI_U_ENTER); + expect(h.overlayOpened()).toBe(false); + }); + ``` + + Retain or add assertions that a non-navigation key deactivates and passes through, Up above `main` deactivates, Escape deactivates, and Enter on `main` deactivates without opening an overlay. With fake timers, also prove disabling or emptying the fleet clears its interval, re-enabling with agents starts one interval again, and `dispose()` calls the terminal-input unsubscribe, closes an open viewer, clears a registered widget, and leaves no timer. + +- [ ] **Step 3: Run the input tests and confirm focus-only failures.** + + Run: + + ```bash + mise exec node@24.15.0 -- pnpm vitest run tests/fleet-list.test.ts + ``` + + Expected: legacy/CSI-u matching and release filtering pass through Pi/TUI; the focused-dialog, focus-loss, and unknown-focus assertions fail because the current raw listener checks only `getEditorText()`. + +- [ ] **Step 4: Use Pi's public focused-component API at the shared boundary.** + + In `src/tui/fleet-list.ts`, import `type EditorComponent` and retain the existing `TUI` type import. Expand the local TUI handle: + + ```ts + type TuiHandle = Pick; + ``` + + Add this private method to `FleetList`: + + ```ts + private editorHasFocus(): boolean { + const focused = this.tuiHandle?.getFocusedComponent(); + if (!focused) return false; + const candidate = focused as Partial; + return ( + typeof candidate.render === "function" && + typeof candidate.invalidate === "function" && + typeof candidate.handleInput === "function" && + typeof candidate.getText === "function" && + typeof candidate.setText === "function" + ); + } + ``` + + In `handleKey()`, immediately after the existing key-release and open-viewer guards, add: + + ```ts + if (!this.editorHasFocus()) { + if (this.active) this.deactivate(); + return undefined; + } + ``` + + Do not read a private `focusedComponent` property, use `instanceof Editor`, add focus mutation, or change any later key branch. + +- [ ] **Step 5: Run the focused test and commit the boundary fix.** + + Run: + + ```bash + mise exec node@24.15.0 -- pnpm vitest run tests/fleet-list.test.ts + mise exec node@24.15.0 -- pnpm typecheck + git diff --check + ``` + + Expected: FleetList consumes legacy and modern navigation only when the editor fixture owns focus; all input, overlay, and existing format tests pass. + + Commit: + + ```bash + git add src/tui/fleet-list.ts tests/fleet-list.test.ts + git commit -m "fix: preserve fleet input focus" + ``` + +--- + +### Task 2: Apply the Unboxed Fleet Presentation and Shared Overlay Options + +**Files:** + +- Modify: `tests/fleet-list.test.ts` +- Modify: `src/tui/fleet-list.ts:12-18,76-88,294-412` + +**Interfaces:** + +- Consume: `DASHBOARD_OVERLAY_OPTIONS` from `src/tui/dashboard-style.ts`. +- Preserve: `formatFleetElapsed()`, `formatFleetTokens()`, `rightAlign()`, the five-agent window, roster filtering/order, viewer callbacks, selection restoration by agent id, timer cadence, and cleanup. +- Presentation contract at normal width: + + ```text + ✦ Agents + main + ▸ coder Fix bug 5s • ↓ 150 tokens + ↑/↓ Select • Enter View • Esc Back + ``` + + When inactive, no row has `▸` and the footer is `↓/← Focus agents • Esc Interrupt`. Unselected rows reserve the same marker width with spaces. Optional `↑ N more` / `↓ N more` rows remain dim and right-aligned. + +- [ ] **Step 1: Add exact visual, semantic-role, and overlay regressions.** + + Use fake timers and a fixed system time so elapsed text is stable. Render one inactive and one active fleet at width `80`, strip only the test theme markers, and assert: + + ```ts + expect(plain(inactive[0])).toBe("✦ Agents"); + expect(plain(inactive.at(-1)!)).toBe("↓/← Focus agents • Esc Interrupt"); + expect(plain(inactive.join("\n"))).not.toContain("▸"); + + expect(plain(active[0])).toBe("✦ Agents"); + expect(plain(active.find((line) => line.includes("coder"))!)).toMatch( + /^▸ coder Fix bug\s+5s • ↓ 150 tokens$/, + ); + expect(plain(active.at(-1)!)).toBe("↑/↓ Select • Enter View • Esc Back"); + ``` + + With a semantic test theme, assert the heading is `accent` + bold, the selected `▸` uses `accent`, footer/overflow/separators use `dim`, and the component emits none of `┏━┃┗╭─│╰`. + + Open the first agent and assert the exact custom options: + + ```ts + expect(h.customOptions()).toEqual({ + overlay: true, + overlayOptions: DASHBOARD_OVERLAY_OPTIONS, + }); + ``` + + Also assert the real viewer factory still receives the selected live record: rendered output includes its tool count, token count, and activity, a steer submission calls `manager.steer(selectedId, message)` once, and pressing `x` twice calls `manager.abort(selectedId)` once. + +- [ ] **Step 2: Add five-agent window, roster, width, and text-safety regressions.** + + Add tests proving: + - eight agents render only five agent rows plus `↓ 3 more` at the top of the window; + - moving to the last agent keeps it visible and shows `↑ 3 more`; + - `main` does not count against the five-agent budget; + - session-less queued agents are hidden; + - visible agents are earliest-started first; + - a recently completed agent lingers while an agent completed more than four seconds ago is absent; + - closing a viewer reselects the viewed agent by id after an earlier row disappears; and + - dynamic type/description text containing CR/LF produces no embedded CR/LF in a returned row. + + For widths `[0, 1, 4, 8, 20, 40, 80, 200]`, render long dynamic text with an ANSI-producing theme and assert: + + ```ts + expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); + expect(lines.every((line) => !/[\r\n]/.test(line))).toBe(true); + ``` + + Keep the right-hand `elapsed • tokens` metadata visible in preference to the left-hand label whenever the width can contain it. + +- [ ] **Step 3: Run the focused test and confirm presentation-only failures.** + + Run: + + ```bash + mise exec node@24.15.0 -- pnpm vitest run tests/fleet-list.test.ts + ``` + + Expected: roster, viewer identity, and existing lifecycle behavior pass; heading, marker, footer, separator, CR/LF normalization, and shared-overlay assertions fail against the old composition. + +- [ ] **Step 4: Implement the minimum unboxed composition.** + + In `src/tui/fleet-list.ts`: + 1. Import `DASHBOARD_OVERLAY_OPTIONS` and stop importing `VIEWPORT_HEIGHT_PCT` from `conversation-viewer.ts`. + 2. In `rightAlign()`, replace CR/LF runs in both inputs with one space before width calculation. Keep truncating the left side first and keep the final `truncateToWidth()` clamp. + 3. Render `theme.fg("accent", theme.bold("✦ Agents"))` as the first line. + 4. Replace the circle marker with a two-column marker: active selected rows use accent `▸`; every other row uses spaces. Bold only the selected row's subject; keep agent type semantic styling, description text, and right-aligned stats. + 5. Change the right metadata separator from `·` to `•` inside the existing dim style. + 6. Keep the existing selected-aware five-agent `start`, `visible`, and `hiddenBelow` calculations plus the `↑/↓ N more` rows. + 7. Append exactly one dim footer line: active `↑/↓ Select • Enter View • Esc Back`; inactive `↓/← Focus agents • Esc Interrupt`. + 8. Remove the old leading hint and blank spacer. Do not add a frame or padding rows. + 9. Replace the inline overlay dimensions with `overlayOptions: DASHBOARD_OVERLAY_OPTIONS` while retaining `overlay: true`. + + Use these composition fragments; keep the existing roster/window loop around them: + + ```ts + const heading = theme.fg("accent", theme.bold("✦ Agents")); + const footer = this.active + ? "↑/↓ Select • Enter View • Esc Back" + : "↓/← Focus agents • Esc Interrupt"; + + private marker(rosterIndex: number, selectedIndex: number, theme: Theme): string { + return this.active && rosterIndex === selectedIndex + ? `${theme.fg("accent", "▸")} ` + : " "; + } + + const right = theme.fg( + "dim", + `${formatFleetElapsed(elapsedMs)} • ${formatFleetTokens(tokens)}`, + ); + ``` + + The overlay call remains: + + ```ts + { + overlay: true, + overlayOptions: DASHBOARD_OVERLAY_OPTIONS, + } + ``` + + Do not change `agentRecords()`, `roster()`, `clampSelection()`, `openSelected()` callbacks, `clearViewer()`, timer registration, or disposal beyond the overlay-options replacement. + +- [ ] **Step 5: Run focused and neighboring UI verification.** + + Run: + + ```bash + mise exec node@24.15.0 -- pnpm vitest run \ + tests/fleet-list.test.ts \ + tests/dashboard-style.test.ts \ + tests/agents-menu.test.ts \ + tests/conversation-viewer.test.ts \ + tests/agent-manager.test.ts + mise exec node@24.15.0 -- pnpm typecheck + git diff --check + ``` + + Expected: the unboxed fleet and shared overlay contract pass without changing the Phase 8 viewer's current `VIEWPORT_HEIGHT_PCT = 70` assertion or any manager lifecycle behavior. + +- [ ] **Step 6: Commit the presentation migration.** + + ```bash + git add src/tui/fleet-list.ts tests/fleet-list.test.ts + git commit -m "feat: restyle background agent fleet" + ``` + +--- + +### Task 3: Verify the Complete Fleet Phase + +**Files:** + +- Verify only: `src/tui/fleet-list.ts` +- Verify only: `tests/fleet-list.test.ts` +- Verify only: `src/tui/dashboard-style.ts` +- Verify only: `src/tui/conversation-viewer.ts` +- Verify only: `src/index.ts` + +**Interfaces:** + +- Produces no new extension API, command, setting, dependency, exported helper, or viewer behavior. +- Accepts the phase only when focused tests, the complete repository check, diff validation, and a rendered TUI smoke path pass. + +- [ ] **Step 1: Run the complete repository check at the pinned runtime.** + + Run: + + ```bash + mise exec node@24.15.0 -- env GIT_CONFIG_GLOBAL=/dev/null pnpm check + ``` + + Expected: exit `0`; every Vitest file passes. Existing Biome warnings or infos may print, but no unrelated file is edited to silence them. + +- [ ] **Step 2: Inspect the implementation diff for scope and whitespace.** + + Run: + + ```bash + git status --short + git diff --stat 0c236ff..HEAD + git diff 0c236ff..HEAD -- src/tui/fleet-list.ts tests/fleet-list.test.ts + git diff --check 0c236ff..HEAD + git diff --check + ``` + + Expected: implementation commits since `0c236ff` change only `src/tui/fleet-list.ts` and `tests/fleet-list.test.ts`; the separately committed planning change may also contain this plan file. No viewer, manager, settings, schema, dependency, or index wiring changes appear. + +- [ ] **Step 3: Smoke-test focus handoff and rendering in Pi.** + + Start the local extension: + + ```bash + mise exec node@24.15.0 -- pi -e ./src/index.ts + ``` + + In Ghostty, Kitty, or another terminal with CSI-u/Kitty keyboard input enabled: + 1. Start at least six background agents; verify `✦ Agents`, the unboxed five-agent window, right-aligned stats, and overflow row. + 2. At an empty prompt, use Down and Left to activate, navigate with arrows, and confirm `▸` follows the selected row without wrapping. + 3. Type prompt text and press Down; verify FleetList does not activate and editor navigation remains native. + 4. Open `/agents` while FleetList is visible; navigate its custom menu and one native input/editor dialog. Verify FleetList never consumes their arrows or Enter/Escape. + 5. Return to the empty prompt, open a background agent, scroll, steer once, close with Escape, and verify the same agent remains selected. + 6. Let the selected agent finish while its viewer is open; verify the viewer stays readable, then closes back to the lingering row. + 7. Resize from wide to narrow and back; verify no fleet row wraps and right-hand metadata survives before long descriptions. + + Expected: editor/dialog focus is preserved, legacy and modern terminal encodings work once per key press, the correct agent opens and receives steering, and the fleet remains unboxed and width-safe. + +- [ ] **Step 4: Record final verification and confirm a clean implementation worktree.** + + Run: + + ```bash + git log -3 --oneline + git status --short --branch + ``` + + Expected: the focus and presentation commits are present, and no implementation file remains modified. Record the terminal name and Pi version in the implementation handoff; any failed smoke path blocks completion. + +## Completion Criteria + +- FleetList consumes Down/Left/Up/Enter/Escape only while a structurally valid Pi editor owns focus and never steals keys from dialogs or unknown focus. +- Legacy and CSI-u/Kitty encodings work, and key-release events never move or activate twice. +- The list stays below the editor, unboxed, earliest-first, session-backed, and limited to five visible agent rows. +- `✦ Agents`, active `▸`, dim `•` separators, overflow indicators, and the single state-aware footer follow the exact presentation contract. +- Every rendered line is CR/LF-safe and no wider than Pi's allocated component width; elapsed/token metadata remains right-aligned. +- Opening a row uses `{ overlay: true, overlayOptions: DASHBOARD_OVERLAY_OPTIONS }` and still targets the correct agent for viewing, steering, stopping, and post-close selection. +- Timers, widget registration, finished-agent linger, viewer persistence, unsubscribe behavior, and disposal remain covered and unchanged. +- Focused tests, neighboring UI tests, `pnpm check`, typecheck, diff checks, and the live Pi smoke path pass. diff --git a/src/tui/fleet-list.ts b/src/tui/fleet-list.ts index db1db71..1371243 100644 --- a/src/tui/fleet-list.ts +++ b/src/tui/fleet-list.ts @@ -2,22 +2,24 @@ * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor. * * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or - * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ⏺ marker), + * ←) at an empty prompt activates the list; ↑/↓ move the selection (▸ marker), * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt. * A viewer stays open when its agent finishes; finished agents linger briefly in the list. * * Mechanics: the list is a `belowEditor` widget (render-only), and ALL key handling * goes through `onTerminalInput` — which fires before the focused editor and can - * `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched. + * `consume` keys — gated on prompt focus and `getEditorText() === ""` so dialogs + * and normal typing are untouched. */ import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { TUI } from "@earendil-works/pi-tui"; +import type { Component, EditorComponent, TUI } from "@earendil-works/pi-tui"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; import type { AgentManager } from "../core/agent-manager.js"; import type { AgentRecord } from "../shared/types.js"; -import { ConversationViewer, VIEWPORT_HEIGHT_PCT, type ViewerKeybindings } from "./conversation-viewer.js"; +import { ConversationViewer, type ViewerKeybindings } from "./conversation-viewer.js"; import type { Theme } from "./agent-widget.js"; +import { DASHBOARD_OVERLAY_OPTIONS } from "./dashboard-style.js"; /** Widget key for the below-editor fleet list. */ const FLEET_KEY = "fleet"; @@ -28,8 +30,10 @@ const TICK_MS = 200; /** How long a finished agent lingers in the list before it drops out. */ const FINISHED_LINGER_MS = 4000; -/** Minimal handle needed from the tui object to request re-renders. */ -type TuiHandle = { requestRender(): void }; +/** Minimal TUI surface needed for focus checks and re-renders. */ +type TuiHandle = Pick & { + getFocusedComponent(): Component | null; +}; /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */ export type FleetUICtx = { @@ -80,9 +84,9 @@ export function formatFleetTokens(count: number): string { * desync pi's line-diff → flicker) even on a terminal too narrow for the stats. */ function rightAlign(left: string, right: string, width: number): string { + const safeLeft = left.replace(/[\r\n]+/g, " "); const rightW = visibleWidth(right); - const maxLeft = Math.max(0, width - rightW - 1); - const leftClamped = truncateToWidth(left, maxLeft); + const leftClamped = truncateToWidth(safeLeft, Math.max(0, width - rightW - 1)); const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW); return truncateToWidth(leftClamped + " ".repeat(gap) + right, width); } @@ -90,6 +94,7 @@ function rightAlign(left: string, right: string, width: number): string { export class FleetList { private ui: FleetUICtx | undefined; private tuiHandle: TuiHandle | undefined; + private promptEditor: Component | undefined; private inputUnsub: (() => void) | undefined; private widgetRegistered = false; private timer: ReturnType | undefined; @@ -121,6 +126,7 @@ export class FleetList { this.ui = ui; this.widgetRegistered = false; this.tuiHandle = undefined; + this.promptEditor = undefined; this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data)); } @@ -144,6 +150,7 @@ export class FleetList { if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined); this.widgetRegistered = false; this.tuiHandle = undefined; + this.promptEditor = undefined; this.active = false; // Null last so a `viewerClose()` microtask above can't re-register the widget. this.ui = undefined; @@ -177,6 +184,7 @@ export class FleetList { FLEET_KEY, (tui, theme) => { this.tuiHandle = tui as TuiHandle; + this.capturePromptEditor(); return { render: (w: number) => this.renderBar(w, theme), invalidate: () => { @@ -242,6 +250,10 @@ export class FleetList { if (isKeyRelease(data)) return undefined; // While an overlay is open, let it own all input. if (this.viewerClose) return undefined; + if (!this.editorHasFocus()) { + if (this.active) this.deactivate(); + return undefined; + } if (!this.active) { // Activate: ↓ or ← at an empty prompt moves focus into the list. @@ -285,6 +297,33 @@ export class FleetList { return undefined; } + private editorHasFocus(): boolean { + this.capturePromptEditor(); + return this.tuiHandle?.getFocusedComponent() === this.promptEditor; + } + + private capturePromptEditor(): void { + if (this.promptEditor) return; + const focused = this.tuiHandle?.getFocusedComponent(); + if (!focused || !this.tuiHandle?.children.some((root) => this.contains(root, focused))) return; + const candidate = focused as Partial; + if ( + typeof candidate.render === "function" && + typeof candidate.invalidate === "function" && + typeof candidate.handleInput === "function" && + typeof candidate.getText === "function" && + typeof candidate.setText === "function" + ) { + this.promptEditor = focused; + } + } + + private contains(root: Component, target: Component): boolean { + if (root === target) return true; + const children = (root as Component & { children?: Component[] }).children; + return children?.some((child) => this.contains(child, target)) ?? false; + } + private deactivate(): void { this.active = false; this.selectedIndex = 0; @@ -327,11 +366,7 @@ export class FleetList { }, { overlay: true, - overlayOptions: { - anchor: "center", - width: "90%", - maxHeight: `${VIEWPORT_HEIGHT_PCT}%`, - }, + overlayOptions: DASHBOARD_OVERLAY_OPTIONS, }, ) .then( @@ -365,14 +400,13 @@ export class FleetList { // Clamp locally so a render between a roster shrink and the next update() // (e.g. on terminal resize) never loses the selection marker. const sel = Math.min(this.selectedIndex, agents.length); - - const hint = this.active - ? "↑↓ select · enter view · esc back" - : "esc to interrupt · ← for agents · ↓ to manage"; - const lines: string[] = []; - lines.push(truncateToWidth(` ${theme.fg("dim", hint)}`, width)); - lines.push(""); - lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width)); + const footer = this.active + ? "↑/↓ Select • Enter View • Esc Back" + : "↓/← Focus agents • Esc Interrupt"; + const lines: string[] = [ + truncateToWidth(theme.fg("accent", theme.bold("✦ Agents")), width), + truncateToWidth(`${this.marker(0, sel, theme)}${sel === 0 ? theme.bold("main") : "main"}`, width), + ]; // Window the agent rows so the selected one stays visible. const visible = Math.min(MAX_AGENT_ROWS, agents.length); @@ -385,12 +419,15 @@ export class FleetList { lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme)); } if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width)); + lines.push(truncateToWidth(theme.fg("dim", footer), width)); return lines; } - private bullet(rosterIndex: number, sel: number, theme: Theme): string { - return rosterIndex === sel ? theme.fg("accent", "⏺") : theme.fg("dim", "◯"); + private marker(rosterIndex: number, selectedIndex: number, theme: Theme): string { + return this.active && rosterIndex === selectedIndex + ? `${theme.fg("accent", "▸")} ` + : " "; } private renderAgentRow( @@ -400,13 +437,14 @@ export class FleetList { width: number, theme: Theme, ): string { - const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", record.type)} ${record.description}`; + const subject = rosterIndex === sel ? theme.bold(record.description) : record.description; + const left = `${this.marker(rosterIndex, sel, theme)}${theme.fg("muted", record.type)} ${subject}`; const tokens = record.lifetimeUsage.inputTokens + record.lifetimeUsage.outputTokens + record.lifetimeUsage.cacheWriteTokens; const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished - const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`); + const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} • ${formatFleetTokens(tokens)}`); return rightAlign(left, right, width); } } diff --git a/tests/fleet-list.test.ts b/tests/fleet-list.test.ts index 40ec84c..dc77d9c 100644 --- a/tests/fleet-list.test.ts +++ b/tests/fleet-list.test.ts @@ -1,15 +1,28 @@ -import { describe, expect, it, vi } from "vitest"; +import { + type EditorComponent, + stripTerminalSequences, + visibleWidth, +} from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { FleetList, formatFleetElapsed, formatFleetTokens } from "../src/tui/fleet-list.js"; import type { AgentManager } from "../src/core/agent-manager.js"; import type { AgentRecord } from "../src/shared/types.js"; import type { Theme } from "../src/tui/agent-widget.js"; - -const DOWN = "\x1b[B"; -const UP_ARROW = "\x1b[A"; -const ESC = "\x1b"; -const ENTER = "\r"; -/** Kitty key-release event for 'A' (codepoint 65, event type 3). */ -const KEY_RELEASE = "\x1b[65:3u"; +import { DASHBOARD_OVERLAY_OPTIONS } from "../src/tui/dashboard-style.js"; + +const LEGACY_DOWN = "\x1b[B"; +const LEGACY_UP = "\x1b[A"; +const LEGACY_LEFT = "\x1b[D"; +const LEGACY_ENTER = "\r"; +const LEGACY_ESCAPE = "\x1b"; +const KITTY_DOWN = "\x1b[1;1B"; +const KITTY_UP = "\x1b[1;1A"; +const KITTY_LEFT = "\x1b[1;1D"; +const CSI_U_ENTER = "\x1b[13u"; +const CSI_U_ESCAPE = "\x1b[27u"; +const KITTY_DOWN_RELEASE = "\x1b[1;1:3B"; + +type CustomOptions = { overlay?: boolean; overlayOptions?: unknown }; const makeRecord = (overrides: Partial = {}): AgentRecord => ({ id: "a1", @@ -21,254 +34,411 @@ const makeRecord = (overrides: Partial = {}): AgentRecord => ({ live: { activeTools: [], responseText: "" }, startedAt: Date.now() - 5000, lifetimeUsage: { inputTokens: 100, outputTokens: 50, cacheWriteTokens: 0 }, - session: {}, + session: { messages: [], subscribe: () => () => {} }, ...overrides, }); -const makeMockManager = (records: AgentRecord[] = []): AgentManager => ({ - listAgents: () => records, - abort: vi.fn(() => true), - steer: vi.fn(() => true), -} as unknown as AgentManager); - -const makeUICtx = (editorText = "") => ({ - setWidget: vi.fn(), - onTerminalInput: vi.fn(() => () => {}), - getEditorText: () => editorText, - notify: vi.fn(), - custom: vi.fn(), -}); - const makeTheme = (): Theme => ({ fg: (_color: string, text: string) => text, bold: (text: string) => text, }); -// ---- formatFleetElapsed ---- +const ansiTheme = (): Theme => { + const colors: Record = { accent: 35, dim: 2, muted: 36 }; + return { + fg: (color, text) => `\x1b[${colors[color] ?? 37}m${text}\x1b[0m`, + bold: (text) => `\x1b[1m${text}\x1b[0m`, + }; +}; + +const plain = stripTerminalSequences; + +const editorComponent = (): EditorComponent => ({ + render: () => [], + invalidate: () => {}, + handleInput: () => {}, + getText: () => "", + setText: () => {}, +}); -describe("formatFleetElapsed", () => { - it("rounds 5500ms to 6s", () => { - expect(formatFleetElapsed(5500)).toBe("6s"); - }); +const dialogComponent = () => ({ + render: () => [], + invalidate: () => {}, + handleInput: () => {}, +}); - it("formats 0ms as 0s", () => { - expect(formatFleetElapsed(0)).toBe("0s"); - }); +function harness( + records: AgentRecord[], + initialFocus: unknown = editorComponent(), + mountedComponent: unknown = initialFocus, +) { + let editorText = ""; + let focused = initialFocus; + let handler: ((data: string) => { consume?: boolean; data?: string } | undefined) | undefined; + let widgetFactory: ((tui: unknown, theme: Theme) => { render(width: number): string[] }) | undefined; + let overlayOpen = false; + let overlayDone: (() => void) | undefined; + let customOptions: CustomOptions | undefined; + let viewer: { handleInput(data: string): void } | undefined; + const unsubscribe = vi.fn(); + const manager = { + listAgents: vi.fn(() => records), + abort: vi.fn(() => true), + steer: vi.fn(() => true), + } as unknown as AgentManager; + const tui = { + children: [mountedComponent], + terminal: { rows: 40, columns: 80 }, + requestRender: vi.fn(), + getFocusedComponent: () => focused, + }; + const ui = { + setWidget: vi.fn((key: string, content: unknown) => { + if (key === "fleet" && typeof content === "function") { + widgetFactory = content as (tui: unknown, theme: Theme) => { render(width: number): string[] }; + } + }), + onTerminalInput: vi.fn((nextHandler: typeof handler) => { + handler = nextHandler; + return unsubscribe; + }), + getEditorText: () => editorText, + notify: vi.fn(), + custom: vi.fn((factory: (tui: unknown, theme: Theme, keybindings: unknown, done: (result: T) => void) => unknown, options?: CustomOptions) => { + customOptions = options; + overlayOpen = true; + return new Promise((resolve) => { + const done = (result: T) => { + overlayOpen = false; + resolve(result); + }; + viewer = factory(tui, makeTheme(), {}, done) as typeof viewer; + overlayDone = () => done(undefined as T); + }); + }), + }; + const fleet = new FleetList(manager); + fleet.setUICtx(ui as never); + fleet.update(); + if (widgetFactory) widgetFactory(tui, makeTheme()); + + return { + fleet, + manager, + records, + ui, + unsubscribe, + press: (data: string) => handler?.(data), + render: (width: number, theme = makeTheme()) => widgetFactory?.(tui, theme).render(width) ?? [], + setEditorText: (text: string) => { editorText = text; }, + setFocus: (component: unknown) => { focused = component; }, + closeOverlay: () => overlayDone?.(), + overlayOpened: () => overlayOpen, + customOptions: () => customOptions, + viewer: () => viewer, + }; +} + +afterEach(() => vi.useRealTimers()); + +// ---- format helpers ---- - it("formats 1000ms as 1s", () => { - expect(formatFleetElapsed(1000)).toBe("1s"); - }); +describe("formatFleetElapsed", () => { + it("rounds 5500ms to 6s", () => expect(formatFleetElapsed(5500)).toBe("6s")); + it("formats 0ms as 0s", () => expect(formatFleetElapsed(0)).toBe("0s")); + it("formats 1000ms as 1s", () => expect(formatFleetElapsed(1000)).toBe("1s")); }); -// ---- formatFleetTokens ---- - describe("formatFleetTokens", () => { - it("formats 0 as '↓ 0 tokens'", () => { - expect(formatFleetTokens(0)).toBe("↓ 0 tokens"); - }); + it("formats 0 as '↓ 0 tokens'", () => expect(formatFleetTokens(0)).toBe("↓ 0 tokens")); + it("formats 500 as '↓ 500 tokens'", () => expect(formatFleetTokens(500)).toBe("↓ 500 tokens")); + it("formats 13100 as '↓ 13.1k tokens'", () => expect(formatFleetTokens(13100)).toBe("↓ 13.1k tokens")); + it("formats 1_200_000 as '↓ 1.2M tokens'", () => expect(formatFleetTokens(1_200_000)).toBe("↓ 1.2M tokens")); +}); - it("formats 500 as '↓ 500 tokens'", () => { - expect(formatFleetTokens(500)).toBe("↓ 500 tokens"); +// ---- FleetList input boundary ---- + +describe("FleetList terminal input", () => { + it.each([LEGACY_DOWN, KITTY_DOWN, LEGACY_LEFT, KITTY_LEFT])("activates from an empty focused editor for %j", (key) => { + const h = harness([makeRecord()]); + expect(h.press(key)).toEqual({ consume: true }); + h.fleet.dispose(); }); - it("formats 13100 as '↓ 13.1k tokens'", () => { - expect(formatFleetTokens(13100)).toBe("↓ 13.1k tokens"); + it.each([LEGACY_DOWN, KITTY_DOWN])("navigates with legacy and Kitty arrows after %j activation", (activate) => { + const h = harness([makeRecord()]); + expect(h.press(activate)).toEqual({ consume: true }); + expect(h.press(KITTY_DOWN)).toEqual({ consume: true }); + expect(h.press(LEGACY_UP)).toEqual({ consume: true }); + expect(h.press(KITTY_UP)).toEqual({ consume: true }); + expect(h.press(LEGACY_UP)).toBeUndefined(); + h.fleet.dispose(); }); - it("formats 1_200_000 as '↓ 1.2M tokens'", () => { - expect(formatFleetTokens(1_200_000)).toBe("↓ 1.2M tokens"); + it.each([LEGACY_ESCAPE, CSI_U_ESCAPE])("deactivates for Escape encoding %j", (escapeSequence) => { + const h = harness([makeRecord()]); + h.press(LEGACY_DOWN); + expect(h.press(escapeSequence)).toEqual({ consume: true }); + expect(h.press(LEGACY_UP)).toBeUndefined(); + h.fleet.dispose(); }); -}); -// ---- FleetList key handling ---- + it.each([LEGACY_ENTER, CSI_U_ENTER])("Enter encoding %j deactivates on main without opening an overlay", (enter) => { + const h = harness([makeRecord()]); + h.press(LEGACY_DOWN); + expect(h.press(enter)).toEqual({ consume: true }); + expect(h.overlayOpened()).toBe(false); + expect(h.press(LEGACY_UP)).toBeUndefined(); + h.fleet.dispose(); + }); -describe("FleetList handleKey", () => { - it("returns undefined when no agents", () => { - const fleet = new FleetList(makeMockManager([])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - expect(fleet.handleKey(DOWN)).toBeUndefined(); - fleet.dispose(); + it.each([LEGACY_ENTER, CSI_U_ENTER])("Enter encoding %j opens the selected agent", (enter) => { + const h = harness([makeRecord()]); + h.press(LEGACY_DOWN); + h.press(KITTY_DOWN); + expect(h.press(enter)).toEqual({ consume: true }); + expect(h.overlayOpened()).toBe(true); + h.closeOverlay(); + h.fleet.dispose(); }); - it("returns undefined when editor has text (even with agents)", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx("some text"); - fleet.setUICtx(ui); - expect(fleet.handleKey(DOWN)).toBeUndefined(); - fleet.dispose(); + it("passes a non-navigation key through after deactivating", () => { + const h = harness([makeRecord()]); + h.press(LEGACY_DOWN); + expect(h.press("x")).toBeUndefined(); + expect(h.press(LEGACY_UP)).toBeUndefined(); + h.fleet.dispose(); }); - it("down arrow at empty editor with agents activates list and returns {consume:true}", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - const result = fleet.handleKey(DOWN); - expect(result).toEqual({ consume: true }); - fleet.dispose(); + it("does not activate when the editor has text", () => { + const h = harness([makeRecord()]); + h.setEditorText("some text"); + expect(h.press(LEGACY_DOWN)).toBeUndefined(); + h.fleet.dispose(); }); - it("up arrow when inactive returns undefined", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - expect(fleet.handleKey(UP_ARROW)).toBeUndefined(); - fleet.dispose(); + it("does not steal activation keys from a focused dialog", () => { + const h = harness([makeRecord()]); + h.setFocus(dialogComponent()); + expect(h.press(KITTY_DOWN)).toBeUndefined(); + h.fleet.dispose(); }); - it("non-activator key when inactive returns undefined", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - expect(fleet.handleKey("x")).toBeUndefined(); - fleet.dispose(); + it("does not steal activation keys from another editor component", () => { + const h = harness([makeRecord()]); + h.setFocus(editorComponent()); + expect(h.press(KITTY_DOWN)).toBeUndefined(); + h.fleet.dispose(); }); - it("down arrow when active navigates and returns {consume:true}", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - fleet.handleKey(DOWN); // activate (selectedIndex=0) - const result = fleet.handleKey(DOWN); // navigate down - expect(result).toEqual({ consume: true }); - fleet.dispose(); + it("learns the prompt editor after mounting behind an overlay", () => { + const promptEditor = editorComponent(); + const h = harness([makeRecord()], editorComponent(), promptEditor); + expect(h.press(KITTY_DOWN)).toBeUndefined(); + h.setFocus(promptEditor); + expect(h.press(KITTY_DOWN)).toEqual({ consume: true }); + h.fleet.dispose(); }); - it("escape when active deactivates and returns {consume:true}", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - fleet.handleKey(DOWN); // activate - const result = fleet.handleKey(ESC); - expect(result).toEqual({ consume: true }); - // now inactive: up arrow should be undefined - expect(fleet.handleKey(UP_ARROW)).toBeUndefined(); - fleet.dispose(); + it("deactivates and passes through when focus leaves the editor", () => { + const h = harness([makeRecord()]); + expect(h.press(LEGACY_DOWN)).toEqual({ consume: true }); + h.setFocus(dialogComponent()); + expect(h.press(LEGACY_DOWN)).toBeUndefined(); + h.setFocus(editorComponent()); + expect(h.press(LEGACY_UP)).toBeUndefined(); + h.fleet.dispose(); }); - it("key-release event returns undefined (filtered out)", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - fleet.handleKey(DOWN); // activate - expect(fleet.handleKey(KEY_RELEASE)).toBeUndefined(); - fleet.dispose(); + it("does not consume input while focused component is unknown", () => { + const h = harness([makeRecord()]); + h.setFocus(null); + expect(h.press(LEGACY_DOWN)).toBeUndefined(); + h.fleet.dispose(); }); - it("update() calls without throwing after agent finishes", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - expect(() => fleet.update()).not.toThrow(); - fleet.dispose(); + it("ignores the release half of a Down key", () => { + const h = harness([makeRecord(), makeRecord({ id: "a2" })]); + h.press(KITTY_DOWN); + expect(h.press(KITTY_DOWN_RELEASE)).toBeUndefined(); + h.press(CSI_U_ENTER); + expect(h.overlayOpened()).toBe(false); + h.fleet.dispose(); }); +}); - it("setEnabled(false) deactivates the list", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - fleet.handleKey(DOWN); // activate - fleet.setEnabled(false); - // disabled: handleKey returns undefined for any key - expect(fleet.handleKey(DOWN)).toBeUndefined(); - fleet.dispose(); +// ---- FleetList lifecycle and rendering ---- + +describe("FleetList lifecycle", () => { + it("clears its interval when disabled or empty and restarts it when re-enabled", () => { + vi.useFakeTimers(); + const h = harness([makeRecord()]); + expect(vi.getTimerCount()).toBe(1); + h.fleet.setEnabled(false); + expect(vi.getTimerCount()).toBe(0); + h.fleet.setEnabled(true); + expect(vi.getTimerCount()).toBe(1); + h.records.length = 0; + h.fleet.update(); + expect(vi.getTimerCount()).toBe(0); + h.fleet.dispose(); }); - it("dispose() runs without throwing", () => { - const fleet = new FleetList(makeMockManager([])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - expect(() => fleet.dispose()).not.toThrow(); + it("dispose unsubscribes, closes an open viewer, clears its widget, and timer", () => { + vi.useFakeTimers(); + const h = harness([makeRecord()]); + h.press(LEGACY_DOWN); + h.press(LEGACY_DOWN); + h.press(LEGACY_ENTER); + expect(h.overlayOpened()).toBe(true); + h.fleet.dispose(); + expect(h.unsubscribe).toHaveBeenCalledOnce(); + expect(h.overlayOpened()).toBe(false); + expect(h.ui.setWidget).toHaveBeenCalledWith("fleet", undefined); + expect(vi.getTimerCount()).toBe(0); }); }); -// ---- FleetList rendering ---- - describe("FleetList rendering", () => { - it("update() with a running agent calls setWidget with key 'fleet'", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - fleet.update(); - expect(ui.setWidget).toHaveBeenCalledWith( - "fleet", - expect.any(Function), - { placement: "belowEditor" }, + it("renders the unboxed inactive and active fleet presentation", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T00:00:00Z")); + const h = harness([makeRecord()]); + const inactive = h.render(80); + expect(plain(inactive[0])).toBe("✦ Agents"); + expect(plain(inactive.at(-1) ?? "")).toBe("↓/← Focus agents • Esc Interrupt"); + expect(plain(inactive.join("\n"))).not.toContain("▸"); + + h.press(LEGACY_DOWN); + h.press(LEGACY_DOWN); + const active = h.render(80); + expect(plain(active[0])).toBe("✦ Agents"); + expect(plain(active.find((line) => line.includes("coder")) ?? "")).toMatch( + /^▸ coder {2}Fix bug\s+5s • ↓ 150 tokens$/, ); - fleet.dispose(); + expect(plain(active.at(-1) ?? "")).toBe("↑/↓ Select • Enter View • Esc Back"); + h.fleet.dispose(); }); - it("rendered output includes 'main' and agent type row", () => { - const record = makeRecord(); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - fleet.setUICtx(ui); - fleet.update(); - - // Extract the widget factory from the setWidget call - const calls = ui.setWidget.mock.calls as Array< - [string, ((tui: unknown, theme: Theme) => { render(width: number): string[] }) | undefined, unknown] - >; - const factory = calls.find(([key]) => key === "fleet")?.[1]; - expect(factory).toBeDefined(); - - const theme = makeTheme(); - if (!factory) throw new Error("factory should be defined"); - const widget = factory({ requestRender: vi.fn() }, theme); - const lines = widget.render(80); - - expect(lines.some((l) => l.includes("main"))).toBe(true); - expect(lines.some((l) => l.includes("coder"))).toBe(true); - expect(lines.some((l) => l.includes("↓ 150 tokens"))).toBe(true); - fleet.dispose(); + it("uses semantic styles without fleet box glyphs", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T00:00:00Z")); + const h = harness([makeRecord(), makeRecord({ id: "a2", type: "reviewer" })]); + const inactive = h.render(80, ansiTheme()); + expect(inactive[0]).toContain("\x1b[35m\x1b[1m✦ Agents"); + expect(inactive.at(-1)?.startsWith("\x1b[2m")).toBe(true); + + h.press(LEGACY_DOWN); + h.press(LEGACY_DOWN); + const active = h.render(80, ansiTheme()); + const selected = active.find((line) => plain(line).includes("coder")) ?? ""; + expect(selected.startsWith("\x1b[35m▸")).toBe(true); + expect(selected).toContain("\x1b[2m5s • ↓ 150 tokens"); + expect(active.at(-1)?.startsWith("\x1b[2m")).toBe(true); + expect(plain(active.join("\n"))).not.toMatch(/[┏━┃┗╭─│╰]/); + h.fleet.dispose(); + + const overflow = harness(Array.from({ length: 6 }, (_, index) => makeRecord({ id: `o${index}` }))); + expect(overflow.render(80, ansiTheme()).find((line) => plain(line).trim() === "↓ 1 more")).toContain( + "\x1b[2m↓ 1 more", + ); + overflow.fleet.dispose(); }); - it("passes the live record to the conversation viewer", () => { - const session = makeRecord().session as NonNullable; - Object.assign(session, { - messages: [{ role: "user", content: "hello", timestamp: Date.now() }], - subscribe: vi.fn(() => () => {}), - }); - const record = makeRecord({ - session, - toolUses: 2, - live: { activeTools: ["read", "read"], responseText: "" }, + it("uses dashboard overlay options and targets the selected live agent", () => { + const record = makeRecord(); + const h = harness([record]); + h.press(LEGACY_DOWN); + h.press(LEGACY_DOWN); + h.press(LEGACY_ENTER); + expect(h.customOptions()).toEqual({ + overlay: true, + overlayOptions: DASHBOARD_OVERLAY_OPTIONS, }); - const fleet = new FleetList(makeMockManager([record])); - const ui = makeUICtx(); - ui.custom.mockReturnValue(Promise.resolve(undefined)); - fleet.setUICtx(ui); - fleet.handleKey(DOWN); - fleet.handleKey(DOWN); - fleet.handleKey(ENTER); - - const factory = ui.custom.mock.calls[0]?.[0] as unknown as ( - tui: unknown, - theme: Theme, - keybindings: unknown, - done: (result: undefined) => void, - ) => { render(width: number): string[] }; - const viewer = factory( - { terminal: { rows: 40, columns: 80 }, requestRender: vi.fn() }, - makeTheme(), - undefined, - vi.fn(), + const viewer = h.viewer(); + if (!viewer) throw new Error("Expected viewer"); + viewer.handleInput(LEGACY_ENTER); + viewer.handleInput("message"); + viewer.handleInput(LEGACY_ENTER); + expect(h.manager.steer).toHaveBeenCalledOnce(); + expect(h.manager.steer).toHaveBeenCalledWith(record.id, "message"); + viewer.handleInput("x"); + viewer.handleInput("x"); + expect(h.manager.abort).toHaveBeenCalledOnce(); + expect(h.manager.abort).toHaveBeenCalledWith(record.id); + h.closeOverlay(); + h.fleet.dispose(); + }); + + it("keeps a five-agent window separate from main and shows overflow above and below", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T00:00:00Z")); + const records = Array.from({ length: 8 }, (_, index) => + makeRecord({ id: `a${index}`, type: `agent-${index}`, startedAt: Date.now() - (8000 - index) }), ); - const output = viewer.render(80).join("\n"); + const h = harness(records); + let lines = h.render(80).map(plain); + expect(lines.filter((line) => line.includes("agent-")).length).toBe(5); + expect(lines.some((line) => line.trim() === "↓ 3 more")).toBe(true); + expect(lines.some((line) => line.includes("main"))).toBe(true); + + h.press(LEGACY_DOWN); + for (let index = 0; index < 8; index++) h.press(LEGACY_DOWN); + lines = h.render(80).map(plain); + expect(lines.filter((line) => line.includes("agent-")).length).toBe(5); + expect(lines.some((line) => line.trim() === "↑ 3 more")).toBe(true); + expect(lines.some((line) => line.startsWith("▸ agent-7"))).toBe(true); + h.fleet.dispose(); + }); - expect(output).toContain("2 tools"); - expect(output).toContain("150 token"); - expect(output).toContain("reading 2 files…"); - fleet.dispose(); + it("filters, orders, and lingers roster records", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T00:00:00Z")); + const h = harness([ + makeRecord({ id: "late", type: "late", startedAt: Date.now() - 1000 }), + makeRecord({ id: "hidden", type: "hidden", session: undefined }), + makeRecord({ id: "early", type: "early", startedAt: Date.now() - 3000 }), + makeRecord({ id: "linger", type: "linger", status: "completed", completedAt: Date.now() - 3999 }), + makeRecord({ id: "gone", type: "gone", status: "completed", completedAt: Date.now() - 4000 }), + ]); + const output = h.render(80).map(plain).join("\n"); + expect(output).not.toContain("hidden"); + expect(output).toContain("linger"); + expect(output).not.toContain("gone"); + expect(output.indexOf("early")).toBeLessThan(output.indexOf("late")); + h.fleet.dispose(); + }); + + it("reselects a viewed agent by id when an earlier row disappears", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T00:00:00Z")); + const first = makeRecord({ id: "first", type: "first", startedAt: Date.now() - 2000 }); + const selected = makeRecord({ id: "selected", type: "selected", startedAt: Date.now() - 1000 }); + const h = harness([first, selected]); + h.press(LEGACY_DOWN); + h.press(LEGACY_DOWN); + h.press(LEGACY_DOWN); + h.press(LEGACY_ENTER); + first.status = "completed"; + first.completedAt = Date.now() - 4000; + h.closeOverlay(); + await Promise.resolve(); + expect(h.render(80).map(plain).find((line) => line.includes("selected"))).toMatch(/^▸ selected/); + h.fleet.dispose(); + }); + + it("caps ANSI output, normalizes dynamic CR/LF text, and retains metadata when it fits", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-28T00:00:00Z")); + const h = harness([makeRecord({ type: "coder\r\nlong", description: "Fix\n\rvery long bug" })]); + for (const width of [0, 1, 4, 8, 20, 40, 80, 200]) { + const lines = h.render(width, ansiTheme()); + expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); + expect(lines.every((line) => !/[\r\n]/.test(line))).toBe(true); + } + expect(plain(h.render(20, ansiTheme()).find((line) => plain(line).includes("5s")) ?? "")).toContain( + "5s • ↓ 150 tokens", + ); + h.fleet.dispose(); }); });