diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index a9f72c99..77dbf24c 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,26 +35,55 @@ function configuredKeys( return keybindings.getKeys(binding).join("/") || "unbound"; } -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", "■"); + return theme.fg("warning", spinnerFrame(now)); 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"); +/** + * 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 ? `${name} · ${args}` : name; } + for (const item of [...snap.transcript].reverse()) { + 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 sanitizeSubagentDisplayLine(tool.name); + } + } + return ""; } // --- Entry points -------------------------------------------------------------- @@ -126,7 +160,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 +169,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 +186,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; } @@ -237,45 +281,36 @@ 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; - // 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); + const rowLines = this.renderRows(subs, innerWidth, bodyHeight, now); + for (const row of rowLines) { + lines.push(divider + this.pad(row, innerWidth) + divider); } // Bottom border @@ -303,37 +338,40 @@ class SubagentDashboard implements Component { subs: ReadonlyArray, width: number, height: number, + now: number, ): string[] { 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, now)} `; - // 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 +379,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; @@ -371,7 +430,7 @@ class SubagentDashboard implements Component { 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; @@ -386,7 +445,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; @@ -414,9 +473,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; @@ -431,6 +492,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 @@ -445,7 +513,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; @@ -504,59 +572,95 @@ 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 now = Date.now(); 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, now) + + " " + + 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. + // `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; - 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; @@ -572,39 +676,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/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index a8723c19..c56dab97 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -18,6 +18,29 @@ import type { SubagentSnapshot, TranscriptItem } from "../domain.ts"; const MAX_CACHED_WIDTHS_PER_ITEM = 2; +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 / SPINNER_INTERVAL_MS) % 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,10 +169,82 @@ 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); + // 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) + + (preview ? theme.fg("dim", ` ${preview}`) : "") + ); +} + +function firstOutputPreview(outputPreview?: string) { + return ( + sanitizeText(outputPreview ?? "") + .split("\n") + .find((line) => line.trim()) ?? "" + ); +} + +/** + * 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. + */ +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, +) { + const preview = outputPreview || "(no output)"; + const content = isError + ? theme.fg(outputPreview ? "error" : "dim", preview) + : theme.fg("dim", preview); + 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) { @@ -164,12 +259,14 @@ 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)); + 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( + renderToolLine(theme, phase, part.name, part.argsPreview, width, now), + ); } } return out; @@ -179,27 +276,117 @@ function renderToolResultItem( theme: Theme, item: Extract, width: number, + paired: boolean, + now: 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), - ]; + 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( + 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, 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; } /** @@ -208,24 +395,43 @@ 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, width: number, theme: Theme) { + render( + snap: SubagentSnapshot, + width: number, + theme: Theme, + options?: { readonly now?: number }, + ) { const out: string[] = []; + const now = options?.now ?? Date.now(); + const liveIds = new Set(snap.liveTools.map((tool) => tool.toolId)); - for (const item of snap.transcript) { - const cached = this.itemCache.get(item)?.get(width); - const lines = cached ?? renderTranscriptItem(theme, item, width); + for (let index = 0; index < snap.transcript.length; index++) { + const item = snap.transcript[index]; + 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) 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(); @@ -239,19 +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", "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)); + ? "error" + : "ok" + : "live"; + out.push( + renderToolLine(theme, phase, tool.name, tool.argsPreview, width, now), + ); + const preview = firstOutputPreview(tool.outputPreview); + if (preview) + out.push(renderOutputLine(theme, !!tool.isError, preview, width)); } // Queued steering/follow-up messages: show them immediately so Enter @@ -288,6 +497,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/takeover.test.ts b/extensions/subagents/takeover.test.ts index 7a550c28..64b7f4df 100644 --- a/extensions/subagents/takeover.test.ts +++ b/extensions/subagents/takeover.test.ts @@ -1,11 +1,86 @@ 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, + TakeoverView, 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 +112,118 @@ 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(); + } +}); + +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(); + } +}); diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index 6f49f670..31e9dc5f 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -5,9 +5,11 @@ 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, + spinnerFrame, summarizeToolArgs, } from "./src/ui/transcript.ts"; @@ -143,6 +145,279 @@ 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, + ); + + // 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 === "")); +}); + +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, + ), + ); + + // 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("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", + name: "bash", + argsPreview: '{"command":"git status"}', + outputPreview: "clean", + }, + ], + }), + 80, + theme, + undefined, + { now: 0 }, + ); + const settled = buildTranscriptLines( + snapshot({ + transcript: [ + call, + { + kind: "toolResult", + toolId: "live-1", + name: "bash", + isError: false, + outputPreview: "clean", + }, + ], + }), + 80, + theme, + undefined, + { now: 0 }, + ); + + // 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], 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", () => { + 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 +444,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/, ); });