From 2eb10b1b7dd326fe64e477d3409c33b047e6202f Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 23:29:28 +0800 Subject: [PATCH 1/5] feat(subagents): render tool executions as command blocks Use status glyphs for tool results and live execution state.\nKeep tool calls and results contiguous, with running and settled tools sharing one body shape. --- extensions/subagents/src/ui/transcript.ts | 143 +++++++++++++--- extensions/subagents/transcript.test.ts | 189 +++++++++++++++++++++- 2 files changed, 303 insertions(+), 29 deletions(-) diff --git a/extensions/subagents/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index a8723c19..7b0c9c17 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -18,6 +18,26 @@ import type { SubagentSnapshot, TranscriptItem } from "../domain.ts"; const MAX_CACHED_WIDTHS_PER_ITEM = 2; +export const SPINNER_FRAMES = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", +] as const; + +export function spinnerFrame(now: number) { + const frame = Math.floor(now / 120) % SPINNER_FRAMES.length; + return SPINNER_FRAMES[ + (frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length + ]; +} + /** * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded * tabs (and stray escapes) make lines wider than the width we declare to the @@ -146,6 +166,39 @@ function renderThinking(theme: Theme, text: string, width: number) { return out; } +function renderToolBody(theme: Theme, name: string, argsPreview?: string) { + const toolName = sanitizeText(name); + const preview = summarizeToolArgs(toolName, argsPreview); + if (toolName === "bash") return theme.fg("dim", `$ ${preview ?? ""}`); + return ( + theme.fg("dim", "→ ") + + theme.fg("toolTitle", toolName) + + (preview ? theme.fg("dim", ` ${preview}`) : "") + ); +} + +function firstOutputPreview(outputPreview?: string) { + return ( + sanitizeText(outputPreview ?? "") + .split("\n") + .find((line) => line.trim()) ?? "" + ); +} + +function renderResultLine( + theme: Theme, + isError: boolean, + outputPreview: string, + width: number, +) { + const glyph = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const preview = outputPreview || "(no output)"; + const content = isError + ? theme.fg(outputPreview ? "error" : "dim", preview) + : theme.fg("dim", preview); + return truncateToWidth(` ${glyph} ${content}`, width); +} + function renderAssistantItem( theme: Theme, item: Extract, @@ -164,12 +217,12 @@ function renderAssistantItem( ), ); } else if (part.type === "toolCall") { - const preview = summarizeToolArgs(part.name, part.argsPreview); - const line = - theme.fg("muted", "→ ") + - theme.fg("toolTitle", part.name) + - (preview ? theme.fg("dim", ` ${preview}`) : ""); - out.push(truncateToWidth(line, width)); + out.push( + truncateToWidth( + renderToolBody(theme, part.name, part.argsPreview), + width, + ), + ); } } return out; @@ -180,18 +233,31 @@ function renderToolResultItem( item: Extract, width: number, ) { - const firstLine = - sanitizeText(item.outputPreview ?? "") - .split("\n") - .find((line) => line.trim()) ?? ""; - const label = item.isError - ? theme.fg("error", " error: ") - : theme.fg("dim", " output: "); return [ - truncateToWidth(label + theme.fg("dim", firstLine || "(no output)"), width), + renderResultLine( + theme, + item.isError, + firstOutputPreview(item.outputPreview), + width, + ), ]; } +function isPairedToolResult( + previous: TranscriptItem | undefined, + current: TranscriptItem, +) { + if ( + !previous || + previous.kind !== "assistant" || + current.kind !== "toolResult" + ) { + return false; + } + const lastPart = previous.parts[previous.parts.length - 1]; + return lastPart?.type === "toolCall" && lastPart.toolId === current.toolId; +} + function renderTranscriptItem( theme: Theme, item: TranscriptItem, @@ -210,10 +276,17 @@ function renderTranscriptItem( export class TranscriptRenderer { private itemCache = new WeakMap>(); - render(snap: SubagentSnapshot, width: number, theme: Theme) { + render( + snap: SubagentSnapshot, + width: number, + theme: Theme, + options?: { readonly now?: number }, + ) { const out: string[] = []; + const now = options?.now ?? Date.now(); - for (const item of snap.transcript) { + for (let index = 0; index < snap.transcript.length; index++) { + const item = snap.transcript[index]; const cached = this.itemCache.get(item)?.get(width); const lines = cached ?? renderTranscriptItem(theme, item, width); if (!cached) { @@ -225,7 +298,15 @@ export class TranscriptRenderer { widths.set(width, lines); this.itemCache.set(item, widths); } - if (lines.length > 0) out.push(...lines, ""); + if (lines.length > 0) { + if ( + out.length > 0 && + !isPairedToolResult(snap.transcript[index - 1], item) + ) { + out.push(""); + } + out.push(...lines); + } } while (out.length > 0 && out[out.length - 1] === "") out.pop(); @@ -244,14 +325,18 @@ export class TranscriptRenderer { if (out.length > 0) out.push(""); const marker = tool.done ? tool.isError - ? theme.fg("error", "error") - : theme.fg("success", "done") - : theme.fg("warning", "running"); - const args = summarizeToolArgs(tool.name, tool.argsPreview); - let line = `${theme.fg("toolTitle", tool.name)}${args ? theme.fg("dim", ` ${args}`) : ""} · ${marker}`; - const preview = tool.outputPreview && compactPreview(tool.outputPreview); - if (preview) line += theme.fg("dim", ` · ${preview}`); - out.push(truncateToWidth(line, width)); + ? theme.fg("error", "✗") + : theme.fg("success", "✓") + : theme.fg("warning", spinnerFrame(now)); + out.push( + truncateToWidth( + `${marker} ${renderToolBody(theme, tool.name, tool.argsPreview)}`, + width, + ), + ); + const preview = firstOutputPreview(tool.outputPreview); + if (preview) + out.push(renderResultLine(theme, !!tool.isError, preview, width)); } // Queued steering/follow-up messages: show them immediately so Enter @@ -288,6 +373,12 @@ export function buildTranscriptLines( width: number, theme: Theme, renderer?: TranscriptRenderer, + options?: { readonly now?: number }, ) { - return (renderer ?? new TranscriptRenderer()).render(snap, width, theme); + return (renderer ?? new TranscriptRenderer()).render( + snap, + width, + theme, + options, + ); } diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index 6f49f670..01f85770 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -8,6 +8,7 @@ import { TranscriptRenderer, buildTranscriptLines, sanitizeText, + spinnerFrame, summarizeToolArgs, } from "./src/ui/transcript.ts"; @@ -143,6 +144,188 @@ test("tool calls summarize known bounded JSON arguments and safely fall back", ( assert.equal(summarizeToolArgs("bash", '{"command":'), '{"command":'); }); +test("bash tool calls use shell prompts while other tools keep arrow form", () => { + const rendered = plain( + buildTranscriptLines( + snapshot({ + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: "bash-1", + name: "bash", + argsPreview: '{"command":"git status --porcelain"}', + }, + { + type: "toolCall", + toolId: "read-1", + name: "read", + argsPreview: '{"path":"src/index.ts"}', + }, + ], + }, + ], + }), + 80, + theme, + ), + ); + + assert.match(rendered, /^\$ git status --porcelain/m); + assert.match(rendered, /→ read src\/index\.ts/); +}); + +test("adjacent tool results form one block with a success glyph", () => { + const lines = buildTranscriptLines( + snapshot({ + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: "call-1", + name: "bash", + argsPreview: '{"command":"printf ok"}', + }, + ], + }, + { + kind: "toolResult", + toolId: "call-1", + name: "bash", + isError: false, + outputPreview: "ok", + }, + ], + }), + 80, + theme, + ); + + assert.deepEqual(lines, ["$ printf ok", " ✓ ok"]); + assert.ok(!lines.slice(0, -1).some((line) => line === "")); +}); + +test("tool errors and empty results use status glyphs", () => { + const rendered = plain( + buildTranscriptLines( + snapshot({ + transcript: [ + { + kind: "toolResult", + toolId: "error-1", + name: "bash", + isError: true, + outputPreview: "command failed", + }, + { + kind: "toolResult", + toolId: "empty-1", + name: "bash", + isError: false, + }, + ], + }), + 80, + theme, + ), + ); + + assert.match(rendered, /✗ command failed/); + assert.match(rendered, /✓ \(no output\)/); +}); + +test("running live tools keep the settled body shape", () => { + const running = buildTranscriptLines( + snapshot({ + liveTools: [ + { + toolId: "live-1", + name: "bash", + argsPreview: '{"command":"git status"}', + outputPreview: "clean", + }, + ], + }), + 80, + theme, + undefined, + { now: 0 }, + ); + const settled = buildTranscriptLines( + snapshot({ + liveTools: [ + { + toolId: "live-1", + name: "bash", + argsPreview: '{"command":"git status"}', + outputPreview: "clean", + done: true, + }, + ], + }), + 80, + theme, + undefined, + { now: 0 }, + ); + + assert.equal(running[0], "⠋ $ git status"); + assert.deepEqual(settled, ["✓ $ git status", " ✓ clean"]); + assert.equal(running[1], settled[1]); +}); + +test("spinnerFrame is deterministic and advances every 120ms", () => { + assert.equal(spinnerFrame(0), "⠋"); + assert.equal(spinnerFrame(119), "⠋"); + assert.equal(spinnerFrame(120), "⠙"); + assert.equal(spinnerFrame(10 * 120), "⠋"); +}); + +test("tool rendering keeps every line within a narrow width", () => { + const lines = buildTranscriptLines( + snapshot({ + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: "narrow-1", + name: "bash", + argsPreview: JSON.stringify({ command: "x".repeat(200) }), + }, + ], + }, + { + kind: "toolResult", + toolId: "narrow-1", + name: "bash", + isError: false, + outputPreview: "y".repeat(200), + }, + ], + liveTools: [ + { + toolId: "narrow-live", + name: "custom-tool", + argsPreview: JSON.stringify({ value: "z".repeat(200) }), + outputPreview: "w".repeat(200), + }, + ], + }), + 24, + theme, + undefined, + { now: 0 }, + ); + + assert.ok(lines.every((line) => visibleWidth(line) <= 24)); +}); + test("cached finalized transcript output is rebuilt after invalidation", () => { const renderer = new TranscriptRenderer(); const cached = snapshot({ @@ -169,15 +352,15 @@ test("cached finalized transcript output is rebuilt after invalidation", () => { assert.match( plain(buildTranscriptLines(cached, 80, taggedTheme("first"), renderer)), - /\[first:toolTitle\]bash/, + /\[first:dim\]\$ npm test/, ); assert.match( plain(buildTranscriptLines(cached, 80, taggedTheme("second"), renderer)), - /\[first:toolTitle\]bash/, + /\[first:dim\]\$ npm test/, ); renderer.invalidate(); assert.match( plain(buildTranscriptLines(cached, 80, taggedTheme("second"), renderer)), - /\[second:toolTitle\]bash/, + /\[second:dim\]\$ npm test/, ); }); From 25e3baebee1d0278ca14d799f59e75a917bd1c10 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 23:34:43 +0800 Subject: [PATCH 2/5] feat(subagents): fit the dashboard to its content and show live activity --- extensions/subagents/src/ui/takeover.ts | 181 ++++++++++++++---------- extensions/subagents/takeover.test.ts | 131 +++++++++++++++++ 2 files changed, 241 insertions(+), 71 deletions(-) diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index a9f72c99..3a818183 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -30,26 +30,44 @@ function configuredKeys( return keybindings.getKeys(binding).join("/") || "unbound"; } +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_INTERVAL_MS = 120; + function statusGlyph(snap: SubagentSnapshot, theme: Theme): string { switch (snap.status) { case "running": - return theme.fg("warning", "■"); + return theme.fg( + "warning", + SPINNER_FRAMES[ + Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length + ], + ); case "done": - return theme.fg("success", "■"); + return theme.fg("success", "✓"); case "error": - return theme.fg("error", "■"); + return theme.fg("error", "✗"); } } -function statusWord(snap: SubagentSnapshot, theme: Theme): string { - switch (snap.status) { - case "running": - return theme.fg("warning", "running"); - case "done": - return theme.fg("success", "done"); - case "error": - return theme.fg("error", "failed"); +function runningActivity(snap: SubagentSnapshot) { + if (snap.status !== "running") return ""; + const liveTool = snap.liveTools.at(-1); + if (liveTool) { + const args = truncateToWidth( + sanitizeSubagentDisplayLine(liveTool.argsPreview ?? ""), + 48, + ); + return args ? `${liveTool.name} · ${args}` : liveTool.name; + } + for (const item of [...snap.transcript].reverse()) { + if (item.kind === "toolResult") return item.name; + if (item.kind !== "assistant") continue; + const tool = [...item.parts] + .reverse() + .find((part) => part.type === "toolCall"); + if (tool?.type === "toolCall") return tool.name; } + return ""; } // --- Entry points -------------------------------------------------------------- @@ -126,7 +144,7 @@ export function reconcileDashboardSelection( selection.id = subs[selection.index]?.id; } -class SubagentDashboard implements Component { +export class SubagentDashboard implements Component { private tui: TUI; private theme: Theme; private keybindings: KeybindingsManager; @@ -135,7 +153,7 @@ class SubagentDashboard implements Component { private done: (value: string | null) => void; private closed = false; - private ticker: ReturnType; + private ticker?: ReturnType; private unsubChange: () => void; constructor( @@ -152,19 +170,29 @@ class SubagentDashboard implements Component { this.view = view; this.selection = selection; this.done = done; - // Elapsed times, token counts, and statuses tick along at 1Hz. - this.ticker = setInterval(() => this.tui.requestRender(), 1000); - this.unsubChange = view.subscribe(() => this.tui.requestRender()); + this.refreshTicker(); + this.unsubChange = view.subscribe(() => { + this.refreshTicker(); + this.tui.requestRender(); + }); } private subs(): ReadonlyArray { return this.view.list(); } + private refreshTicker() { + const interval = this.subs().some((snap) => snap.status === "running") + ? SPINNER_INTERVAL_MS + : 1000; + if (this.ticker) clearInterval(this.ticker); + this.ticker = setInterval(() => this.tui.requestRender(), interval); + } + private cleanup() { if (this.closed) return false; this.closed = true; - clearInterval(this.ticker); + if (this.ticker) clearInterval(this.ticker); this.unsubChange(); return true; } @@ -238,44 +266,33 @@ class SubagentDashboard implements Component { reconcileDashboardSelection(this.selection, subs); const rows = this.tui.terminal.rows || 30; - // Render exactly terminal rows - 1 so the overlay covers the header, - // chat, editor, and extra footer lines while leaving pi's final footer - // row visible. - const bodyHeight = Math.max(6, rows - 5); - const innerWidth = width - 2; + const maxBodyHeight = Math.max(1, rows - 5); + const bodyHeight = + subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length); + const innerWidth = Math.max(0, width - 2); const lines: string[] = []; - - // Header: title left, count right - const headerLeft = theme.fg("accent", theme.bold("Subagents")); - const headerRight = theme.fg( - "muted", - `${subs.length} agent${subs.length === 1 ? "" : "s"}`, - ); - const headerPad = Math.max( - 1, - width - visibleWidth(headerLeft) - visibleWidth(headerRight) - 4, - ); - lines.push( - truncateToWidth( - ` ${headerLeft}${" ".repeat(headerPad)}${headerRight} `, - width, - ), - ); - - // Top border with panel title - const settled = subs.filter((s) => s.status !== "running").length; + const running = subs.filter((snap) => snap.status === "running").length; + const done = subs.filter((snap) => snap.status === "done").length; + const failed = subs.filter((snap) => snap.status === "error").length; + const summary = + [ + running > 0 ? `${running} running` : "", + done > 0 ? `${done} done` : "", + failed > 0 ? `${failed} failed` : "", + ] + .filter(Boolean) + .join(" · ") || "no agents"; lines.push( theme.fg("border", "╭") + - this.borderSegment(innerWidth, `agents · ${settled}/${subs.length}`) + + this.borderSegment(innerWidth, `Subagents · ${summary}`) + theme.fg("border", "╮"), ); - // Rows const divider = theme.fg("border", "│"); const rowLines = this.renderRows(subs, innerWidth, bodyHeight); - for (let i = 0; i < bodyHeight; i++) { - lines.push(divider + this.pad(rowLines[i] ?? "", innerWidth) + divider); + for (const row of rowLines) { + lines.push(divider + this.pad(row, innerWidth) + divider); } // Bottom border @@ -307,33 +324,35 @@ class SubagentDashboard implements Component { const theme = this.theme; const out: string[] = []; - // Scroll window around selection + const needsMore = subs.length > height && height > 1; + const visibleHeight = needsMore ? height - 1 : height; + + // Scroll window around selection. The more indicator gets its own row, so + // it never replaces a selectable subagent. let start = 0; - if (subs.length > height) { + if (subs.length > visibleHeight) { start = Math.min( - Math.max(0, this.selection.index - Math.floor(height / 2)), - subs.length - height, + Math.max(0, this.selection.index - Math.floor(visibleHeight / 2)), + Math.max(0, subs.length - visibleHeight), ); } - const visible = subs.slice(start, start + height); + const visible = subs.slice(start, start + visibleHeight); for (let i = 0; i < visible.length; i++) { const snap = visible[i]; const index = start + i; const isSelected = index === this.selection.index; - // Left: marker, status square, title const marker = isSelected ? theme.fg("accent", "❯") : " "; const safeTitle = sanitizeSubagentDisplayLine(snap.title) || snap.id; const title = isSelected ? theme.fg("accent", safeTitle) : theme.fg("text", safeTitle); - const left = ` ${marker} ${statusGlyph(snap, theme)} ${title}`; + const activity = runningActivity(snap); + const prefix = ` ${marker} ${statusGlyph(snap, theme)} `; - // Right: backend · model · context utilization · elapsed · status const utilization = formatContextUtilization(snap.usage); - const dot = theme.fg("dim", " · "); - const rightParts = [ + const metadata = [ theme.fg("muted", snap.backend), theme.fg( "muted", @@ -341,24 +360,45 @@ class SubagentDashboard implements Component { ), ...(utilization ? [theme.fg("muted", utilization)] : []), theme.fg("muted", formatElapsed(snap)), - statusWord(snap, theme), ]; - const right = `${rightParts.join(dot)} `; - + const dot = theme.fg("dim", " · "); + // Preserve elapsed and the activity-bearing left side longest. Shed the + // least useful metadata as a segment instead of clipping a joined tail. + while ( + metadata.length > 1 && + visibleWidth(metadata.join(dot)) > Math.max(0, width - 16) + ) { + metadata.shift(); + } + const right = metadata.join(dot); const rightWidth = visibleWidth(right); - const leftMax = Math.max(0, width - rightWidth - 2); - const leftTruncated = truncateToWidth(left, leftMax); - const gap = Math.max(2, width - visibleWidth(leftTruncated) - rightWidth); - out.push(truncateToWidth(leftTruncated + " ".repeat(gap) + right, width)); + const leftMax = Math.max(0, width - rightWidth - (right ? 2 : 0)); + const activityLabel = activity ? theme.fg("muted", ` · ${activity}`) : ""; + const left = + prefix + + truncateToWidth( + title, + Math.max( + 0, + leftMax - visibleWidth(prefix) - visibleWidth(activityLabel), + ), + ) + + truncateToWidth( + activityLabel, + Math.max(0, leftMax - visibleWidth(prefix)), + ); + const gap = right + ? Math.max(1, width - visibleWidth(left) - rightWidth) + : 0; + out.push(truncateToWidth(left + " ".repeat(gap) + right, width)); } - if (start > 0) { - out[0] = truncateToWidth(theme.fg("dim", ` ... ${start} more`), width); - } - if (start + height < subs.length) { - out[out.length - 1] = truncateToWidth( - theme.fg("dim", ` ... ${subs.length - start - height} more`), - width, + if (needsMore) { + out.push( + truncateToWidth( + theme.fg("dim", ` … ${subs.length - visible.length} more`), + width, + ), ); } return out; @@ -367,7 +407,6 @@ class SubagentDashboard implements Component { invalidate(): void {} } -// --- Takeover view ------------------------------------------------------------ const TRANSCRIPT_SCROLL_STEP = 6; diff --git a/extensions/subagents/takeover.test.ts b/extensions/subagents/takeover.test.ts index 7a550c28..ba41cf4f 100644 --- a/extensions/subagents/takeover.test.ts +++ b/extensions/subagents/takeover.test.ts @@ -1,11 +1,85 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { + KeybindingsManager, + Theme, +} from "@earendil-works/pi-coding-agent"; +import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import type { SubagentSnapshot } from "./src/domain.ts"; +import type { SubagentReadModel } from "./src/manager.ts"; import { reconcileDashboardSelection, sanitizeSubagentDisplayLine, + SubagentDashboard, type DashboardSelection, } from "./src/ui/takeover.ts"; +const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +} as unknown as Theme; + +const keys = { + matches: (data: string, binding: string) => data === binding, + getKeys: (binding: string) => [binding.split(".").at(-1) ?? binding], +} as unknown as KeybindingsManager; + +function tui(rows = 30) { + return { + terminal: { rows }, + requestRender() {}, + } as unknown as TUI; +} + +function snap( + id: string, + status: SubagentSnapshot["status"] = "running", + overrides: Partial = {}, +): SubagentSnapshot { + return { + id, + origin: "model", + backend: "pi", + title: `agent ${id}`, + prompt: "test", + cwd: "/tmp", + status, + createdAt: Date.now() - 12_000, + meta: { backend: "pi", modelLabel: "provider/a-very-long-model-label" }, + usage: { tokens: 12_000, contextWindow: 100_000 }, + transcript: [], + liveTools: [], + queued: [], + finalText: "", + turns: 0, + ...overrides, + }; +} + +function model(subs: SubagentSnapshot[]): SubagentReadModel { + return { + list: () => subs, + get: (id) => subs.find((snap) => snap.id === id), + size: () => subs.length, + subscribe: () => () => {}, + subscribeTo: () => () => {}, + requestSend() {}, + requestAbort() {}, + setOnSettled() {}, + }; +} + +function dashboard(subs: SubagentSnapshot[], rows = 30) { + return new SubagentDashboard( + tui(rows), + theme, + keys, + model(subs), + { index: 0 }, + () => {}, + ); +} + test("picker and takeover display text cannot inject terminal controls", () => { assert.equal( sanitizeSubagentDisplayLine( @@ -37,3 +111,60 @@ test("dashboard selection follows its subagent id and falls back by row", () => reconcileDashboardSelection(selection, []); assert.deepEqual(selection, { id: undefined, index: 0 }); }); + +test("dashboard box height follows its subagents and retains the old maximum", () => { + const one = dashboard([snap("one")], 10); + try { + const lines = one.render(100); + assert.equal(lines.length, 4); // border, one agent, border, hints + assert.equal(lines.filter((line) => line.includes("agent one")).length, 1); + } finally { + one.dispose(); + } + + const many = dashboard( + Array.from({ length: 12 }, (_, i) => snap(`${i}`)), + 10, + ); + try { + const lines = many.render(100); + assert.equal(lines.filter((line) => line.startsWith("│")).length, 5); + } finally { + many.dispose(); + } +}); + +test("dashboard reserves a more row and shows each visible subagent", () => { + const view = dashboard( + Array.from({ length: 8 }, (_, i) => snap(`${i}`)), + 10, + ); + try { + const output = view.render(100).join("\n"); + for (const id of ["0", "1", "2", "3"]) { + assert.match(output, new RegExp(`agent ${id}`)); + } + assert.match(output, /… 4 more/); + } finally { + view.dispose(); + } +}); + +test("dashboard uses one status glyph and reports live activity", () => { + const view = dashboard([ + snap("run", "running", { + liveTools: [{ toolId: "1", name: "Bash", argsPreview: "git status" }], + }), + snap("done", "done"), + snap("bad", "error"), + ]); + try { + const output = view.render(100).join("\n"); + assert.match(output, /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] agent run · Bash · git status/); + assert.doesNotMatch(output, /agent run.*running/); + assert.match(output, /✓ agent done/); + assert.match(output, /✗ agent bad/); + } finally { + view.dispose(); + } +}); From 3a6c96605f1cfda06dc50f42bd0372d34345bb09 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 23:35:03 +0800 Subject: [PATCH 3/5] feat(subagents): calm the takeover chrome down to three rules --- extensions/subagents/src/ui/takeover.ts | 157 +++++++++++++++--------- extensions/subagents/takeover.test.ts | 59 +++++++++ 2 files changed, 156 insertions(+), 60 deletions(-) diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index 3a818183..f3f3073c 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -407,10 +407,11 @@ export class SubagentDashboard implements Component { invalidate(): void {} } +// --- Takeover view ------------------------------------------------------------ const TRANSCRIPT_SCROLL_STEP = 6; -class TakeoverView implements Component, Focusable { +export class TakeoverView implements Component, Focusable { private tui: TUI; private theme: Theme; private keybindings: KeybindingsManager; @@ -425,7 +426,7 @@ class TakeoverView implements Component, Focusable { private scrollOffset = 0; private unsubscribe: () => void; private renderTimer?: ReturnType; - private ticker: ReturnType; + private ticker?: ReturnType; private closed = false; private _focused = false; @@ -453,9 +454,11 @@ class TakeoverView implements Component, Focusable { this.view = view; this.done = done; this.options = options; - this.unsubscribe = view.subscribeTo(id, () => this.scheduleRender()); - // Elapsed time in the header ticks along at 1Hz. - this.ticker = setInterval(() => this.tui.requestRender(), 1000); + this.unsubscribe = view.subscribeTo(id, () => { + this.refreshTicker(); + this.scheduleRender(); + }); + this.refreshTicker(); this.input.onSubmit = (value: string) => { const text = value.trim(); if (!text) return; @@ -470,6 +473,13 @@ class TakeoverView implements Component, Focusable { return this.view.get(this.id); } + private refreshTicker() { + const interval = + this.snap()?.status === "running" ? SPINNER_INTERVAL_MS : 1000; + if (this.ticker) clearInterval(this.ticker); + this.ticker = setInterval(() => this.tui.requestRender(), interval); + } + private scheduleRender() { if (this.renderTimer) return; // Streaming can emit an event per token. Limit terminal repaints so this @@ -484,7 +494,7 @@ class TakeoverView implements Component, Focusable { if (this.closed) return false; this.closed = true; this.unsubscribe(); - clearInterval(this.ticker); + if (this.ticker) clearInterval(this.ticker); if (this.renderTimer) clearTimeout(this.renderTimer); this.renderTimer = undefined; return true; @@ -543,49 +553,83 @@ class TakeoverView implements Component, Focusable { private viewportHeight(): number { const rows = this.tui.terminal.rows || 30; - // The complete view renders viewport + 7 chrome rows. Using rows - 8 - // makes the overlay exactly terminal rows - 1. - return Math.max(6, rows - 8); + // Top rule, transcript rule, input, key hints, and bottom rule are five + // chrome rows. The overlay leaves Pi's final footer row visible. + return Math.max(1, rows - 6); + } + + private rule(width: number, left = "", right = "") { + const fill = "─"; + const available = Math.max(1, width); + const leftWidth = visibleWidth(left); + const rightWidth = visibleWidth(right); + if (!right || leftWidth + rightWidth + 2 > available) { + return truncateToWidth( + left + fill.repeat(Math.max(0, available - leftWidth)), + available, + ); + } + return ( + left + + fill.repeat(Math.max(1, available - leftWidth - rightWidth)) + + right + ); } render(width: number): string[] { const theme = this.theme; - const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width))); const lines: string[] = []; const snap = this.snap(); if (!snap) { - lines.push(border); - lines.push(theme.fg("dim", `${this.id} is no longer tracked`)); - lines.push(border); + const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width))); + lines.push( + border, + theme.fg("dim", `${this.id} is no longer tracked`), + border, + ); return lines; } - lines.push(border); + const title = sanitizeSubagentDisplayLine(snap.title) || snap.id; + const headerLeft = + theme.fg("borderAccent", "─ ") + + statusGlyph(snap, theme) + + " " + + theme.fg("accent", theme.bold(title)) + + theme.fg("borderAccent", " "); const utilization = formatContextUtilization(snap.usage); - const header = - `${statusGlyph(snap, theme)} ` + - theme.fg( - "accent", - theme.bold(sanitizeSubagentDisplayLine(snap.title) || snap.id), - ) + - theme.fg("muted", ` · ${snap.status} · ${formatElapsed(snap)}`) + - (this.options?.badge - ? theme.fg( - "muted", - ` · ${sanitizeSubagentDisplayLine(this.options.badge)}`, - ) - : "") + + const metadata = [ + ...(this.options?.badge + ? [theme.fg("muted", sanitizeSubagentDisplayLine(this.options.badge))] + : []), theme.fg( - "dim", - ` · ${snap.backend}: ${sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?"}`, - ) + - (utilization ? theme.fg("dim", ` · ${utilization}`) : ""); - lines.push(truncateToWidth(header, width)); - lines.push(border); - - // Fixed-height transcript viewport. Error and scroll status consume rows - // inside the viewport so streaming/scrolling never changes overlay height. + "muted", + sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?", + ), + ...(utilization ? [theme.fg("muted", utilization)] : []), + theme.fg("muted", formatElapsed(snap)), + ]; + const dot = theme.fg("dim", " · "); + while ( + metadata.length > 1 && + visibleWidth(headerLeft) + visibleWidth(metadata.join(dot)) + 2 > width + ) { + metadata.shift(); + } + lines.push( + this.rule( + width, + truncateToWidth( + headerLeft, + Math.max(1, width - visibleWidth(metadata.join(dot)) - 2), + ), + metadata.join(dot), + ), + ); + + // Fixed-height transcript viewport. Errors consume a row, but scroll state + // is represented by the following rule so its height never changes. const transcript = buildTranscriptLines( snap, width, @@ -594,8 +638,7 @@ class TakeoverView implements Component, Focusable { ); const viewport = this.viewportHeight(); const errorRows = snap.errorText ? 1 : 0; - const scrollRows = this.scrollOffset > 0 ? 1 : 0; - const transcriptCapacity = Math.max(1, viewport - errorRows - scrollRows); + const transcriptCapacity = Math.max(1, viewport - errorRows); const maxOffset = Math.max(0, transcript.length - transcriptCapacity); if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset; @@ -611,39 +654,33 @@ class TakeoverView implements Component, Focusable { ), ); } - - const capacity = Math.max( - 1, - viewport - body.length - (this.scrollOffset > 0 ? 1 : 0), - ); const end = transcript.length - this.scrollOffset; - const visible = transcript.slice(Math.max(0, end - capacity), end); - if (visible.length === 0) body.push(theme.fg("dim", "(no output yet)")); + const visible = transcript.slice( + Math.max(0, end - Math.max(1, viewport - body.length)), + end, + ); + if (visible.length === 0) body.push(theme.fg("dim", "waiting for output…")); else body.push(...visible); - - if (this.scrollOffset > 0) { - body.push( - truncateToWidth( - theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`), - width, - ), - ); - } while (body.length < viewport) body.push(""); lines.push(...body.slice(0, viewport)); - lines.push(border); + lines.push( + this.rule( + width, + theme.fg("borderAccent", "─"), + this.scrollOffset > 0 ? theme.fg("dim", `↓ ${this.scrollOffset}`) : "", + ), + ); lines.push(...this.input.render(width)); + const hints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`; + const compactHints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll`; lines.push( truncateToWidth( - theme.fg( - "dim", - `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`, - ), + theme.fg("dim", visibleWidth(hints) <= width ? hints : compactHints), width, ), ); - lines.push(border); + lines.push(this.rule(width, theme.fg("borderAccent", "─"))); return lines; } diff --git a/extensions/subagents/takeover.test.ts b/extensions/subagents/takeover.test.ts index ba41cf4f..64b7f4df 100644 --- a/extensions/subagents/takeover.test.ts +++ b/extensions/subagents/takeover.test.ts @@ -11,6 +11,7 @@ import { reconcileDashboardSelection, sanitizeSubagentDisplayLine, SubagentDashboard, + TakeoverView, type DashboardSelection, } from "./src/ui/takeover.ts"; @@ -168,3 +169,61 @@ test("dashboard uses one status glyph and reports live activity", () => { view.dispose(); } }); + +test("chrome rows stay width-bounded and takeover uses three rules", () => { + const running = snap("run", "running", { + title: "a very long title for a very narrow terminal", + liveTools: [{ toolId: "1", name: "Bash", argsPreview: "git status" }], + }); + const list = model([running]); + const pick = new SubagentDashboard( + tui(), + theme, + keys, + list, + { index: 0 }, + () => {}, + ); + const takeover = new TakeoverView(tui(), theme, keys, "run", list, () => {}); + try { + for (const lines of [pick.render(30), takeover.render(30)]) { + for (const line of lines) assert.ok(visibleWidth(line) <= 30, line); + } + assert.equal( + takeover.render(80).filter((line) => line.includes("─")).length, + 3, + ); + } finally { + pick.dispose(); + takeover.dispose(); + } +}); + +test("takeover scroll indicator lives in its rule without changing overlay height", () => { + const transcript = Array.from({ length: 40 }, (_, index) => ({ + kind: "assistant" as const, + parts: [{ type: "text" as const, text: `output ${index}` }], + })); + const running = snap("run", "running", { transcript }); + const view = new TakeoverView( + tui(20), + theme, + keys, + "run", + model([running]), + () => {}, + ); + try { + const pinned = view.render(80); + assert.equal(pinned.length, 19); + assert.doesNotMatch(pinned.join("\n"), /↓ \d+/); + + view.handleInput("tui.editor.pageUp"); + const scrolled = view.render(80); + assert.equal(scrolled.length, pinned.length); + assert.match(scrolled.join("\n"), /↓ \d+/); + assert.doesNotMatch(scrolled.join("\n"), /lines below/); + } finally { + view.dispose(); + } +}); From d8b8c28f69961ee2f44c13b77a38a02c56061216 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 23:42:08 +0800 Subject: [PATCH 4/5] refactor(subagents): share one spinner across the subagent UI The dashboard, takeover header, and transcript each grew their own spinner copy. Export the frames and cadence from transcript.ts, pass the render's `now` into buildTranscriptLines so both spinners show the same frame, and mark a running tool's partial output with a neutral dot instead of a success glyph it has not earned yet. --- extensions/subagents/src/ui/takeover.ts | 33 ++++++++++++++--------- extensions/subagents/src/ui/transcript.ts | 20 +++++++++++--- extensions/subagents/transcript.test.ts | 7 +++-- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index f3f3073c..5b7ab016 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -17,7 +17,12 @@ import { sanitizeTerminalText } from "../../../shared/terminal-text.ts"; import { formatElapsed, type SubagentSnapshot } from "../domain.ts"; import { formatContextUtilization } from "../format.ts"; import type { SubagentReadModel } from "../manager.ts"; -import { TranscriptRenderer, buildTranscriptLines } from "./transcript.ts"; +import { + SPINNER_INTERVAL_MS, + TranscriptRenderer, + buildTranscriptLines, + spinnerFrame, +} from "./transcript.ts"; export function sanitizeSubagentDisplayLine(value: string) { return sanitizeTerminalText(value).replace(/\s+/g, " ").trim(); @@ -30,18 +35,19 @@ function configuredKeys( return keybindings.getKeys(binding).join("/") || "unbound"; } -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -const SPINNER_INTERVAL_MS = 120; - -function statusGlyph(snap: SubagentSnapshot, theme: Theme): string { +/** + * One spinner definition for the whole subagent UI: the dashboard glyph, the + * takeover header, and the transcript's live tools must animate in step, so the + * frames and their cadence live in `transcript.ts` and are imported here. + */ +function statusGlyph( + snap: SubagentSnapshot, + theme: Theme, + now = Date.now(), +): string { switch (snap.status) { case "running": - return theme.fg( - "warning", - SPINNER_FRAMES[ - Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length - ], - ); + return theme.fg("warning", spinnerFrame(now)); case "done": return theme.fg("success", "✓"); case "error": @@ -578,6 +584,7 @@ export class TakeoverView implements Component, Focusable { render(width: number): string[] { const theme = this.theme; + const now = Date.now(); const lines: string[] = []; const snap = this.snap(); @@ -594,7 +601,7 @@ export class TakeoverView implements Component, Focusable { const title = sanitizeSubagentDisplayLine(snap.title) || snap.id; const headerLeft = theme.fg("borderAccent", "─ ") + - statusGlyph(snap, theme) + + statusGlyph(snap, theme, now) + " " + theme.fg("accent", theme.bold(title)) + theme.fg("borderAccent", " "); @@ -630,11 +637,13 @@ export class TakeoverView implements Component, Focusable { // Fixed-height transcript viewport. Errors consume a row, but scroll state // is represented by the following rule so its height never changes. + // `now` is shared with the header glyph so both spinners show one frame. const transcript = buildTranscriptLines( snap, width, theme, this.transcriptRenderer, + { now }, ); const viewport = this.viewportHeight(); const errorRows = snap.errorText ? 1 : 0; diff --git a/extensions/subagents/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index 7b0c9c17..2d43cdad 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -31,8 +31,11 @@ export const SPINNER_FRAMES = [ "⠏", ] as const; +/** Frame cadence, shared with the dashboard and takeover headers. */ +export const SPINNER_INTERVAL_MS = 120; + export function spinnerFrame(now: number) { - const frame = Math.floor(now / 120) % SPINNER_FRAMES.length; + const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length; return SPINNER_FRAMES[ (frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length ]; @@ -185,13 +188,22 @@ function firstOutputPreview(outputPreview?: string) { ); } +/** + * `settled: false` marks partial output from a tool that is still running: a + * success glyph there would claim an outcome the tool has not reached yet. + */ function renderResultLine( theme: Theme, isError: boolean, outputPreview: string, width: number, + settled = true, ) { - const glyph = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const glyph = isError + ? theme.fg("error", "✗") + : settled + ? theme.fg("success", "✓") + : theme.fg("dim", "·"); const preview = outputPreview || "(no output)"; const content = isError ? theme.fg(outputPreview ? "error" : "dim", preview) @@ -336,7 +348,9 @@ export class TranscriptRenderer { ); const preview = firstOutputPreview(tool.outputPreview); if (preview) - out.push(renderResultLine(theme, !!tool.isError, preview, width)); + out.push( + renderResultLine(theme, !!tool.isError, preview, width, !!tool.done), + ); } // Queued steering/follow-up messages: show them immediately so Enter diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index 01f85770..8d7a457b 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -273,9 +273,12 @@ test("running live tools keep the settled body shape", () => { { now: 0 }, ); - assert.equal(running[0], "⠋ $ git status"); + // The body never reflows on settle: only the leading glyph changes, and a + // still-running tool's partial output must not claim success yet. + assert.deepEqual(running, ["⠋ $ git status", " · clean"]); assert.deepEqual(settled, ["✓ $ git status", " ✓ clean"]); - assert.equal(running[1], settled[1]); + assert.equal(running[0]?.slice(1), settled[0]?.slice(1)); + assert.equal(running[1]?.slice(3), settled[1]?.slice(3)); }); test("spinnerFrame is deterministic and advances every 120ms", () => { From a73357a6728bff5cf1a6cb7bdec832058e7db53a Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Fri, 21 Aug 2026 00:00:37 +0800 Subject: [PATCH 5/5] fix(subagents): render one glyph per tool execution, not one per source Review found the running/settled parity contract was vacuous in production: message_end lands the assistant toolCall in the transcript before tool_execution_start creates the live entry, and ToolEnd deletes that entry rather than marking it done. So a running command was rendered twice and the block reflowed two columns left when it settled. Give an execution exactly one glyph, on its command line: the live block owns the call while the tool runs, the transcript's call line takes over with the settled glyph in the same column, and output lines are plain indented text. The item cache now keys on width plus tool phase, so a pending glyph cannot outlive its phase. Also sanitize tool names in the dashboard activity string (they come from the child's own events) and share one timestamp per frame. --- extensions/subagents/src/ui/takeover.ts | 23 ++- extensions/subagents/src/ui/transcript.ts | 194 +++++++++++++++++----- extensions/subagents/transcript.test.ts | 117 +++++++++++-- 3 files changed, 273 insertions(+), 61 deletions(-) diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index 5b7ab016..77dbf24c 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -55,23 +55,33 @@ function statusGlyph( } } +/** + * Tool names come from the child's own tool-call events, so they are as + * untrusted as their arguments and must be sanitized before reaching the + * terminal: `truncateToWidth` only ignores escape sequences while measuring. + */ function runningActivity(snap: SubagentSnapshot) { if (snap.status !== "running") return ""; const liveTool = snap.liveTools.at(-1); if (liveTool) { + const name = sanitizeSubagentDisplayLine(liveTool.name); const args = truncateToWidth( sanitizeSubagentDisplayLine(liveTool.argsPreview ?? ""), 48, ); - return args ? `${liveTool.name} · ${args}` : liveTool.name; + return args ? `${name} · ${args}` : name; } for (const item of [...snap.transcript].reverse()) { - if (item.kind === "toolResult") return item.name; + if (item.kind === "toolResult") { + return sanitizeSubagentDisplayLine(item.name); + } if (item.kind !== "assistant") continue; const tool = [...item.parts] .reverse() .find((part) => part.type === "toolCall"); - if (tool?.type === "toolCall") return tool.name; + if (tool?.type === "toolCall") { + return sanitizeSubagentDisplayLine(tool.name); + } } return ""; } @@ -271,6 +281,8 @@ export class SubagentDashboard implements Component { const subs = this.subs(); reconcileDashboardSelection(this.selection, subs); + // One timestamp per frame so every row's spinner shows the same frame. + const now = Date.now(); const rows = this.tui.terminal.rows || 30; const maxBodyHeight = Math.max(1, rows - 5); const bodyHeight = @@ -296,7 +308,7 @@ export class SubagentDashboard implements Component { ); const divider = theme.fg("border", "│"); - const rowLines = this.renderRows(subs, innerWidth, bodyHeight); + const rowLines = this.renderRows(subs, innerWidth, bodyHeight, now); for (const row of rowLines) { lines.push(divider + this.pad(row, innerWidth) + divider); } @@ -326,6 +338,7 @@ export class SubagentDashboard implements Component { subs: ReadonlyArray, width: number, height: number, + now: number, ): string[] { const theme = this.theme; const out: string[] = []; @@ -355,7 +368,7 @@ export class SubagentDashboard implements Component { ? theme.fg("accent", safeTitle) : theme.fg("text", safeTitle); const activity = runningActivity(snap); - const prefix = ` ${marker} ${statusGlyph(snap, theme)} `; + const prefix = ` ${marker} ${statusGlyph(snap, theme, now)} `; const utilization = formatContextUtilization(snap.usage); const metadata = [ diff --git a/extensions/subagents/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index 2d43cdad..c56dab97 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -172,7 +172,9 @@ function renderThinking(theme: Theme, text: string, width: number) { function renderToolBody(theme: Theme, name: string, argsPreview?: string) { const toolName = sanitizeText(name); const preview = summarizeToolArgs(toolName, argsPreview); - if (toolName === "bash") return theme.fg("dim", `$ ${preview ?? ""}`); + // The `$` form only earns its prompt when there is a command to show; a bare + // `$ ` would read as an empty shell line. + if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`); return ( theme.fg("dim", "→ ") + theme.fg("toolTitle", toolName) + @@ -189,32 +191,60 @@ function firstOutputPreview(outputPreview?: string) { } /** - * `settled: false` marks partial output from a tool that is still running: a - * success glyph there would claim an outcome the tool has not reached yet. + * One execution owns exactly one glyph column, on its command line. Output + * lines are plain indented text so a block keeps identical columns from the + * moment the command starts to the moment it settles. */ -function renderResultLine( +export type ToolPhase = "live" | "ok" | "error" | "pending"; + +function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) { + switch (phase) { + case "live": + return theme.fg("warning", spinnerFrame(now)); + case "ok": + return theme.fg("success", "✓"); + case "error": + return theme.fg("error", "✗"); + case "pending": + return theme.fg("dim", "·"); + } +} + +/** Command line: ` $ cmd` for bash, ` → name args` otherwise. */ +function renderToolLine( + theme: Theme, + phase: ToolPhase, + name: string, + argsPreview: string | undefined, + width: number, + now: number, +) { + return truncateToWidth( + `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview)}`, + width, + ); +} + +/** Output line: indented under the command, no second glyph. */ +function renderOutputLine( theme: Theme, isError: boolean, outputPreview: string, width: number, - settled = true, ) { - const glyph = isError - ? theme.fg("error", "✗") - : settled - ? theme.fg("success", "✓") - : theme.fg("dim", "·"); const preview = outputPreview || "(no output)"; const content = isError ? theme.fg(outputPreview ? "error" : "dim", preview) : theme.fg("dim", preview); - return truncateToWidth(` ${glyph} ${content}`, width); + return truncateToWidth(` ${content}`, width); } function renderAssistantItem( theme: Theme, item: Extract, width: number, + phases: ReadonlyMap, + now: number, ) { const out: string[] = []; for (const part of item.parts) { @@ -229,11 +259,13 @@ function renderAssistantItem( ), ); } else if (part.type === "toolCall") { + const phase = phases.get(part.toolId) ?? "pending"; + // A live tool is rendered by the live block, which owns the spinner and + // the streaming output; rendering the call here too would show the same + // command twice and make the block reflow when the tool settles. + if (phase === "live") continue; out.push( - truncateToWidth( - renderToolBody(theme, part.name, part.argsPreview), - width, - ), + renderToolLine(theme, phase, part.name, part.argsPreview, width, now), ); } } @@ -244,15 +276,28 @@ function renderToolResultItem( theme: Theme, item: Extract, width: number, + paired: boolean, + now: number, ) { - return [ - renderResultLine( - theme, - item.isError, - firstOutputPreview(item.outputPreview), - width, - ), - ]; + const preview = firstOutputPreview(item.outputPreview); + // An orphan result (its call is not the previous item) still needs a glyph: + // there is no command line above it to carry one. + if (!paired) { + return [ + renderToolLine( + theme, + item.isError ? "error" : "ok", + item.name, + undefined, + width, + now, + ), + ...(preview + ? [renderOutputLine(theme, item.isError, preview, width)] + : []), + ]; + } + return [renderOutputLine(theme, item.isError, preview, width)]; } function isPairedToolResult( @@ -274,10 +319,74 @@ function renderTranscriptItem( theme: Theme, item: TranscriptItem, width: number, + context: ItemContext, + now: number, ) { if (item.kind === "user") return renderUserText(theme, item.text, width); - if (item.kind === "assistant") return renderAssistantItem(theme, item, width); - return renderToolResultItem(theme, item, width); + if (item.kind === "assistant") { + return renderAssistantItem(theme, item, width, context.phases, now); + } + return renderToolResultItem(theme, item, width, context.paired, now); +} + +interface ItemContext { + readonly phases: ReadonlyMap; + readonly paired: boolean; + /** Cache discriminator: identity plus width is not enough on its own. */ + readonly token: string; +} + +/** + * An item's rendering depends on its neighbours (does a call have its result + * yet?) and on live state (is the call still running?), so the cache key has to + * carry that context or a stale glyph would outlive the phase it described. + */ +function itemContext( + transcript: ReadonlyArray, + index: number, + liveIds: ReadonlySet, +): ItemContext { + const item = transcript[index]!; + if (item.kind === "user") + return { phases: new Map(), paired: false, token: "" }; + if (item.kind === "toolResult") { + const paired = isPairedToolResult(transcript[index - 1], item); + return { phases: new Map(), paired, token: paired ? "p" : "o" }; + } + + const phases = new Map(); + for (const part of item.parts) { + if (part.type !== "toolCall") continue; + if (liveIds.has(part.toolId)) { + phases.set(part.toolId, "live"); + continue; + } + const result = findResult(transcript, index, part.toolId); + phases.set( + part.toolId, + result ? (result.isError ? "error" : "ok") : "pending", + ); + } + return { + phases, + paired: false, + token: [...phases].map(([id, phase]) => `${id}:${phase}`).join(","), + }; +} + +/** The result for a call, if it has already landed later in the transcript. */ +function findResult( + transcript: ReadonlyArray, + callIndex: number, + toolId: string, +) { + for (let index = callIndex + 1; index < transcript.length; index++) { + const candidate = transcript[index]; + if (candidate?.kind === "toolResult" && candidate.toolId === toolId) { + return candidate; + } + } + return undefined; } /** @@ -286,7 +395,7 @@ function renderTranscriptItem( * from their component's invalidate() when Pi changes theme. */ export class TranscriptRenderer { - private itemCache = new WeakMap>(); + private itemCache = new WeakMap>(); render( snap: SubagentSnapshot, @@ -296,18 +405,22 @@ export class TranscriptRenderer { ) { const out: string[] = []; const now = options?.now ?? Date.now(); + const liveIds = new Set(snap.liveTools.map((tool) => tool.toolId)); for (let index = 0; index < snap.transcript.length; index++) { const item = snap.transcript[index]; - const cached = this.itemCache.get(item)?.get(width); - const lines = cached ?? renderTranscriptItem(theme, item, width); + const context = itemContext(snap.transcript, index, liveIds); + const key = `${width}|${context.token}`; + const cached = this.itemCache.get(item)?.get(key); + const lines = + cached ?? renderTranscriptItem(theme, item, width, context, now); if (!cached) { - const widths = this.itemCache.get(item) ?? new Map(); + const widths = this.itemCache.get(item) ?? new Map(); if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) { const oldestWidth = widths.keys().next().value; if (oldestWidth !== undefined) widths.delete(oldestWidth); } - widths.set(width, lines); + widths.set(key, lines); this.itemCache.set(item, widths); } if (lines.length > 0) { @@ -332,25 +445,22 @@ export class TranscriptRenderer { if (out.length === before + 1) out.pop(); } - // Live tool executions (present until the ToolEnd lands in the transcript). + // Live tool executions. The manager drops a live entry when its ToolEnd + // lands, and the transcript's call line then takes over with the settled + // glyph in the same column, so the block never reflows. for (const tool of snap.liveTools) { if (out.length > 0) out.push(""); - const marker = tool.done + const phase: ToolPhase = tool.done ? tool.isError - ? theme.fg("error", "✗") - : theme.fg("success", "✓") - : theme.fg("warning", spinnerFrame(now)); + ? "error" + : "ok" + : "live"; out.push( - truncateToWidth( - `${marker} ${renderToolBody(theme, tool.name, tool.argsPreview)}`, - width, - ), + renderToolLine(theme, phase, tool.name, tool.argsPreview, width, now), ); const preview = firstOutputPreview(tool.outputPreview); if (preview) - out.push( - renderResultLine(theme, !!tool.isError, preview, width, !!tool.done), - ); + out.push(renderOutputLine(theme, !!tool.isError, preview, width)); } // Queued steering/follow-up messages: show them immediately so Enter diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index 8d7a457b..31e9dc5f 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -5,6 +5,7 @@ import { stripVTControlCharacters } from "node:util"; import { visibleWidth } from "@earendil-works/pi-tui"; import type { SubagentSnapshot } from "./src/domain.ts"; import { + SPINNER_INTERVAL_MS, TranscriptRenderer, buildTranscriptLines, sanitizeText, @@ -173,8 +174,8 @@ test("bash tool calls use shell prompts while other tools keep arrow form", () = ), ); - assert.match(rendered, /^\$ git status --porcelain/m); - assert.match(rendered, /→ read src\/index\.ts/); + assert.match(rendered, /^· \$ git status --porcelain/m); + assert.match(rendered, /· → read src\/index\.ts/); }); test("adjacent tool results form one block with a success glyph", () => { @@ -205,7 +206,8 @@ test("adjacent tool results form one block with a success glyph", () => { theme, ); - assert.deepEqual(lines, ["$ printf ok", " ✓ ok"]); + // One glyph per execution, on the command line; the output sits under it. + assert.deepEqual(lines, ["✓ $ printf ok", " ok"]); assert.ok(!lines.slice(0, -1).some((line) => line === "")); }); @@ -234,13 +236,29 @@ test("tool errors and empty results use status glyphs", () => { ), ); - assert.match(rendered, /✗ command failed/); - assert.match(rendered, /✓ \(no output\)/); + // Orphan results (no call above them) keep a glyph of their own. + assert.match(rendered, /✗ → bash/); + assert.match(rendered, /command failed/); + assert.match(rendered, /✓ → bash/); }); -test("running live tools keep the settled body shape", () => { +test("a running tool becomes settled without reflowing", () => { + const call = { + kind: "assistant" as const, + parts: [ + { + type: "toolCall" as const, + toolId: "live-1", + name: "bash", + argsPreview: '{"command":"git status"}', + }, + ], + }; + // Production order: the assistant message (with the call) lands in the + // transcript before tool_execution_start, so both sources describe one tool. const running = buildTranscriptLines( snapshot({ + transcript: [call], liveTools: [ { toolId: "live-1", @@ -257,13 +275,14 @@ test("running live tools keep the settled body shape", () => { ); const settled = buildTranscriptLines( snapshot({ - liveTools: [ + transcript: [ + call, { + kind: "toolResult", toolId: "live-1", name: "bash", - argsPreview: '{"command":"git status"}', + isError: false, outputPreview: "clean", - done: true, }, ], }), @@ -273,12 +292,82 @@ test("running live tools keep the settled body shape", () => { { now: 0 }, ); - // The body never reflows on settle: only the leading glyph changes, and a - // still-running tool's partial output must not claim success yet. - assert.deepEqual(running, ["⠋ $ git status", " · clean"]); - assert.deepEqual(settled, ["✓ $ git status", " ✓ clean"]); + // The command appears exactly once while running, and only the glyph changes + // when the tool settles: same line count, same columns. + assert.deepEqual(running, ["⠋ $ git status", " clean"]); + assert.deepEqual(settled, ["✓ $ git status", " clean"]); assert.equal(running[0]?.slice(1), settled[0]?.slice(1)); - assert.equal(running[1]?.slice(3), settled[1]?.slice(3)); + assert.equal(running[1], settled[1]); +}); + +test("the spinner advances between frames instead of freezing in the cache", () => { + const renderer = new TranscriptRenderer(); + const snap = snapshot({ + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: "live-1", + name: "bash", + argsPreview: '{"command":"sleep 1"}', + }, + ], + }, + ], + liveTools: [ + { toolId: "live-1", name: "bash", argsPreview: '{"command":"sleep 1"}' }, + ], + }); + + const first = renderer.render(snap, 80, theme, { now: 0 }); + const later = renderer.render(snap, 80, theme, { now: SPINNER_INTERVAL_MS }); + assert.notEqual(first[0], later[0]); + assert.equal(first[0]?.slice(1), later[0]?.slice(1)); +}); + +test("cached items are keyed by width and by tool phase", () => { + const renderer = new TranscriptRenderer(); + const call = { + kind: "assistant" as const, + parts: [ + { + type: "toolCall" as const, + toolId: "call-1", + name: "bash", + argsPreview: '{"command":"printf a-very-long-command-name"}', + }, + ], + }; + const pending = snapshot({ transcript: [call] }); + const wide = renderer.render(pending, 80, theme, { now: 0 }); + const narrow = renderer.render(pending, 24, theme, { now: 0 }); + assert.ok(wide.every((line) => visibleWidth(line) <= 80)); + assert.ok(narrow.every((line) => visibleWidth(line) <= 24)); + assert.notDeepEqual(wide, narrow); + + // The same item re-renders once its result lands: a width-only cache key + // would serve the stale pending glyph forever. + const settled = renderer.render( + snapshot({ + transcript: [ + call, + { + kind: "toolResult", + toolId: "call-1", + name: "bash", + isError: false, + outputPreview: "a", + }, + ], + }), + 80, + theme, + { now: 0 }, + ); + assert.match(wide[0]!, /^·/); + assert.match(settled[0]!, /^✓/); }); test("spinnerFrame is deterministic and advances every 120ms", () => {