/** * Transcript rendering for the `subagent` tool. * * `renderCall` previews the requested mode before execution; `renderResult` * draws a collapsed summary or an expanded, per-result view. They are pure over * the tool arguments/details, so tests can call `.render(width)` directly; the * optional render context only drives the running elapsed-time repaint. * * Status and error rendering is shared across single- and multi-result modes so * a failed task never loses its message, regardless of how many tasks ran. */ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui"; import { formatDuration, sanitizeLine, shortenPath, stripControlChars } from "../../lib/format.ts"; import { reuseText, toolDetails } from "../../lib/tui/chrome.ts"; import { messageText, rejectionLine, reuseCallText, syncElapsedTimer, toolCallTitle } from "../../lib/tui/tool.ts"; import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts"; import { centeredRule } from "../../lib/tui/rules.ts"; import { BODY_INDENT } from "../../lib/tui/indent.ts"; import { paint, type TuiRole } from "../../lib/tui/tokens.ts"; import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts"; import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts"; import { getFinalOutput, isFailedResult } from "./stream.ts"; import type { SingleResult, SubagentDetails } from "./types.ts"; type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; type ResultStatus = "running" | "aborted" | "failed" | "success"; /** Columns a section rule adds around its label: a space and three dashes each side. */ const SECTION_RULE_PAD = 8; /** A section rule keeps the `meta` colour the hand-rolled `─── label ───` used. */ const SECTION_RULE_STYLE = { pad: 1, fillRole: "meta", labelRole: "meta" } as const; /** Text and tool-call parts of every assistant message, in order. */ function getDisplayItems(messages: SingleResult["messages"]): DisplayItem[] { const items: DisplayItem[] = []; for (const message of messages) { if (message.role !== "assistant") continue; for (const part of message.content) { if (part.type === "text") items.push({ type: "text", text: part.text }); else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments }); } } return items; } /** Whether a collapsed result hides items or lines that expanding would reveal. */ function hasExpandableContent(items: DisplayItem[]): boolean { return ( items.length > COLLAPSED_ITEM_COUNT || items.some((item) => item.type === "text" && item.text.split("\n").length > COLLAPSED_TEXT_LINES) ); } /** Design roles per status; the running glyph is time-based, so it is not a value here. */ const STATUS_ROLE: Record = { running: "warn", aborted: "warn", failed: "bad", success: "ok", }; /** Static glyphs for terminal states, from the shared narrow-glyph vocabulary. */ const STATUS_GLYPH: Record, string> = { aborted: GLYPHS.stopped, failed: GLYPHS.jobsFailure, success: GLYPHS.check, }; /** Braille frames are narrow and uncoloured, so the running row never shifts. */ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; /** The glyph for a status; the running glyph advances on the 1s repaint clock. */ function statusGlyph(status: ResultStatus, now: number): string { return status === "running" ? SPINNER_FRAMES[Math.floor(now / 1000) % SPINNER_FRAMES.length] : STATUS_GLYPH[status]; } /** Classify a result. `isPartial` marks a still-streaming single result. */ function resultStatus(result: SingleResult, isPartial = false): ResultStatus { if (isPartial || result.exitCode === -1) return "running"; if (result.stopReason === "aborted") return "aborted"; if (isFailedResult(result)) return "failed"; return "success"; } function statusIcon(status: ResultStatus, theme: Theme, now: number): string { return paint(theme, STATUS_ROLE[status], statusGlyph(status, now)); } /** The role the status uses, so the `[stopReason]` bracket matches its icon. */ function statusRole(status: ResultStatus): TuiRole { return STATUS_ROLE[status]; } /** The message a failed/aborted result should show, falling back to stderr. */ function errorDetail(result: SingleResult, status: ResultStatus, max?: number): string | undefined { if (status !== "failed" && status !== "aborted") return undefined; const raw = result.errorMessage || result.stderr.trim(); const detail = raw || (status === "failed" ? `Subprocess exited with code ${result.exitCode}.` : ""); if (!detail) return undefined; return max === undefined ? stripControlChars(detail).trim() : clip(sanitizeLine(detail), max); } /** A "Error: ..." / "Aborted: ..." block, or undefined when clean. */ function renderError(result: SingleResult, status: ResultStatus, theme: Theme, max?: number): string | undefined { const detail = errorDetail(result, status, max); if (!detail) return undefined; const label = status === "aborted" ? "Aborted" : "Error"; return paint(theme, status === "aborted" ? "warn" : "bad", `${label}: ${detail}`); } /** Whether the collapsed error/stderr text is truncated, so expanding shows more. */ function hasClippedError(result: SingleResult, status: ResultStatus): boolean { const full = errorDetail(result, status); if (!full) return false; const clean = sanitizeLine(full); return clip(clean, COLLAPSED_ERROR_MAX) !== clean; } /** The lines under a collapsed result header: error, output preview, or a placeholder. */ function collapsedBody(result: SingleResult, status: ResultStatus, items: DisplayItem[], theme: Theme): string { const error = renderError(result, status, theme, COLLAPSED_ERROR_MAX); if (error) return error; if (items.length === 0) return paint(theme, "meta", status === "running" ? "(running…)" : "(no output)"); return renderDisplayItems(items, theme, COLLAPSED_ITEM_COUNT); } /** Whether expanding a collapsed result would reveal more than its preview. */ function isExpandable(result: SingleResult, status: ResultStatus, items: DisplayItem[]): boolean { return hasExpandableContent(items) || hasClippedError(result, status); } /** "2 tool errors" / "1 tool error". */ function toolErrorsLabel(count: number): string { return `${count} tool error${count > 1 ? "s" : ""}`; } /** Usage plus a tool-error count when the subagent hit any. */ function usageLine(result: SingleResult): string { const base = formatUsageStats(result.usage, result.model); if (!result.toolErrors) return base; const label = toolErrorsLabel(result.toolErrors); return base ? `${base} ${label}` : label; } /** The `Total:` usage line for a multi run, including summed tool errors. */ function totalUsageLine(results: SingleResult[]): string { const usage = formatUsageStats(aggregateUsage(results)); const errors = results.reduce((sum, result) => sum + (result.toolErrors ?? 0), 0); const label = errors ? toolErrorsLabel(errors) : ""; return [usage, label].filter(Boolean).join(" "); } /** " 12s" while running or once finished, or "" when no start time was recorded. */ function durationSuffix(result: SingleResult, status: ResultStatus, now: number): string { if (!result.startedAt) return ""; const end = result.finishedAt ?? (status === "running" ? now : undefined); if (end === undefined) return ""; return ` ${formatDuration(end - result.startedAt)}`; } /** Elapsed time plus a `[stopReason]` bracket, unless the result is clean or still running. */ function statusSuffix(result: SingleResult, status: ResultStatus, theme: Theme, now: number): string { let suffix = ""; const duration = durationSuffix(result, status, now); if (duration) suffix += paint(theme, "faint", duration); const showReason = status === "failed" || status === "aborted"; const stop = showReason && result.stopReason && result.stopReason !== "end" ? result.stopReason : undefined; const reason = stop ?? (status === "failed" ? `exit ${result.exitCode}` : undefined); if (reason) suffix += ` ${paint(theme, statusRole(status), `[${reason}]`)}`; return suffix; } /** A result header line: status icon, agent, and status suffix. */ function resultHeader( result: SingleResult, status: ResultStatus, theme: Theme, stepPrefix: string, now: number, ): string { let header = `${statusIcon(status, theme, now)} ${paint(theme, "accent", theme.bold(sanitizeLine(result.agent)))}`; if (result.agentSource && result.agentSource !== "builtin" && result.agentSource !== "unknown") { header += paint(theme, "faint", ` [${sanitizeLine(result.agentSource)}]`); } if (result.step) header = `${paint(theme, "meta", `${stepPrefix} ${result.step} `)}` + header; return header + statusSuffix(result, status, theme, now); } /** Prefix each line of intermediate assistant text with the block's body indent. */ function indentBody(text: string, theme: Theme): string { const indent = " ".repeat(BODY_INDENT); return stripControlChars(text) .trim() .split("\n") .map((line) => indent + theme.fg("toolOutput", line)) .join("\n"); } function renderDisplayItems(items: DisplayItem[], theme: Theme, limit?: number, preview = true): string { const shown = limit ? items.slice(-limit) : items; const skipped = limit && items.length > limit ? items.length - limit : 0; const lines: string[] = []; if (skipped > 0) lines.push(paint(theme, "meta", `… ${skipped} earlier items`)); for (const item of shown) { if (item.type === "text") { const safe = stripControlChars(item.text); const text = preview ? safe.split("\n").slice(0, COLLAPSED_TEXT_LINES).join("\n") : safe; lines.push(theme.fg("toolOutput", text)); } else { lines.push(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, preview)); } } return lines.join("\n"); } /** "2. explorer" for a parallel task, or "Step 2: explorer" for a chain step. */ function resultLabel(result: SingleResult, index: number): string { const prefix = result.step ? `Step ${result.step}: ` : `${index + 1}. `; return prefix + result.agent; } /** The call-line context: the session cwd and the slot's previous component. */ export interface SubagentCallContext { cwd?: string; lastComponent?: Component; } /** * The call's arguments as Pi hands them to `renderCall`. While the model streams * a call, a field can still be missing, or arrive with a type the schema will * later reject, so the call line reads every one defensively and never throws: a * throwing renderer makes Pi fall back to its own generic call line. */ export type StreamedSubagentArgs = Partial>; /** One field of a partially-streamed element, or undefined when it has not arrived. */ function streamedText(element: unknown, key: "agent" | "task" | "cwd"): string | undefined { if (typeof element !== "object" || element === null) return undefined; const value = (element as Record)[key]; return typeof value === "string" ? value : undefined; } /** Preview the requested mode and its agents, reusing the slot's `Text`. */ export function renderSubagentCall( args: StreamedSubagentArgs | undefined, theme: Theme, context?: SubagentCallContext, ): Component { const text = reuseCallText(context?.lastComponent); text.setText(callText(args ?? {}, theme, context)); return text; } /** * The call-line string for the requested mode. Continuation lines carry no * indent of their own: the tool-block frame headlines the first line and * indents the rest by `BODY_INDENT`. */ function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: string }): string { const title = toolCallTitle(theme, "subagent ") + (args.readOnly ? paint(theme, "faint", "read-only ") : ""); const cwdNote = (cwd?: unknown) => { if (typeof cwd !== "string" || !cwd || cwd === context?.cwd) return ""; return paint(theme, "faint", ` in ${shortenPath(cwd)}`); }; const chain = Array.isArray(args.chain) ? args.chain : []; if (chain.length > 0) { let text = title + paint(theme, "label", `chain${SEPARATORS.item}${chain.length} step${chain.length > 1 ? "s" : ""}`); for (let i = 0; i < Math.min(chain.length, 3); i++) { const step = chain[i]; const task = (streamedText(step, "task") ?? "…").replace(/\{previous\}/g, ""); text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(step, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(task), 40)}`)}${cwdNote(streamedText(step, "cwd"))}`; } if (chain.length > 3) text += `\n${paint(theme, "meta", `… +${chain.length - 3} more`)}`; return text; } const tasks = Array.isArray(args.tasks) ? args.tasks : []; if (tasks.length > 0) { let text = title + paint(theme, "label", `parallel${SEPARATORS.item}${tasks.length} task${tasks.length > 1 ? "s" : ""}`); for (let i = 0; i < Math.min(tasks.length, 3); i++) { const task = tasks[i]; text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(task, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(streamedText(task, "task") ?? "…"), 40)}`)}${cwdNote(streamedText(task, "cwd"))}`; } if (tasks.length > 3) text += `\n${paint(theme, "meta", `… +${tasks.length - 3} more`)}`; return text; } const agentName = sanitizeLine(streamedText(args, "agent") || "…"); let text = title + paint(theme, "label", agentName) + cwdNote(streamedText(args, "cwd")); text += `\n${paint(theme, "meta", clip(sanitizeLine(streamedText(args, "task") ?? "…"), 60))}`; return text; } /** Append one result's expanded block: header, error, task, output, usage. */ function addExpandedResult( container: Container, result: SingleResult, theme: Theme, mdTheme: ReturnType, now: number, isPartial = false, ): void { const status = resultStatus(result, isPartial); container.addChild(new Text(resultHeader(result, status, theme, "Step", now), 0, 0)); const error = renderError(result, status, theme); if (error) container.addChild(new Text(error, 0, 0)); container.addChild(new Spacer(1)); container.addChild( new Text(centeredRule(theme, visibleWidth("Task") + SECTION_RULE_PAD, "Task", SECTION_RULE_STYLE), 0, 0), ); container.addChild(new Text(paint(theme, "faint", stripControlChars(result.taskTemplate ?? result.task)), 0, 0)); const displayItems = getDisplayItems(result.messages); const lastItem = displayItems[displayItems.length - 1]; const finalOutput = getFinalOutput(result.messages); // Only hoist a trailing Markdown block when the final output really is the // last thing the agent emitted; otherwise render items in their original // order and do not surface a stale earlier message as the answer. const trailingOutput = lastItem?.type === "text" && lastItem.text === finalOutput ? lastItem.text : ""; container.addChild(new Spacer(1)); container.addChild( new Text(centeredRule(theme, visibleWidth("Output") + SECTION_RULE_PAD, "Output", SECTION_RULE_STYLE), 0, 0), ); let renderedOutput = false; for (const item of displayItems) { if (item.type === "toolCall") { container.addChild( new Text(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, false), 0, 0), ); renderedOutput = true; } else if (item.text.trim() && item.text !== trailingOutput) { container.addChild(new Text(indentBody(item.text, theme), 0, 0)); renderedOutput = true; } } if (trailingOutput.trim()) { container.addChild(new Spacer(1)); container.addChild(new Markdown(stripControlChars(trailingOutput).trim(), 0, 0, mdTheme)); renderedOutput = true; } if (!renderedOutput) { container.addChild(new Text(paint(theme, "meta", status === "running" ? "(running…)" : "(no output)"), 0, 0)); } const usage = usageLine(result); if (usage) { container.addChild(new Spacer(1)); container.addChild(new Text(paint(theme, "faint", usage), 0, 0)); } } /** The collapsed single result: header, a word, body, expand hint, and usage. */ function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: boolean, now: number): string { const status = resultStatus(result, isPartial); // The leading blank line separates the result block from the call line, matching // the built-in renderers and the `job`/`todo`/`goal` results. let text = `\n${resultHeader(result, status, theme, "Step", now)}`; const displayItems = getDisplayItems(result.messages); // A blank line separates the header from the body, matching the multi view. text += `\n\n${collapsedBody(result, status, displayItems, theme)}`; if (isExpandable(result, status, displayItems)) text += `\n${expandHint(theme)}`; const usage = usageLine(result); if (usage) text += `\n${paint(theme, "faint", usage)}`; return text; } function summarizeResults(results: SingleResult[]) { const statuses = results.map((result) => resultStatus(result)); return { running: statuses.filter((status) => status === "running").length, failed: statuses.filter((status) => status === "failed").length, aborted: statuses.filter((status) => status === "aborted").length, succeeded: statuses.filter((status) => status === "success").length, }; } /** Shared header for parallel/chain results, with a failure count when present. */ function multiHeader(details: SubagentDetails, theme: Theme, now: number): string { const { running, failed, aborted, succeeded } = summarizeResults(details.results); const total = details.total ?? details.results.length; const noun = details.mode === "chain" ? "steps" : "tasks"; let status: string; if (running > 0) { // Parallel findings are placeholder-backed, so `total - running` really are // finished. A chain only holds started steps, so report the current step. status = details.mode === "chain" ? `${succeeded}/${total} ${noun} done, step ${details.results.length} running` : `${total - running}/${total} ${noun} done, ${running} running`; } else { status = `${succeeded}/${total} ${noun}`; } const issues: string[] = []; if (failed) issues.push(`${failed} failed`); if (aborted) issues.push(`${aborted} aborted`); if (details.mode === "chain" && running === 0 && details.results.length < total) { issues.push(`stopped at step ${details.results.length}`); } if (issues.length) status += ` (${issues.join(", ")})`; const bad = failed + aborted; const icon = running > 0 ? paint(theme, "warn", statusGlyph("running", now)) : bad > 0 ? paint(theme, bad === total ? "bad" : "warn", bad === total ? GLYPHS.jobsFailure : GLYPHS.half) : paint(theme, "ok", GLYPHS.check); return `${icon} ${theme.fg("toolTitle", theme.bold(`${details.mode} · `))}${paint(theme, "accent", status)}`; } function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number): string { const results = details.results; const { running } = summarizeResults(results); let text = `\n${multiHeader(details, theme, now)}`; let hasHidden = false; for (let i = 0; i < results.length; i++) { const result = results[i]; const status = resultStatus(result); const displayItems = getDisplayItems(result.messages); if (isExpandable(result, status, displayItems)) hasHidden = true; const taskText = result.taskTemplate ?? result.task; const taskNote = taskText ? paint(theme, "faint", ` ${clip(sanitizeLine(taskText), 50)}`) : ""; text += `\n\n${paint(theme, "meta", "─── ")}${paint(theme, "accent", resultLabel(result, i))}${taskNote} ${statusIcon(status, theme, now)}`; text += statusSuffix(result, status, theme, now); text += `\n${collapsedBody(result, status, displayItems, theme)}`; } if (running === 0) { const usage = totalUsageLine(results); if (usage) text += `\n\n${paint(theme, "faint", `Total: ${usage}`)}`; } if (running > 0 || hasHidden) text += `\n${expandHint(theme)}`; return text; } function fillExpandedMulti(container: Container, details: SubagentDetails, theme: Theme, now: number): void { const mdTheme = getMarkdownTheme(); container.addChild(new Text(multiHeader(details, theme, now), 0, 0)); for (const result of details.results) { container.addChild(new Spacer(1)); addExpandedResult(container, result, theme, mdTheme, now); } const { running } = summarizeResults(details.results); if (running === 0) { const usage = totalUsageLine(details.results); if (usage) { container.addChild(new Spacer(1)); container.addChild(new Text(paint(theme, "faint", `Total: ${usage}`), 0, 0)); } } } /** The subset of Pi's render context the transcript needs. */ interface SubagentRenderContext { state: Record; invalidate: () => void; lastComponent?: Component; } /** Render a subagent result, collapsed or expanded. */ export function renderSubagentResult( result: AgentToolResult, options: { expanded: boolean; isPartial?: boolean; now?: number }, theme: Theme, context?: SubagentRenderContext, ): Component { const now = options.now ?? Date.now(); const details = toolDetails(result.details); if (!details || details.results.length === 0) { syncElapsedTimer(false, context); const text = reuseText(context?.lastComponent); if (!details) { text.setText(rejectionLine(theme, result, options.expanded)); } else { text.setText(messageText(result) || "(no output)"); } return text; } // The `-1` sentinel is set while a subprocess is in flight, in every mode. syncElapsedTimer( details.results.some((entry) => entry.exitCode === -1), context, ); const isPartial = options.isPartial ?? false; if (options.expanded) { const container = context?.lastComponent instanceof Container ? context.lastComponent : new Container(); container.clear(); if (details.mode === "single") { addExpandedResult(container, details.results[0], theme, getMarkdownTheme(), now, isPartial); } else { fillExpandedMulti(container, details, theme, now); } return container; } const text = reuseText(context?.lastComponent); text.setText( details.mode === "single" ? collapsedSingleText(details.results[0], theme, isPartial, now) : collapsedMultiText(details, theme, now), ); return text; }