From 81b24ecc51b705b4c18494c73cae8b4070b296f6 Mon Sep 17 00:00:00 2001 From: tintinweb Date: Tue, 30 Jun 2026 21:15:14 +0200 Subject: [PATCH 1/3] feat(ui): inline steer composer in the conversation viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press `i` in an agent's conversation viewer to open an input box, type a message, Enter to send (Esc cancels). The message is delivered via the existing session.steer / pendingSteers path — it appears as a user message and redirects the agent after its current tool, same as the steer_subagent tool. Works in both the FleetView overlay and /agents. Adds AgentManager.steer(id, message) to centralize the running-vs-pending routing, wires onSteer at both viewer call sites, and gates the affordance on the agent still being running/queued (mirrors the x/stop affordance). Tested at three layers: manager routing, composer interaction, and the fleet-list wiring seam (real ConversationViewer → manager.steer). Co-Authored-By: Claude Opus 4.8 (1M context) Shorter alternative if you prefer a one-liner subject + minimal body: feat(ui): steer agents from the conversation viewer `i` opens an input box to send a steering message to a running agent (Enter send, Esc cancel), delivered via session.steer. Adds AgentManager.steer() and tests across manager, viewer, and wiring. --- README.md | 4 +- src/agent-manager.ts | 21 ++++++++ src/index.ts | 2 +- src/ui/conversation-viewer.ts | 86 ++++++++++++++++++++++++------ src/ui/fleet-list.ts | 1 + test/agent-manager.test.ts | 47 +++++++++++++++++ test/conversation-viewer.test.ts | 89 ++++++++++++++++++++++++++++++++ test/fleet-list.test.ts | 31 +++++++++-- 8 files changed, 260 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e73bebc0..38f3a33a 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 `i` to open a composer, then `Enter` to send (`Esc` cancels) — 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 @@ -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 `i` to open the steering composer and `Enter` to send a message that redirects the agent (same mechanism as the `steer_subagent` tool), 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 ad49408a..21ba658e 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 a37f2d76..9e7b3946 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 6caee354..bdac8199 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,28 @@ 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; } + // Open the steering composer (only while the agent can still be steered). + // When not steerable, fall through so the key still disarms a pending stop. + if (matchesKey(data, "i") && 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 +182,31 @@ 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 { + 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 · Esc close"); + // Stop/steer hints go first in the right group so they survive right-edge + // truncation on narrow terminals (the scroll hint is the expendable part). + const rightParts: string[] = []; + if (this.canSteer()) rightParts.push(th.fg("dim", "i steer")); + if (this.isStoppable()) { + rightParts.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")); + } + rightParts.push(scrollHint); + const footerRight = rightParts.join(th.fg("dim", " · ")); + const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight)); + lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight)); + } lines.push(hrBot); return lines; @@ -185,6 +217,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 +260,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 f46b5903..3c4ecb30 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 5216ff99..99890269 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 035adadc..6be2d769 100644 --- a/test/conversation-viewer.test.ts +++ b/test/conversation-viewer.test.ts @@ -380,4 +380,93 @@ 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 'i'", () => { + const { viewer } = makeViewer(); + expect(viewer.render(W).join("\n")).toContain("i steer"); + + viewer.handleInput("i"); + // 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("i steer"); + }); + + it("typing then Enter sends the trimmed message and closes the composer", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("i"); + for (const ch of " hello ") viewer.handleInput(ch); + viewer.handleInput("\r"); // Enter + + 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("i"); + 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("submitting an empty message does not call onSteer", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("i"); + viewer.handleInput("\r"); + expect(onSteer).not.toHaveBeenCalled(); + }); + + it("scroll keys are inert while composing (input owns them)", () => { + const { viewer } = makeViewer(); + viewer.handleInput("i"); + // '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("i steer"); + viewer.handleInput("i"); + 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("i steer"); + expect(() => viewer.handleInput("i")).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("i"); + 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 48ef7f28..b5c7434c 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("i"); // 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); From df89a7c5272b5ea480c36014b27f29d00d179c98 Mon Sep 17 00:00:00 2001 From: tintinweb Date: Tue, 30 Jun 2026 21:37:54 +0200 Subject: [PATCH 2/3] feat(ui): steer agents from the conversation viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press Enter in an agent's conversation viewer to open an input box, type a message, Enter to send (Esc or an empty submit returns). The message is delivered via the existing session.steer / pendingSteers path — it appears as a user message and redirects the agent after its current tool, same as the steer_subagent tool. Works in the FleetView overlay and /agents. Adds AgentManager.steer(id, message) to centralize running-vs-pending routing, wires onSteer at both viewer call sites, and gates the affordance on the agent still being running/queued (mirrors the x/stop affordance). The idle footer keeps the full scroll-key hint by splitting into actions-left / nav-right. --- README.md | 4 ++-- src/ui/conversation-viewer.ts | 27 ++++++++++++-------------- test/conversation-viewer.test.ts | 33 ++++++++++++++++---------------- test/fleet-list.test.ts | 2 +- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 38f3a33a..f579eca1 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). Steer a running agent inline by pressing `i` to open a composer, then `Enter` to send (`Esc` cancels) — 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 +- **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 @@ -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 `i` to open the steering composer and `Enter` to send a message that redirects the agent (same mechanism as the `steer_subagent` tool), 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. +- **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/ui/conversation-viewer.ts b/src/ui/conversation-viewer.ts index bdac8199..c3bb9259 100644 --- a/src/ui/conversation-viewer.ts +++ b/src/ui/conversation-viewer.ts @@ -68,9 +68,10 @@ export class ConversationViewer implements Component { return; } - // Open the steering composer (only while the agent can still be steered). - // When not steerable, fall through so the key still disarms a pending stop. - if (matchesKey(data, "i") && this.canSteer()) { + // 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; @@ -190,20 +191,16 @@ export class ConversationViewer implements Component { const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint)); lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint)); } else { - 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 · Esc close"); - // Stop/steer hints go first in the right group so they survive right-edge - // truncation on narrow terminals (the scroll hint is the expendable part). - const rightParts: string[] = []; - if (this.canSteer()) rightParts.push(th.fg("dim", "i steer")); + // 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 actions: string[] = []; + if (this.canSteer()) actions.push(th.fg("dim", "Enter steer")); if (this.isStoppable()) { - rightParts.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")); + actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")); } - rightParts.push(scrollHint); - const footerRight = rightParts.join(th.fg("dim", " · ")); + const footerLeft = actions.join(th.fg("dim", " · ")); + const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close"); const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight)); lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight)); } diff --git a/test/conversation-viewer.test.ts b/test/conversation-viewer.test.ts index 6be2d769..033878bb 100644 --- a/test/conversation-viewer.test.ts +++ b/test/conversation-viewer.test.ts @@ -394,22 +394,22 @@ describe("ConversationViewer", () => { return { viewer, tui, onSteer }; } - it("offers the steer affordance for a running agent and opens on 'i'", () => { + it("offers the steer affordance for a running agent and opens on Enter", () => { const { viewer } = makeViewer(); - expect(viewer.render(W).join("\n")).toContain("i steer"); + expect(viewer.render(W).join("\n")).toContain("Enter steer"); - viewer.handleInput("i"); + 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("i steer"); + expect(out).not.toContain("Enter steer"); }); it("typing then Enter sends the trimmed message and closes the composer", () => { const { viewer, onSteer } = makeViewer(); - viewer.handleInput("i"); + viewer.handleInput("\r"); // open composer for (const ch of " hello ") viewer.handleInput(ch); - viewer.handleInput("\r"); // Enter + viewer.handleInput("\r"); // send expect(onSteer).toHaveBeenCalledWith("hello"); expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed @@ -417,7 +417,7 @@ describe("ConversationViewer", () => { it("Esc cancels the composer without sending", () => { const { viewer, onSteer } = makeViewer(); - viewer.handleInput("i"); + viewer.handleInput("\r"); // open composer for (const ch of "draft") viewer.handleInput(ch); viewer.handleInput("\x1b"); // Esc @@ -425,16 +425,17 @@ describe("ConversationViewer", () => { expect(viewer.render(W).join("\n")).not.toContain("Enter send"); }); - it("submitting an empty message does not call onSteer", () => { + it("an empty submit just returns (like Esc), without calling onSteer", () => { const { viewer, onSteer } = makeViewer(); - viewer.handleInput("i"); - viewer.handleInput("\r"); + 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("i"); + 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"); @@ -442,8 +443,8 @@ describe("ConversationViewer", () => { 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("i steer"); - viewer.handleInput("i"); + 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(); }); @@ -452,8 +453,8 @@ describe("ConversationViewer", () => { const viewer = new ConversationViewer( mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), ); - expect(viewer.render(W).join("\n")).not.toContain("i steer"); - expect(() => viewer.handleInput("i")).not.toThrow(); + expect(viewer.render(W).join("\n")).not.toContain("Enter steer"); + expect(() => viewer.handleInput("\r")).not.toThrow(); }); it("composer rows never exceed width", () => { @@ -463,7 +464,7 @@ describe("ConversationViewer", () => { tui, mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(), ); - viewer.handleInput("i"); + 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 b5c7434c..a5b1396f 100644 --- a/test/fleet-list.test.ts +++ b/test/fleet-list.test.ts @@ -343,7 +343,7 @@ describe("FleetList overlay lifecycle", () => { const viewer = h.overlayComponent(); expect(viewer).toBeDefined(); - viewer!.handleInput("i"); // open composer + viewer!.handleInput("\r"); // Enter → open composer for (const ch of "go left") viewer!.handleInput(ch); viewer!.handleInput("\r"); // Enter → send From babcccdf83e153534651c3645ff2caa1bc5281df Mon Sep 17 00:00:00 2001 From: tintinweb Date: Tue, 30 Jun 2026 21:47:21 +0200 Subject: [PATCH 3/3] hide scroll% for narrow width terminals --- CHANGELOG.md | 1 + README.md | 2 +- src/ui/conversation-viewer.ts | 14 +++++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f288b376..b013415b 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 f579eca1..9b2d2a2e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/ui/conversation-viewer.ts b/src/ui/conversation-viewer.ts index c3bb9259..558886db 100644 --- a/src/ui/conversation-viewer.ts +++ b/src/ui/conversation-viewer.ts @@ -194,13 +194,25 @@ export class ConversationViewer implements Component { // 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 footerLeft = actions.join(th.fg("dim", " · ")); 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)); }