diff --git a/CHANGELOG.md b/CHANGELOG.md index f288b37..b013415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Steer a running agent from the conversation viewer.** The live conversation overlay (FleetView's `Enter`, or `/agents → Running agents`) now lets you redirect an agent without leaving the view: press `Enter` to open an inline composer, type a message, `Enter` to send — `Esc` or an empty submit just returns. The message is delivered through the same path as the `steer_subagent` tool (`AgentManager.steer()` → `session.steer`, or queued onto `pendingSteers` if the session isn't ready yet), so it appears as a user message and redirects the agent after its current tool execution; feedback is the message showing up in the live transcript you're already watching. The affordance is offered only while the agent is still running/queued (mirrors the `x`/stop affordance), and the viewer stays modal — every existing shortcut (`x`/`x` stop, arrows/`j`/`k` scroll, `q` close) is untouched, and `Enter` was previously inert here so nothing is overridden. The idle footer was reorganized to actions-left / navigation-right so the full scroll-key hint (`↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close`) stays fully visible down to 80-column terminals; the `N lines · %` readout returns on the left whenever there's spare width. - **Forgiving model resolution for agent `model:` pins.** `resolveModel` now tolerates cosmetic id variations and falls back across providers, so a qualified or date-pinned config resolves widely instead of silently dropping to the parent model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`); a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id); and a `provider/modelId` that isn't available under the named provider retries the bare id against every provider (the named provider is still preferred when present). An exact match still wins over a tolerant one, so dated snapshots aren't conflated — the precedence is exact → fuzzy-under-named-provider → same model under any provider → unavailable. - **`/agents → Agent types` shows each agent's full description and what its model resolves to.** The list now renders with `SettingsList` (like the Settings menu) instead of a flat selector: the highlighted agent's full description shows on its own line below the list, so long descriptions no longer wrap and push the agent names out of alignment. The model column shows the configured model, flags it `(unavailable, fallback: inherit)` when it can't be resolved against the registry (it would silently inherit the parent model at runtime), and surfaces the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured. - **`widgetMode` setting — control what the above-editor widget shows: `all` / `background` / `off`** ([#117](https://github.com/tintinweb/pi-subagents/pull/117) — thanks [@Alan-TheGentleman](https://github.com/Alan-TheGentleman); fixes [#118](https://github.com/tintinweb/pi-subagents/issues/118)). Foreground agents already render inline as the `Agent` tool result, so also listing them in the persistent widget double-rendered the same run (most visible in tmux/zellij). `widgetMode` (via `/agents → Settings → Widget`, or `subagents.json`) selects the widget's contents: `all` shows every agent (the previous behavior), `background` shows background/queued/scheduled/RPC runs but hides foreground, and `off` hides the widget entirely (agents still appear inline and in FleetView). Applied live — toggling refreshes immediately. Filtering keys off a new tri-state `AgentRecord.isBackground` captured at spawn (`true` = background, `false` = foreground, `undefined` = undeclared, e.g. a cross-extension RPC spawn), independent of the UI-only `invocation` snapshot — so scheduler- and RPC-spawned background agents stay visible instead of vanishing; only runs *known* to be foreground are dropped. The running-status line was also refactored from one multiline `Text` into two component rows, so rapid partial updates replace cleanly instead of leaving stale rows behind in terminal multiplexers — identical on-screen output, no more ghost lines. diff --git a/README.md b/README.md index e73bebc..9b2d2a2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543 - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications) - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off` - **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view` -- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Stop a still-running agent from here by pressing `x` (then `x` again to confirm) — works for background agents too +- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too - **Custom agent types** — define agents in `.pi/agents/.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions - **Mid-run steering** — inject messages into running agents to redirect their work without restarting - **Session resume** — pick up where an agent left off, preserving full conversation context @@ -123,7 +123,7 @@ While subagents are running, a Claude Code-style navigable list renders **below* ↓ 3 more ``` -The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `⏺`, the rest `◯`. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`. +The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `⏺`, the rest `◯`. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. Inside the overlay, press `Enter` to steer the running agent — type a message and `Enter` to send it (`Esc` or an empty submit returns), and it redirects the agent the same way the `steer_subagent` tool does. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`. Individual agent results render Claude Code-style in the conversation: @@ -317,7 +317,7 @@ Create new agent ← manual wizard or AI-generated Settings ← max concurrency, max turns, grace turns, join mode ``` -- **Running agents** — select one to open its live conversation viewer. While it's still running, press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion. +- **Running agents** — select one to open its live conversation viewer. While it's still running, press `Enter` to open the steering composer, then `Enter` again to send a message that redirects the agent (same mechanism as the `steer_subagent` tool; `Esc` or an empty submit returns), or press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion. - **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Each row shows the agent's model, and the highlighted agent's full description appears below the list. The model column flags `(unavailable, fallback: inherit)` when a configured model can't be resolved (it would silently inherit the parent model), and shows `(→ provider/id)` when it resolves to a different provider or version than configured. Select an agent to manage it: - **Default agents** (no override): Eject (export as `.md`), Disable - **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete diff --git a/src/agent-manager.ts b/src/agent-manager.ts index ad49408..21ba658 100644 --- a/src/agent-manager.ts +++ b/src/agent-manager.ts @@ -472,6 +472,27 @@ export class AgentManager { return record; } + /** + * Send a steering message to an agent from the UI (mirrors the steer_subagent + * tool). A live session delivers it now — it interrupts the agent after its + * current tool execution and appears as a user message. If the session isn't + * ready yet, the message is queued on `pendingSteers` and flushed when the + * session is created. Returns false if the agent can't accept steering + * (unknown id, or no longer running/queued). + */ + steer(id: string, message: string): boolean { + const record = this.agents.get(id); + if (!record) return false; + if (record.status !== "running" && record.status !== "queued") return false; + if (record.session) { + record.session.steer(message).catch(() => {}); + } else { + if (!record.pendingSteers) record.pendingSteers = []; + record.pendingSteers.push(message); + } + return true; + } + getRecord(id: string): AgentRecord | undefined { return this.agents.get(id); } diff --git a/src/index.ts b/src/index.ts index a37f2d7..9e7b394 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1664,7 +1664,7 @@ Terse command-style prompts produce shallow, generic work. if (manager.abort(record.id)) { ctx.ui.notify(`Stopped "${record.description}".`, "info"); } - }, keybindings); + }, keybindings, (message: string) => manager.steer(record.id, message)); }, { overlay: true, diff --git a/src/ui/conversation-viewer.ts b/src/ui/conversation-viewer.ts index 6caee35..558886d 100644 --- a/src/ui/conversation-viewer.ts +++ b/src/ui/conversation-viewer.ts @@ -6,7 +6,7 @@ */ import type { AgentSession } from "@earendil-works/pi-coding-agent"; -import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { extractText } from "../context.js"; import type { AgentRecord } from "../types.js"; import { getLifetimeTotal, getSessionContextPercent } from "../usage.js"; @@ -29,6 +29,8 @@ export class ConversationViewer implements Component { /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */ private stopArmed = false; private keys: ViewerKeys; + /** Steering composer — present while the user is typing a message to the agent. */ + private composer: Input | undefined; constructor( private tui: TUI, @@ -41,6 +43,8 @@ export class ConversationViewer implements Component { private onStop?: () => void, /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */ keybindings?: ViewerKeybindings, + /** Send a steering message to the agent. Omitted → no compose affordance. */ + private onSteer?: (message: string) => void, ) { this.keys = createViewerKeys(keybindings); this.unsubscribe = session.subscribe(() => { @@ -50,12 +54,29 @@ export class ConversationViewer implements Component { } handleInput(data: string): void { + // While composing a steer message, the input owns all keys (Enter sends, + // Esc cancels — both wired in openComposer()). Editing keys flow through. + if (this.composer) { + this.composer.handleInput(data); + this.tui.requestRender(); + return; + } + if (matchesKey(data, "escape") || matchesKey(data, "q")) { this.closed = true; this.done(undefined); return; } + // Enter opens the steering composer (only while the agent can still be + // steered) — then type + Enter sends, Esc or an empty submit returns. When + // not steerable, fall through so the key still disarms a pending stop. + if (matchesKey(data, "enter") && this.canSteer()) { + this.stopArmed = false; + this.openComposer(); + return; + } + // Stop/abort the agent (only while it can still be stopped). Two-press: // first "x" arms, second confirms — any other key disarms. if (matchesKey(data, "x")) { @@ -162,19 +183,39 @@ export class ConversationViewer implements Component { // Footer lines.push(hrMid); - const scrollPct = contentLines.length <= viewportHeight - ? "100%" - : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`; - const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`); - const scrollHint = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close"); - // Stop hint goes first in the right group so it survives right-edge - // truncation on narrow terminals (the scroll hint is the expendable part). - const footerRight = this.isStoppable() - ? (this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")) + - th.fg("dim", " · ") + scrollHint - : scrollHint; - const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight)); - lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight)); + if (this.composer) { + // Composer row: the Input renders its own `> ` prompt and cursor. + lines.push(row(this.composer.render(innerW)[0] ?? "")); + const composeHint = th.fg("dim", "Enter send · Esc cancel"); + const composeLeft = th.fg("accent", "✎ steer"); + const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint)); + lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint)); + } else { + // Actions on the left, navigation on the right. The scroll hint keeps its + // full key list so the less-obvious bindings stay discoverable; it leads + // the right group so "Esc close" is the only part that truncates first. + const sep = th.fg("dim", " · "); + const actions: string[] = []; + if (this.canSteer()) actions.push(th.fg("dim", "Enter steer")); + if (this.isStoppable()) { + actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")); + } + const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close"); + + // Prepend the line-count/scroll-% readout only when there's spare width — + // it's the first thing dropped so it never crowds out the hints. + const scrollPct = contentLines.length <= viewportHeight + ? "100%" + : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`; + const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`); + const withCount = [count, ...actions].join(sep); + const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW + ? withCount + : actions.join(sep); + + const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight)); + lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight)); + } lines.push(hrBot); return lines; @@ -185,6 +226,29 @@ export class ConversationViewer implements Component { return !!this.onStop && (this.record.status === "running" || this.record.status === "queued"); } + /** Steerable only when a steer handler exists and the agent is still active. */ + private canSteer(): boolean { + return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued"); + } + + /** Open the inline steering composer and route subsequent input to it. */ + private openComposer(): void { + const input = new Input(); + input.focused = true; + input.onSubmit = (value: string) => { + const message = value.trim(); + this.composer = undefined; + if (message) this.onSteer?.(message); + this.tui.requestRender(); + }; + input.onEscape = () => { + this.composer = undefined; + this.tui.requestRender(); + }; + this.composer = input; + this.tui.requestRender(); + } + invalidate(): void { /* no cached state to clear */ } dispose(): void { @@ -205,7 +269,8 @@ export class ConversationViewer implements Component { } private chromeLines(): number { - return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0); + // The composer adds one row above the footer hint while it's open. + return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0); } private invocationLine(): string | undefined { diff --git a/src/ui/fleet-list.ts b/src/ui/fleet-list.ts index f46b590..3c4ecb3 100644 --- a/src/ui/fleet-list.ts +++ b/src/ui/fleet-list.ts @@ -288,6 +288,7 @@ export class FleetList { if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info"); }, keybindings, + (message: string) => this.manager.steer(record.id, message), ); }, { diff --git a/test/agent-manager.test.ts b/test/agent-manager.test.ts index 5216ff9..9989026 100644 --- a/test/agent-manager.test.ts +++ b/test/agent-manager.test.ts @@ -741,6 +741,53 @@ describe("AgentManager — abort() state machine", () => { // Regression for #44: ESC during a foreground Agent call must propagate to // the child. Pi delivers parent abort via AbortSignal; the manager wires the // signal's "abort" event to this.abort(id). +describe("AgentManager — steer()", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("returns false for an unknown id", () => { + manager = new AgentManager(); + expect(manager.steer("nope", "hi")).toBe(false); + }); + + it("delivers to a live session via session.steer()", () => { + manager = new AgentManager(); + const steer = vi.fn(() => Promise.resolve()); + let captured: ((s: any) => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx, _type, _prompt, opts) => { + captured = (opts as any)?.onSessionCreated; + return new Promise(() => {}); + }); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "r", isBackground: true }); + // Simulate the session becoming ready. + captured?.({ steer, dispose: vi.fn() }); + + expect(manager.steer(id, "go left")).toBe(true); + expect(steer).toHaveBeenCalledWith("go left"); + }); + + it("queues onto pendingSteers when the session isn't ready yet", () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "r", isBackground: true }); + const record = manager.getRecord(id)!; + record.session = undefined; // not ready + + expect(manager.steer(id, "first")).toBe(true); + expect(manager.steer(id, "second")).toBe(true); + expect(record.pendingSteers).toEqual(["first", "second"]); + }); + + it("refuses to steer an agent that is no longer running", async () => { + manager = new AgentManager(); + resolvedRun(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: false }); + await manager.getRecord(id)?.promise; + expect(manager.getRecord(id)?.status).toBe("completed"); + expect(manager.steer(id, "too late")).toBe(false); + }); +}); + describe("AgentManager — parent abort signal forwarding (#44)", () => { let manager: AgentManager; afterEach(() => manager?.dispose()); diff --git a/test/conversation-viewer.test.ts b/test/conversation-viewer.test.ts index 035adad..033878b 100644 --- a/test/conversation-viewer.test.ts +++ b/test/conversation-viewer.test.ts @@ -380,4 +380,94 @@ describe("ConversationViewer", () => { expect(() => { viewer.handleInput("x"); viewer.handleInput("x"); }).not.toThrow(); }); }); + + describe("steer composer", () => { + const W = 80; + + function makeViewer(opts: { status?: AgentRecord["status"]; onSteer?: (m: string) => void } = {}) { + const onSteer = opts.onSteer ?? vi.fn(); + const tui = mockTui(30, W); + const viewer = new ConversationViewer( + tui, mockSession(), mockRecord({ status: opts.status ?? "running" }), + undefined, ansiTheme(), vi.fn(), undefined, undefined, onSteer, + ); + return { viewer, tui, onSteer }; + } + + it("offers the steer affordance for a running agent and opens on Enter", () => { + const { viewer } = makeViewer(); + expect(viewer.render(W).join("\n")).toContain("Enter steer"); + + viewer.handleInput("\r"); // Enter + // Composer is shown (its prompt + send/cancel hint), idle footer is gone. + const out = viewer.render(W).join("\n"); + expect(out).toContain("Enter send · Esc cancel"); + expect(out).not.toContain("Enter steer"); + }); + + it("typing then Enter sends the trimmed message and closes the composer", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("\r"); // open composer + for (const ch of " hello ") viewer.handleInput(ch); + viewer.handleInput("\r"); // send + + expect(onSteer).toHaveBeenCalledWith("hello"); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed + }); + + it("Esc cancels the composer without sending", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("\r"); // open composer + for (const ch of "draft") viewer.handleInput(ch); + viewer.handleInput("\x1b"); // Esc + + expect(onSteer).not.toHaveBeenCalled(); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); + }); + + it("an empty submit just returns (like Esc), without calling onSteer", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("\r"); // open composer + viewer.handleInput("\r"); // empty submit + expect(onSteer).not.toHaveBeenCalled(); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed + }); + + it("scroll keys are inert while composing (input owns them)", () => { + const { viewer } = makeViewer(); + viewer.handleInput("\r"); // open composer + // 'j' would normally scroll, but here it types into the composer. + viewer.handleInput("j"); + expect(viewer.render(W).join("\n")).toContain("Enter send · Esc cancel"); + }); + + it("no steer affordance once the agent is no longer running", () => { + const { viewer, onSteer } = makeViewer({ status: "completed" }); + expect(viewer.render(W).join("\n")).not.toContain("Enter steer"); + viewer.handleInput("\r"); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); + expect(onSteer).not.toHaveBeenCalled(); + }); + + it("no steer affordance when no onSteer handler is provided", () => { + const viewer = new ConversationViewer( + mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), + ); + expect(viewer.render(W).join("\n")).not.toContain("Enter steer"); + expect(() => viewer.handleInput("\r")).not.toThrow(); + }); + + it("composer rows never exceed width", () => { + for (const w of [40, 80, 120]) { + const tui = mockTui(30, w); + const viewer = new ConversationViewer( + tui, mockSession(), mockRecord({ status: "running" }), + undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(), + ); + viewer.handleInput("\r"); // open composer + for (const ch of "x".repeat(200)) viewer.handleInput(ch); + assertAllLinesFit(viewer.render(w), w); + } + }); + }); }); diff --git a/test/fleet-list.test.ts b/test/fleet-list.test.ts index 48ef7f2..a5b1396 100644 --- a/test/fleet-list.test.ts +++ b/test/fleet-list.test.ts @@ -40,12 +40,16 @@ function fakeManager(agents: AgentRecord[]): AgentManager { return { listAgents: () => agents, abort: () => true, + steer: vi.fn(() => true), } as unknown as AgentManager; } interface Harness { fleet: FleetList; ui: FleetUICtx; + manager: AgentManager; + /** The overlay component (a real ConversationViewer) once one is opened. */ + overlayComponent: () => { handleInput(data: string): void } | undefined; /** Feed a key to the registered input handler; returns the consume result. */ press: (data: string) => { consume?: boolean } | undefined; /** Render the currently-registered below-editor widget at the given width. */ @@ -66,6 +70,7 @@ function harness(agents: AgentRecord[]): Harness { let opened = false; let closed = false; let overlayDone: ((r: undefined) => void) | undefined; + let overlayComponent: { handleInput(data: string): void } | undefined; const fakeTui = { requestRender: () => {}, terminal: { columns: 120, rows: 40 } }; const ui: FleetUICtx = { @@ -78,19 +83,23 @@ function harness(agents: AgentRecord[]): Harness { return new Promise((resolve) => { const done = (r: undefined) => { closed = true; overlayDone = undefined; resolve(r); }; overlayDone = done; - // Construct the overlay component so the controller wires viewerClose. - factory(fakeTui, theme, undefined, done); + // Construct the overlay component so the controller wires viewerClose, + // and keep it so tests can drive the real ConversationViewer's input. + overlayComponent = factory(fakeTui, theme, undefined, done); }); }) as FleetUICtx["custom"], }; - const fleet = new FleetList(fakeManager(agents), new Map()); + const manager = fakeManager(agents); + const fleet = new FleetList(manager, new Map()); fleet.setUICtx(ui); fleet.update(); return { fleet, ui, + manager, + overlayComponent: () => overlayComponent, press: (data) => inputHandler?.(data), render: (width = 120) => (widgetFactory ? widgetFactory(fakeTui, theme).render(width) : []), setEditorText: (t) => { editorText = t; }, @@ -325,6 +334,22 @@ describe("FleetList overlay lifecycle", () => { expect(h.render().find(l => l.includes("three"))).toContain("◯"); }); + it("wires the viewer's steer composer to manager.steer with the agent id", () => { + const agents = [makeRecord({ id: "live", description: "the one" })]; + const h = harness(agents); + h.press(DOWN); // activate (main) + h.press(DOWN); // → the agent + h.press(ENTER); // open the conversation viewer + + const viewer = h.overlayComponent(); + expect(viewer).toBeDefined(); + viewer!.handleInput("\r"); // Enter → open composer + for (const ch of "go left") viewer!.handleInput(ch); + viewer!.handleInput("\r"); // Enter → send + + expect(h.manager.steer).toHaveBeenCalledWith("live", "go left"); + }); + it("does NOT auto-close when the viewed agent finishes (final output stays readable)", () => { const agents = [makeRecord({ id: "live", description: "the one" })]; const h = harness(agents);