// ultra — live progress sinks (Task 7). TWO surface-specific views over the // shared `progress.ts` reducer, render + input only, NO orchestration: // // • TOOL sink (Layer 7a) — a `renderResult(result, opts, theme, ctx)` that // folds NOTHING itself: it reads the reducer state off `result.details` // (pushed through the SDK `onUpdate` channel by the engine) and draws a // collapsed/expanded board. Read-only — no `controls`, no steering, and it // NEVER renders per-agent bodies/transcripts. Matches Pi tool rendering. // // • COMMAND overlay (Layers 7b/7c) — a `ctx.ui.custom` `Component` that folds // the `onProgress` stream itself, renders the board + a focused-agent // drill-in transcript (follow-mode + throttle), and is the ONLY surface // with per-agent steering/abort. Modeled on `devil/ui.ts`. // // Both sinks consume only what `progress.ts` defines; this module never // constructs an SDK session and never holds orchestration logic. import type { AgentToolResult, Theme, ToolDefinition, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, getMarkdownTheme, keyHint, ToolExecutionComponent, } from "@earendil-works/pi-coding-agent"; import { type Component, Markdown, matchesKey, Text, type TUI, truncateToWidth, } from "@earendil-works/pi-tui"; import { formatStructuredText, formatUsage, formatValueMarkdown, summarizeValue, } from "./display.ts"; import { type AgentControls, type AgentStatus, initialReducerState, type PlannedPhase, type ReducerState, reduceProgress, type TranscriptItem, type UltraProgressEvent, } from "./progress.ts"; import { formatUltraResultMarkdown } from "./result.ts"; import type { TokenUsage } from "./runner.ts"; // --------------------------------------------------------------------------- // Shared board helpers (used by both sinks) // --------------------------------------------------------------------------- const STATUS_ICON: Record = { queued: "󰅐", running: "󰐌", done: "󰗠", dropped: "󰅙", }; const PHASE_ICON = { "agents-pending": "󰭙", "condition-pending": "󰘬", skipped: "−", } as const; const RESET = /\x1b\[0m/g; function fitLine(text: string, width: number): string { const w = Math.max(1, width); const out = truncateToWidth(text, w); return text.includes("\x1b") ? out : out.replace(RESET, ""); } /** `phase N/len` from the reducer's ordered phase plan. */ function phaseCounter(state: ReducerState): string { if (state.phases.length === 0) return ""; const idx = state.phase ? state.phases.findIndex((phase) => phase.phase === state.phase) + 1 : 0; return `phase ${idx}/${state.phases.length}`; } /** One-line aggregate with terminal, active, queued, and later work counts. */ function summaryLine(state: ReducerState): string { const parts: string[] = []; const pc = phaseCounter(state); if (pc) parts.push(pc); parts.push(`${state.done}/${state.total} done`); if (state.running > 0) parts.push(`${state.running} active`); if (state.queued > 0) parts.push(`${state.queued} queued`); if (state.later > 0) parts.push(`${state.later} later phase${state.later === 1 ? "" : "s"}`); if (state.dropped > 0) parts.push(`${state.dropped}✗`); return parts.join(" · "); } function headerLine(state: ReducerState, brand = "ultra"): string { const wallTime = state.startedAt === undefined ? undefined : `${elapsed(state.startedAt, state.endedAt)} elapsed`; return [brand, state.workflowName, wallTime].filter(Boolean).join(" · "); } function descriptionLine(state: ReducerState): string | undefined { return state.workflowDescription; } function elapsed(startedAt: number, endedAt = Date.now()): string { const seconds = Math.max(0, Math.floor((endedAt - startedAt) / 1000)); if (seconds < 60) return `${seconds}s`; return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } type AgentRow = ReducerState["rows"][number]; type AgentBoardLine = { text: string; color: "accent" | "error" | "dim"; bold?: boolean; }; function agentModel(row: AgentRow): string | undefined { const model = row.tier ? `${row.tier}${row.model ? ` → ${row.model}` : ""}` : (row.model ?? row.failure?.model); const thinkingLevel = row.thinkingLevel ?? row.failure?.thinkingLevel; return [model, thinkingLevel].filter(Boolean).join(" · ") || undefined; } function agentMetadata(row: AgentRow, state: ReducerState): string { const task = row.summary?.trim() || row.agentId; const parts = [task === row.agentId ? undefined : row.agentId, row.status]; if (row.usage) parts.push(formatUsage(row.usage).summary); if (row.failure) parts.push( `${row.failure.retryable ? "retryable" : "terminal"} · ${row.failure.attempts} attempt${row.failure.attempts === 1 ? "" : "s"}`, ); const model = agentModel(row); if (model) parts.push(model); if (row.startedAt !== undefined) parts.push(elapsed(row.startedAt, row.endedAt)); if (row.cached) parts.push("checkpoint reused"); if (row.status === "queued") parts.push(queuedDetail(state, row.phase)); return parts.filter(Boolean).join(" · "); } function agentBoardLines( row: AgentRow, state: ReducerState, prefix: string, ): AgentBoardLine[] { const task = row.summary?.trim() || row.agentId; const color = row.status === "running" ? "accent" : row.status === "dropped" ? "error" : "dim"; const primary = `${prefix}${STATUS_ICON[row.status]} ${task}`; const metadata = agentMetadata(row, state); if (row.status === "done" || row.status === "queued") { return [ { text: primary, color }, { text: `${" ".repeat(prefix.length + 2)}${metadata}`, color: "dim", }, ]; } const activity = row.failure ? `${row.failure.code}: ${row.failure.message}` : row.action?.trim(); const secondary = [activity, metadata].filter(Boolean); return [ { text: primary, color, bold: true }, ...secondary.map( (text): AgentBoardLine => ({ text: `${" ".repeat(prefix.length + 2)}${text}`, color: "dim", }), ), ]; } type DisplayRow = | { kind: "agent"; row: ReducerState["rows"][number] } | { kind: "phase"; phase: PlannedPhase; expanded: boolean }; function rowsInPhase(state: ReducerState, phase: string) { return state.rows.filter((row) => row.phase === phase); } function displayRows( state: ReducerState, expanded: (phase: PlannedPhase) => boolean, ): DisplayRow[] { const out: DisplayRow[] = []; const seen = new Set(); for (const phase of state.phases) { const open = expanded(phase); out.push({ kind: "phase", phase, expanded: open }); if (!open) continue; for (const row of rowsInPhase(state, phase.phase)) { seen.add(row.agentId); out.push({ kind: "agent", row }); } } for (const row of state.rows) { if ( !seen.has(row.agentId) && !state.phases.some((p) => p.phase === row.phase) ) out.push({ kind: "agent", row }); } return out; } function phaseLabel( state: ReducerState, phase: PlannedPhase, expanded: boolean, ): string { if (phase.status === "agents-pending") return `${PHASE_ICON[phase.status]} ${phase.phase} · agents not planned yet`; if (phase.status === "condition-pending") return `${PHASE_ICON[phase.status]} ${phase.phase} · conditional`; if (phase.status === "skipped") return `${PHASE_ICON[phase.status]} ${phase.phase} · skipped`; const rows = rowsInPhase(state, phase.phase); const done = rows.filter((row) => row.status === "done").length; const running = rows.filter((row) => row.status === "running").length; const dropped = rows.filter((row) => row.status === "dropped").length; const queued = rows.filter((row) => row.status === "queued").length; const counts = [ `${done}/${rows.length} done`, running ? `${running} active` : "", queued ? `${queued} queued` : "", dropped ? `${dropped} dropped` : "", ] .filter(Boolean) .join(" · "); if (phase.status === "done") return `${dropped ? STATUS_ICON.dropped : STATUS_ICON.done} ${phase.phase}${counts ? ` · ${counts}` : " · done"}`; if (state.phase === phase.phase) return `${expanded ? "▼" : "▶"} ${phase.phase}${counts ? ` · ${counts}` : ""}`; return `${STATUS_ICON.queued} ${phase.phase}${rows.length ? ` · ${rows.length} queued` : ""}`; } function phaseDetail(phase: PlannedPhase): string | undefined { if (phase.status === "agents-pending") return "earlier results determine which agents are needed"; if (phase.status === "condition-pending") return "earlier results determine whether this phase runs"; return undefined; } function queuedDetail(state: ReducerState, phase: string): string { return state.phase === phase ? "waiting for concurrency slot" : "starts when phase begins"; } function automaticPhase(state: ReducerState): string | undefined { const current = state.phases.find( (phase) => phase.phase === state.phase && phase.status === "resolved", ); return (current ?? state.phases.find((phase) => phase.status === "resolved")) ?.phase; } function toolPhaseExpanded(state: ReducerState, phase: PlannedPhase): boolean { if (rowsInPhase(state, phase.phase).some((row) => row.status === "dropped")) return true; return phase.phase === automaticPhase(state); } function aggregateUsage(state: ReducerState): TokenUsage | undefined { if (state.workflowResult?.tokenUsage) return state.workflowResult.tokenUsage; let usage: TokenUsage | undefined; for (const row of state.rows) { if (!row.usage) continue; usage ??= { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0, cost: 0, }; usage.input += row.usage.input; usage.output += row.usage.output; usage.total += row.usage.total; usage.cacheRead += row.usage.cacheRead; usage.cacheWrite += row.usage.cacheWrite; usage.cost += row.usage.cost; } return usage; } function metricsLines(state: ReducerState, expanded: boolean): string[] { if (state.total === 0) return []; const usage = aggregateUsage(state); const formatted = usage ? formatUsage(usage) : undefined; const resumed = state.rows.filter((row) => row.cached).length; if (!usage && state.done === 0 && resumed === 0) return []; return [ [ formatted?.summary, `checkpoint ${state.done}/${state.total}`, resumed ? `${resumed} resumed` : "", ] .filter(Boolean) .join(" · "), ...(expanded && formatted ? [formatted.detail] : []), ]; } function divider(label: string, width: number): string { const w = Math.max(1, width); const text = ` ${label} `; if (text.length >= w) return fitLine(label, w); const left = Math.floor((w - text.length) / 2); const right = w - text.length - left; return `${"─".repeat(left)}${text}${"─".repeat(right)}`; } /** The collapsed expand hint. Guarded: `keyHint` needs an initialised theme. */ function expandHint(): string { try { return keyHint("app.tools.expand", "to expand"); } catch { return "to expand"; } } // --------------------------------------------------------------------------- // Layer 7a — TOOL sink: `renderResult` over the reducer state // --------------------------------------------------------------------------- /** * Pure board renderer for the tool surface. Collapsed = aggregate counts + * bounded phase and run summaries + the expand hint (never action or transcript * bodies). Expanded = phase-first summaries with active and failed phases opened, * plus model/usage metadata and the final outcome. The drill-in transcript is * the OVERLAY's job, never the tool sink's, so `state.transcripts` is never read here. */ function renderToolBoard( state: ReducerState, options: ToolRenderResultOptions, theme: Theme, width: number, ): string[] { const w = Math.max(1, width); const lines: string[] = []; const header = `${headerLine(state, theme.bold("ultra"))} · ${summaryLine(state)}`; lines.push(theme.fg("accent", fitLine(header, w))); const description = descriptionLine(state); if (description) lines.push(theme.fg("muted", fitLine(description, w))); for (const metrics of metricsLines(state, options.expanded)) lines.push(theme.fg("muted", fitLine(metrics, w))); if (!options.expanded) { const focus = Math.max( 0, state.phases.findIndex((phase) => phase.phase === state.phase), ); const first = Math.max(0, Math.min(focus - 1, state.phases.length - 3)); if (first > 0) lines.push(theme.fg("dim", fitLine(` ${first} earlier phases`, w))); for (const phase of state.phases.slice(first, first + 3)) lines.push( theme.fg("muted", fitLine(` ${phaseLabel(state, phase, false)}`, w)), ); const remaining = state.phases.length - first - 3; if (remaining > 0) lines.push(theme.fg("dim", fitLine(` ${remaining} later phases`, w))); const current = state.phase ?? state.phases.find((phase) => phase.status === "resolved")?.phase; const runs = current ? rowsInPhase(state, current) : []; const ranked = (["running", "dropped", "queued", "done"] as const).flatMap( (status) => runs.filter((row) => row.status === status), ); for (const row of ranked.slice(0, 3)) { const summary = row.summary?.trim(); const label = summary ? `${row.agentId} · ${summary}` : row.agentId; lines.push( theme.fg( row.status === "dropped" ? "error" : "muted", fitLine(` ${STATUS_ICON[row.status]} ${label}`, w), ), ); } if (ranked.length > 3) lines.push( theme.fg("dim", fitLine(` ${ranked.length - 3} more runs`, w)), ); if (state.workflowResult?.fullOutputPath) lines.push( theme.fg( "muted", fitLine(`Evidence: ${state.workflowResult.fullOutputPath}`, w), ), ); lines.push(theme.fg("muted", fitLine(expandHint(), w))); return lines.map((l) => fitLine(l, w)); } // Expanded: one phase row each, with only active or failed phases opened. for (const display of displayRows(state, (phase) => toolPhaseExpanded(state, phase), )) { if (display.kind === "phase") { const color = display.phase.status === "done" ? "success" : state.phase === display.phase.phase ? "accent" : "dim"; lines.push( theme.fg( color, fitLine(phaseLabel(state, display.phase, display.expanded), w), ), ); const detail = phaseDetail(display.phase); if (detail) lines.push(theme.fg("muted", fitLine(` └ ${detail}`, w))); continue; } for (const line of agentBoardLines(display.row, state, " ")) { const text = line.bold ? theme.bold(line.text) : line.text; lines.push(theme.fg(line.color, fitLine(text, w))); } } if (state.workflowResult) { lines.push(theme.fg("border", divider("outcome", w))); lines.push( ...new Markdown( formatUltraResultMarkdown(state.workflowResult), 0, 0, getMarkdownTheme(), ).render(w), ); } return lines.map((l) => fitLine(l, w)); } /** * A throwaway `Component` that draws the tool-surface board for ONE render. * It holds no cache: every `render(width)` recomputes from the snapshot it was * built with, so a fresh `renderResult` call (one per `onUpdate`, P2) with fresh * `details` always tracks the latest reducer state (no frozen snapshot). */ class UltraResultComponent implements Component { constructor( private readonly state: ReducerState, private readonly options: ToolRenderResultOptions, private readonly theme: Theme, ) {} render(width: number): string[] { return renderToolBoard(this.state, this.options, this.theme, width); } invalidate(): void { // No cached state — nothing to drop. } } /** * Accept `result.details` only when it structurally IS a reducer state. It * reaches us over an SDK-controlled channel (structured-cloned per `onUpdate` * tick, and re-hydrated from session persistence on resume), so a truthy-but- * foreign value can arrive — a resumed/foreign tool result, a payload from * another producer, a serialization wrapper. The board reads `state.phases` * and `state.rows` unguarded, so anything lacking them falls back to an empty * board. This is the single choke point: an uncaught throw in `renderResult` * escapes tool-error handling and crashes the whole TUI render loop. */ function asReducerState(details: unknown): ReducerState { const d = details as Partial | null | undefined; const phaseStatuses: readonly string[] = [ "resolved", "agents-pending", "condition-pending", "done", "skipped", ]; const agentStatuses: readonly string[] = [ "queued", "running", "done", "dropped", ]; const phasesValid = Array.isArray(d?.phases) && d.phases.every( (phase) => typeof phase === "object" && phase !== null && typeof phase.phase === "string" && phaseStatuses.includes(phase.status) && Array.isArray(phase.agentIds) && phase.agentIds.every((agentId: unknown) => typeof agentId === "string"), ); const rowsValid = Array.isArray(d?.rows) && d.rows.every( (row) => typeof row === "object" && row !== null && typeof row.agentId === "string" && typeof row.phase === "string" && agentStatuses.includes(row.status), ); return d && phasesValid && rowsValid ? (d as ReducerState) : initialReducerState(); } /** * Tool-surface `renderResult`: reads the reducer state off `result.details` * (pushed through `onUpdate`) and returns a board `Component`. Read-only — no * `controls`, no steering. SDK errors have text content, not reducer details; * render them before the board fallback so failed calls never look successful. */ export function renderUltraResult( result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme, context?: { isError: boolean }, ): Component { if (context?.isError) { const message = result.content .filter((part) => part.type === "text") .map((part) => part.text) .join("\n") .trim() || "Workflow failed without an error message."; if (options.expanded) return new Text(theme.fg("error", `ultra · failed\n${message}`), 0, 0); return { render: (width) => [ theme.fg( "error", fitLine( `ultra · failed: ${message.replace(/\s+/g, " ")}`, Math.min(width, 160), ), ), theme.fg("muted", fitLine(expandHint(), width)), ], invalidate() {}, }; } return new UltraResultComponent( asReducerState(result?.details), options, theme, ); } // --------------------------------------------------------------------------- // Layers 7b/7c — COMMAND overlay: board + drill-in transcript + steer/abort // --------------------------------------------------------------------------- /** * Per-agent controls as the OVERLAY consumes them. Extends the locked * `AgentControls` (`{steer, abort}`) with an optional `followUp` for re-prompting * an agent that has already finished. The runner binds `followUp` on a finished * agent's controls (cross-task contract — see module notes); when absent the * follow-up affordance is simply inert. */ export interface OverlayAgentControls extends AgentControls { followUp?(text: string): Promise | void; } /** * Minimal host surface the overlay needs. The real `TUI` (passed by * `ctx.ui.custom`) satisfies this structurally; tests inject a fake with a * `requestRender` spy + a fixed terminal size. */ export interface OverlayHost { requestRender(): void; terminal: { rows: number; columns: number }; } export interface UltraOverlayOptions { host: OverlayHost; theme: Theme; /** Confirmed `Esc Esc` run-level abort through the command path's AbortController. */ onRunAbort: () => void; /** Injectable clock for the render throttle (default `Date.now`). */ now?: () => number; /** Render-throttle window in ms (default 80, matching devil). */ throttleMs?: number; workflowName?: string; workflowDescription?: string; /** Optional Pi TUI handle; when present, tool transcript rows use Pi's native tool renderer. */ toolUi?: TUI; toolsExpanded?: () => boolean; hideToolPreviews?: boolean; isToolsExpandKey?: (data: string) => boolean; onToolsExpand?: () => void; cwd?: string; } const DEFAULT_THROTTLE_MS = 80; // Use Pi's native views for source/code tools; generic tools get a field/list view. const nativeToolDefinitions = new Map< string, ( cwd: string, ) => NonNullable[4]> >([ ["read", createReadToolDefinition], ["write", createWriteToolDefinition], ["edit", createEditToolDefinition], ["bash", createBashToolDefinition], ["powershell", createPowerShellToolDefinition], ["grep", createGrepToolDefinition], ["find", createFindToolDefinition], ["ls", createLsToolDefinition], ]); /** Render transcript markdown with Pi's own markdown renderer. */ function transcriptLines(text: string, width: number): string[] { if (!text) return []; return new Markdown(text, 0, 0, getMarkdownTheme()).render( Math.max(1, width), ); } function normalizeToolResult(item: TranscriptItem & { kind: "tool" }) { const r = item.result as | { content?: unknown; details?: unknown; isError?: boolean } | undefined; const content = Array.isArray(r?.content) ? (r.content as Array<{ type: string; text?: string; data?: string; mimeType?: string; }>) : item.fallback ? [{ type: "text", text: item.fallback }] : []; return { content, details: r?.details, isError: item.isError ?? r?.isError ?? false, }; } /** * The `ctx.ui.custom` overlay (command surface only). Folds the `onProgress` * stream with the shared reducer, renders the board + a focused-agent drill-in * transcript (follow-mode), and exposes per-agent steer/abort + global abort. * Render + input only: it holds NO orchestration — the run is driven elsewhere * and pushes events in via `handleProgress`. */ export class UltraOverlayComponent implements Component { private state: ReducerState; private focusKey: string | undefined; private readonly expandedPhases = new Set(); private readonly collapsedPhases = new Set(); private followMode = true; private scrollOffset = 0; private lastMaxScroll = 0; private lastRenderAt = Number.NEGATIVE_INFINITY; private steerMode = false; private steerBuffer = ""; private steeredFlag = false; private abortArmedAt: number | undefined; private readonly transcriptTools = new Map< string, { component: ToolExecutionComponent; args: unknown; result: unknown; isError: boolean | undefined; isPartial: boolean | undefined; expanded: boolean; } >(); private readonly host: OverlayHost; private readonly theme: Theme; private readonly onRunAbort: () => void; private readonly now: () => number; private readonly throttleMs: number; private readonly toolUi: TUI | undefined; private readonly toolsExpanded: () => boolean; private readonly hideToolPreviews: boolean; private readonly isToolsExpandKey: (data: string) => boolean; private readonly onToolsExpand: () => void; private readonly cwd: string | undefined; constructor(options: UltraOverlayOptions) { this.state = initialReducerState({ workflowName: options.workflowName, workflowDescription: options.workflowDescription, }); this.host = options.host; this.theme = options.theme; this.onRunAbort = options.onRunAbort; this.now = options.now ?? Date.now; this.throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; this.toolUi = options.toolUi; this.toolsExpanded = options.toolsExpanded ?? (() => false); this.hideToolPreviews = options.hideToolPreviews ?? false; this.isToolsExpandKey = options.isToolsExpandKey ?? (() => false); this.onToolsExpand = options.onToolsExpand ?? (() => {}); this.cwd = options.cwd; } /** True once the human has submitted a steer this run (enqueue-time taint). */ get steered(): boolean { return this.steeredFlag; } /** Test/inspection accessor for the folded reducer state. */ get reducerState(): ReducerState { return this.state; } // ---- progress sink: fold + throttled re-render ----------------------- handleProgress(event: UltraProgressEvent): void { const previousPhase = this.state.phase; this.state = reduceProgress(this.state, event); if (event.kind === "start" && event.phase !== previousPhase) { if (previousPhase) { this.expandedPhases.delete(previousPhase); this.collapsedPhases.delete(previousPhase); } this.collapsedPhases.delete(event.phase); this.focusKey = `agent:${event.agentId}`; this.followMode = true; this.scrollOffset = 0; } this.ensureVisibleFocus(); const now = this.now(); // Terminal events bypass the throttle so the final board is never lost // behind the render window. const force = event.kind === "end" || event.kind === "phase"; if (force || now - this.lastRenderAt >= this.throttleMs) { this.lastRenderAt = now; this.host.requestRender(); } } // ---- input ------------------------------------------------------------ handleInput(data: string): void { if (this.isToolsExpandKey(data)) { this.onToolsExpand(); this.host.requestRender(); return; } if (this.steerMode) { this.handleSteerInput(data); return; } if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { const now = this.now(); if (this.abortArmedAt !== undefined && now - this.abortArmedAt <= 1500) { this.abortArmedAt = undefined; this.onRunAbort(); } else { this.abortArmedAt = now; this.host.requestRender(); } return; } this.abortArmedAt = undefined; if (matchesKey(data, "tab")) { this.focusBy(1, true); } else if (matchesKey(data, "down")) { this.focusBy(1, false); } else if (matchesKey(data, "up")) { this.focusBy(-1, false); } else if (matchesKey(data, "return") || matchesKey(data, "enter")) { this.toggleFocusedPhase(); } else if ( matchesKey(data, "pageUp") || matchesKey(data, "alt+up") || matchesKey(data, "ctrl+u") ) { this.scrollBy(-10); } else if ( matchesKey(data, "pageDown") || matchesKey(data, "alt+down") || matchesKey(data, "ctrl+d") ) { this.scrollBy(10); } else if (data === "s") { this.openSteer(); } else if (data === "x") { this.abortFocused(); } } private phaseExpanded(phase: PlannedPhase): boolean { if (this.collapsedPhases.has(phase.phase)) return false; if (this.expandedPhases.has(phase.phase)) return true; return phase.phase === automaticPhase(this.state); } private visibleRows(): DisplayRow[] { return displayRows(this.state, (phase) => this.phaseExpanded(phase)); } private displayKey(display: DisplayRow): string { return display.kind === "phase" ? `phase:${display.phase.phase}` : `agent:${display.row.agentId}`; } private ensureVisibleFocus(): void { const visible = this.visibleRows(); if (visible.length === 0) { this.focusKey = undefined; return; } if ( this.focusKey && visible.some((row) => this.displayKey(row) === this.focusKey) ) return; const firstAgent = visible.find((row) => row.kind === "agent"); this.focusKey = this.displayKey(firstAgent ?? visible[0]); } private focusBy(delta: number, agentsOnly: boolean): void { const candidates = this.visibleRows().filter( (row) => !agentsOnly || row.kind === "agent", ); if (candidates.length === 0) return; const current = candidates.findIndex( (row) => this.displayKey(row) === this.focusKey, ); const next = current < 0 ? 0 : agentsOnly ? (current + delta + candidates.length) % candidates.length : Math.min(candidates.length - 1, Math.max(0, current + delta)); this.focusKey = this.displayKey(candidates[next]); this.followMode = true; this.scrollOffset = 0; this.host.requestRender(); } private toggleFocusedPhase(): void { const phase = this.focusedPhase(); if (!phase) return; if (this.phaseExpanded(phase)) { this.expandedPhases.delete(phase.phase); this.collapsedPhases.add(phase.phase); this.focusKey = `phase:${phase.phase}`; } else { this.collapsedPhases.delete(phase.phase); this.expandedPhases.add(phase.phase); } this.host.requestRender(); } private scrollBy(delta: number): void { if (delta < 0) { this.followMode = false; this.scrollOffset = Math.max(0, this.scrollOffset + delta); } else { this.scrollOffset += delta; if (this.scrollOffset >= this.lastMaxScroll) this.followMode = true; } this.host.requestRender(); } private focusedRow() { if (!this.focusKey?.startsWith("agent:")) return undefined; const id = this.focusKey.slice("agent:".length); return this.state.rows.find((row) => row.agentId === id); } private focusedPhase(): PlannedPhase | undefined { if (!this.focusKey) return undefined; const row = this.focusedRow(); const name = row?.phase ?? this.focusKey.slice("phase:".length); return this.state.phases.find((phase) => phase.phase === name); } private openSteer(): void { if (!this.focusedRow()) return; this.steerMode = true; this.steerBuffer = ""; this.host.requestRender(); } private abortFocused(): void { const controls = this.focusedRow()?.controls as | OverlayAgentControls | undefined; if (controls?.abort) void Promise.resolve(controls.abort()).catch(() => {}); } private handleSteerInput(data: string): void { if (matchesKey(data, "escape")) { // Cancel the buffer back to normal mode — does NOT abort the run. this.steerMode = false; this.steerBuffer = ""; this.host.requestRender(); return; } if (matchesKey(data, "return") || matchesKey(data, "enter")) { this.submitSteer(); return; } if (matchesKey(data, "backspace")) { this.steerBuffer = this.steerBuffer.slice(0, -1); this.host.requestRender(); return; } // Accept a single printable character (excludes control bytes + DEL). if (data.length === 1) { const code = data.charCodeAt(0); if (code >= 0x20 && code !== 0x7f) { this.steerBuffer += data; this.host.requestRender(); } } } private submitSteer(): void { const text = this.steerBuffer; const row = this.focusedRow(); this.steerMode = false; this.steerBuffer = ""; this.host.requestRender(); if (!row || !text) return; const controls = row.controls as OverlayAgentControls | undefined; if (row.status === "running") { // Taint the run synchronously, at enqueue time (before the steer // promise settles) — this is what the test asserts and what v2 // caching keys off. this.steeredFlag = true; if (controls?.steer) void Promise.resolve(controls.steer(text)).catch(() => {}); } else { // A finished agent is re-prompted as a follow-up, not steered. if (controls?.followUp) void Promise.resolve(controls.followUp(text)).catch(() => {}); } } private transcriptTool( agentId: string, item: TranscriptItem & { kind: "tool" }, index: number, ): ToolExecutionComponent | undefined { if (!this.toolUi || !this.cwd) return undefined; const key = `${agentId}:${item.toolCallId ?? index}`; const expanded = this.toolsExpanded(); let cached = this.transcriptTools.get(key); if (!cached) { const component = new ToolExecutionComponent( item.toolName, item.toolCallId ?? key, item.args ?? {}, undefined, nativeToolDefinitions.get(item.toolName)?.(this.cwd) ?? ({ renderCall: (args, _theme, context) => new Markdown( `### ${item.toolName}\n${context.expanded ? formatValueMarkdown(args) : summarizeValue(args)}`, 0, 0, getMarkdownTheme(), ), renderResult: (result, options, theme, context) => { if (!options.expanded) return new Text( theme.fg( context.isError ? "error" : "dim", context.isError ? "Failed" : options.isPartial ? "Running" : "Finished", ), 0, 0, ); const parts = result.content.flatMap((part) => part.type === "text" ? [formatStructuredText(part.text)] : [], ); if (result.details !== undefined) parts.push("### Details", formatValueMarkdown(result.details)); return new Markdown(parts.join("\n\n"), 0, 0, getMarkdownTheme()); }, } satisfies Pick), this.toolUi, this.cwd, ); component.markExecutionStarted(); component.setArgsComplete(); component.setExpanded(expanded); cached = { component, args: item.args, result: undefined, isError: undefined, isPartial: undefined, expanded, }; this.transcriptTools.set(key, cached); } else if (item.args !== undefined && item.args !== cached.args) { cached.component.updateArgs(item.args); cached.args = item.args; } const expansionChanged = expanded !== cached.expanded; if (expansionChanged) { cached.component.setExpanded(expanded); cached.expanded = expanded; } if ( item.result !== undefined && (item.result !== cached.result || item.isError !== cached.isError || item.isPartial !== cached.isPartial || expansionChanged) ) { const result = normalizeToolResult(item); cached.component.updateResult( expanded ? result : { ...result, content: [] }, item.isPartial ?? false, ); cached.result = item.result; cached.isError = item.isError; cached.isPartial = item.isPartial; } return cached.component; } private transcriptBodyLines( agentId: string | undefined, width: number, ): string[] { if (!agentId) return []; const items = this.state.transcriptItems[agentId]; if (!items || items.length === 0) { return transcriptLines(this.state.transcripts[agentId] ?? "", width); } const lines: string[] = []; let text = ""; const flushText = () => { if (!text) return; lines.push(...transcriptLines(text, width)); text = ""; }; for (const [index, item] of items.entries()) { if (item.kind === "text") { text += item.text; continue; } flushText(); if (item.kind === "user") { lines.push(this.theme.fg("accent", "user")); lines.push(...transcriptLines(item.text, width)); continue; } if (this.hideToolPreviews && !this.toolsExpanded()) continue; const tool = this.transcriptTool(agentId, item, index); if (tool) lines.push(...tool.render(width)); else if (item.fallback) lines.push(...transcriptLines(`> ${item.fallback}`, width)); } flushText(); return lines; } // ---- render ----------------------------------------------------------- render(width: number): string[] { const w = Math.max(1, width); const state = this.state; const lines: string[] = []; const header = [headerLine(state), summaryLine(state)] .filter(Boolean) .join(" · "); lines.push(this.theme.fg("accent", fitLine(header, w))); for (const context of [ descriptionLine(state), ...metricsLines(state, this.toolsExpanded()), ]) { if (context) lines.push(this.theme.fg("muted", fitLine(context, w))); } const maxLines = Math.max(1, this.host.terminal.rows - 3); const fixedAfterRows = 4; // transcript divider + body allowance + two footer rows const workBudget = Math.max(0, maxLines - lines.length - fixedAfterRows); const display = this.visibleRows(); const groups = display.map((item) => { const focused = this.displayKey(item) === this.focusKey; if (item.kind === "phase") { const marker = focused ? "▶" : " "; const color = focused ? "accent" : item.phase.status === "done" ? "success" : "dim"; return [ this.theme.fg( color, fitLine( `${marker} ${phaseLabel(state, item.phase, item.expanded)}`, w, ), ), ]; } const marker = focused ? "▶" : " "; return agentBoardLines(item.row, state, `${marker} `).map((line) => { const text = line.bold || focused ? this.theme.bold(line.text) : line.text; return this.theme.fg(line.color, fitLine(text, w)); }); }); const focusedDisplay = Math.max( 0, display.findIndex((item) => this.displayKey(item) === this.focusKey), ); let groupStart = focusedDisplay; let groupEnd = Math.min(groups.length, focusedDisplay + 1); let used = groups[focusedDisplay]?.length ?? 0; if (used <= workBudget) { while (groupStart > 0 || groupEnd < groups.length) { let expanded = false; if ( groupStart > 0 && used + groups[groupStart - 1].length <= workBudget ) { groupStart--; used += groups[groupStart].length; expanded = true; } if ( groupEnd < groups.length && used + groups[groupEnd].length <= workBudget ) { used += groups[groupEnd].length; groupEnd++; expanded = true; } if (!expanded) break; } } const workLines = groups.slice(groupStart, groupEnd).flat(); lines.push(...workLines.slice(0, workBudget)); const focusedRow = this.focusedRow(); const focusedPhase = this.focusedPhase(); lines.push( this.theme.fg( "border", divider( focusedRow ? `transcript: ${focusedRow.agentId}` : `phase: ${focusedPhase?.phase ?? "—"}`, w, ), ), ); const body = this.transcriptBodyLines(focusedRow?.agentId, w - 2); // Reserve both footer rows and keep a 3-row terminal safety margin. const maxVisible = Math.max(0, maxLines - lines.length - 2); const maxScroll = Math.max(0, body.length - maxVisible); this.lastMaxScroll = maxScroll; if (this.followMode) this.scrollOffset = maxScroll; this.scrollOffset = Math.min(Math.max(0, this.scrollOffset), maxScroll); for (const l of body.slice( this.scrollOffset, this.scrollOffset + maxVisible, )) { lines.push(this.theme.fg("dim", fitLine(` ${l}`, w))); } if (this.steerMode) { const verb = focusedRow && focusedRow.status === "running" ? "steer" : "follow-up"; lines.push( this.theme.fg( "warning", fitLine( `${verb} ${focusedRow?.agentId ?? ""}> ${this.steerBuffer}`, w, ), ), ); } else if ( this.abortArmedAt !== undefined && this.now() - this.abortArmedAt <= 1500 ) { lines.push( this.theme.fg( "warning", fitLine("Press Esc again to abort all agents", w), ), ); } else { const follow = this.followMode ? "follow" : "paused"; lines.push( this.theme.fg( "dim", fitLine( `↑↓ focus · Tab agents · Enter phase · Alt+↑/↓ transcript (${follow})`, w, ), ), this.theme.fg( "dim", fitLine( `s steer · x abort agent · ${keyHint("app.tools.expand", "tools")} · Esc Esc abort all`, w, ), ), ); } return lines.map((l) => fitLine(l, w)); } invalidate(): void { for (const { component } of this.transcriptTools.values()) component.invalidate(); } }