// allow: SIZE_OK — one eval render pipeline avoids the runtime/render TDZ that motivated this module boundary. import { type AgentToolResult, highlightCode, type Theme, type ThemeColor, type ToolDefinition, type ToolRenderResultOptions, truncateToVisualLines, } from "@earendil-works/pi-coding-agent"; import { formatTruncationWarning, stripOutputNotice, type TruncationMeta, } from "../output/output-meta.ts"; import { renderDiffPreview } from "../render/previews/diff-preview.ts"; import { renderGrepPreview } from "../render/previews/grep-preview.ts"; import { renderReadPreview } from "../render/previews/read-preview.ts"; import { sanitizeTerminalLabel } from "../render/sanitize.ts"; import { JSON_TREE_MAX_DEPTH_COLLAPSED, JSON_TREE_MAX_DEPTH_EXPANDED, JSON_TREE_MAX_LINES_COLLAPSED, JSON_TREE_MAX_LINES_EXPANDED, JSON_TREE_SCALAR_LEN_COLLAPSED, JSON_TREE_SCALAR_LEN_EXPANDED, renderJsonTreeLines, } from "./json-tree.ts"; import { codePointPrefix, formatDuration, renderToolCallWidget, } from "./tool-widgets.ts"; import type { EvalCellResult, EvalInputSchema, EvalLanguage, EvalRenderState, EvalStatusEvent, EvalToolCallSummary, EvalToolDetails, EvalToolInput, EvalToolRequest, } from "./types.ts"; type EvalToolDefinition = ToolDefinition< EvalInputSchema, EvalToolDetails, EvalRenderState >; type RenderContext = Parameters< NonNullable >[2]; type ResultRenderContext = Parameters< NonNullable >[3]; type CollapsibleKind = "code" | "output"; export interface EvalRenderComponent { invalidate: () => void; render: (width: number) => string[]; } interface ToolCallRow { readonly color: "success" | "error"; readonly error?: string; readonly preview?: readonly string[]; readonly summary: string; } type RenderBlock = | { readonly kind: "blank" } | { readonly kind: "text"; readonly text: string; readonly maxVisualLines?: number; readonly collapseKind?: CollapsibleKind; readonly theme?: Theme; } | { readonly kind: "toolCalls"; readonly calls: readonly ToolCallRow[]; readonly expanded: boolean; readonly theme?: Theme; } | { readonly kind: "dynamic"; readonly render: (width: number) => readonly string[]; }; const CODE_PREVIEW_LINES = 4; const OUTPUT_PREVIEW_LINES = 8; const STATUS_PREVIEW_COUNT = 3; const SPINNER_FRAMES = [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ] as const; const SPINNER_INTERVAL_MS = 100; // Leak backstop: pi re-renders a tool row only while it is live, but a row can // be discarded mid-stream (session reset, message removal). The interval then // has no terminal render to clear it, so cap the total ticks (~30 minutes of // streaming) and freeze the spinner at its last frame. const MAX_SPINNER_TICKS = 18_000; const TOOL_CALL_PREVIEW_COUNT = 5; const TOOL_CALL_COLLAPSED_VISUAL_LINES = 4; const TOOL_CALL_COLLAPSED_PREVIEW_LINES = 2; const TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS = 512; const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]"; /** * Bounded content-addressed render cache for one tool row. Keys are arrays of * primitives and object references (strings by value, objects by identity), * stored as nested maps so lookup is O(key length) with no serialization. The * cache is a pure speedup: when the entry budget is exceeded it is dropped * wholesale and the next render recomputes. string[] results are returned as * shallow copies so caller mutation can never poison a cached entry. */ class RenderCache { static readonly #MAX_ENTRIES = 128; readonly #root = new Map(); #entryCount = 0; memo(key: readonly unknown[], compute: () => T): T { let node = this.#root; for (let index = 0; index < key.length - 1; index += 1) { const part = key[index]; const next = node.get(part); if (next instanceof Map) { node = next; continue; } const branch = new Map(); node.set(part, branch); this.#entryCount += 1; node = branch; } const last = key.at(-1); if (node.has(last)) { const cached = node.get(last) as T; return Array.isArray(cached) ? ([...cached] as T) : cached; } if (this.#entryCount >= RenderCache.#MAX_ENTRIES) { this.#root.clear(); this.#entryCount = 0; } const value = compute(); node.set(last, Array.isArray(value) ? [...value] : value); this.#entryCount += 1; return value; } } // Code text rarely changes mid-cell while the spinner re-renders the row every // 100 ms; highlightCode output is fully determined by (code, language, theme // presence) and is expensive enough to memoize module-wide with a small bound. const HIGHLIGHTED_CODE_CACHE_LIMIT = 16; const highlightedCodeCache = new Map(); class PlainTextComponent implements EvalRenderComponent { readonly renderCache = new RenderCache(); #blocks: readonly RenderBlock[] = []; #version = 0; #lastWidth = -1; #lastVersion = -1; #lastLines: string[] = []; setBlocks(blocks: readonly RenderBlock[]): void { this.#blocks = blocks; this.#version += 1; } render(width: number): string[] { // Cheap whole-component cache: identical width + unchanged blocks return // the previous render untouched (a shallow copy for caller safety). if (width === this.#lastWidth && this.#version === this.#lastVersion) { return [...this.#lastLines]; } const lines: string[] = []; for (const block of this.#blocks) { switch (block.kind) { case "blank": lines.push(""); break; case "toolCalls": appendLines( lines, this.renderCache.memo( ["toolCalls", width, block.expanded, block.calls, block.theme], () => renderToolCallBlock(block, width) ) ); break; case "text": appendLines( lines, this.renderCache.memo( [ "text", width, block.text, block.maxVisualLines ?? -1, block.collapseKind ?? "", block.theme, ], () => renderTextBlock(block, width) ) ); break; case "dynamic": appendLines(lines, block.render(width)); break; default: assertNever(block); } } this.#lastWidth = width; this.#lastVersion = this.#version; this.#lastLines = [...lines]; return lines; } invalidate(): void {} } function componentFor( context: RenderContext | ResultRenderContext ): PlainTextComponent { const existing = context.lastComponent; if (existing instanceof PlainTextComponent) { return existing; } return new PlainTextComponent(); } function style( theme: Theme | undefined, color: ThemeColor, text: string ): string { return theme ? theme.fg(color, text) : text; } function appendLines(target: string[], source: readonly string[]): void { for (const line of source) { target.push(line); } } function renderAllVisualLines(text: string, width: number): string[] { return truncateToVisualLines( text, Number.POSITIVE_INFINITY, width ).visualLines.map((line) => line.trimEnd()); } function renderTextBlock( block: Extract, width: number ): string[] { if (block.maxVisualLines === undefined) { return renderAllVisualLines(block.text, width); } const result = truncateToVisualLines(block.text, block.maxVisualLines, width); const visualLines = result.visualLines.map((line) => line.trimEnd()); if (result.skippedCount === 0 || block.collapseKind === undefined) { return visualLines; } return [ ...renderAllVisualLines( style( block.theme, "muted", `${result.skippedCount} earlier ${block.collapseKind} lines` ), width ), ...visualLines, ]; } function renderToolCall( call: ToolCallRow, block: Extract, width: number ): string[] { if (call.error === undefined) { const lines = renderAllVisualLines( style(block.theme, call.color, call.summary), width ); appendToolCallPreview(lines, call, block, width); return lines; } if (block.expanded) { return renderAllVisualLines( style(block.theme, call.color, `${call.summary} (${call.error})`), width ); } const guardedError = codePointPrefix( call.error, TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS ); const guardedLines = renderAllVisualLines( style(block.theme, call.color, `${call.summary} (${guardedError})`), width ); if ( guardedError.length === call.error.length && guardedLines.length <= TOOL_CALL_COLLAPSED_VISUAL_LINES ) { return guardedLines; } const summaryLines = renderAllVisualLines( style(block.theme, call.color, call.summary), width ); const errorLines = renderAllVisualLines( style(block.theme, call.color, ` (${guardedError})`), width ); const markerLines = renderAllVisualLines( style(block.theme, "muted", TOOL_ERROR_OMISSION_MARKER), width ); const lines: string[] = []; const summaryBudget = Math.max( 1, TOOL_CALL_COLLAPSED_VISUAL_LINES - markerLines.length ); appendLines(lines, summaryLines.slice(0, summaryBudget)); const errorBudget = Math.max( 0, TOOL_CALL_COLLAPSED_VISUAL_LINES - lines.length - markerLines.length ); appendLines(lines, errorLines.slice(0, errorBudget)); appendLines( lines, markerLines.slice(0, TOOL_CALL_COLLAPSED_VISUAL_LINES - lines.length) ); return lines; } function appendToolCallPreview( lines: string[], call: ToolCallRow, block: Extract, width: number ): void { if (call.preview === undefined || call.preview.length === 0) { return; } const wrapped = call.preview.flatMap((line) => renderAllVisualLines(` ${line}`, Math.max(1, width)) ); const budget = block.expanded ? wrapped.length : Math.min(wrapped.length, TOOL_CALL_COLLAPSED_PREVIEW_LINES); appendLines(lines, wrapped.slice(0, budget)); } function renderToolCallBlock( block: Extract, width: number ): string[] { const retainedCalls = block.expanded ? block.calls : block.calls.slice(-TOOL_CALL_PREVIEW_COUNT); const skippedCount = block.calls.length - retainedCalls.length; const toolCallNoun = skippedCount === 1 ? "call" : "calls"; const lines = block.expanded || skippedCount === 0 ? [] : renderAllVisualLines( style( block.theme, "muted", `${skippedCount} earlier tool ${toolCallNoun}` ), width ); for (const call of retainedCalls) { appendLines(lines, renderToolCall(call, block, width)); } return lines; } type CellStatus = EvalCellResult["status"]; type AgentStatus = "pending" | "running" | "completed" | "failed" | "aborted"; interface StatusPresentation { readonly color: ThemeColor; readonly icon: string; readonly label: string; } interface RenderEnvironment { /** Per-row content-addressed memo consulted by dynamic render closures. */ readonly cache: RenderCache; readonly expanded: boolean; readonly meta: TruncationMeta | undefined; readonly spinnerFrame: number | undefined; readonly theme: Theme | undefined; readonly width: number; } interface CellBadges { readonly reset: boolean; readonly timeout: number | undefined; } interface PrefixStyle { readonly color: ThemeColor; readonly continuation: string; readonly prefix: string; } interface DetailedRenderContext { readonly args: EvalToolRequest; readonly environment: RenderEnvironment; readonly showImageFallback: boolean; } function assertNever(value: never): never { throw new TypeError(`Unhandled eval render variant: ${String(value)}`); } function languageForHighlighter( language: unknown ): "python" | "javascript" | "ruby" | "typescript" { switch (language) { case "py": return "python"; case "js": return "javascript"; case "rb": return "ruby"; case "ts": return "typescript"; default: // pi can render a partially received tool call before schema validation. return "javascript"; } } function highlightedCode( code: unknown, language: unknown, theme: Theme | undefined ): string { const source = typeof code === "string" ? code : ""; const normalizedCode = source.trim().length > 0 ? source : "..."; const key = `${theme === undefined ? "plain" : "styled"}\u0000${String(language)}\u0000${normalizedCode}`; const cached = highlightedCodeCache.get(key); if (cached !== undefined) { return cached; } const lines = highlightCode(normalizedCode, languageForHighlighter(language)); const highlighted = ( theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines ).join("\n"); if (highlightedCodeCache.size >= HIGHLIGHTED_CODE_CACHE_LIMIT) { // Map iteration order is insertion order: evict the oldest entry. const oldest = highlightedCodeCache.keys().next().value; if (oldest !== undefined) { highlightedCodeCache.delete(oldest); } } highlightedCodeCache.set(key, highlighted); return highlighted; } function spinner(frame: number | undefined): string { return ( SPINNER_FRAMES.at((frame ?? 0) % SPINNER_FRAMES.length) ?? SPINNER_FRAMES[0] ); } /** * Shared per-row spinner state. See TECHNICAL.md, Output and rendering. pi's ToolRenderContext has * no spinnerFrame; the port stores the frame counter in context.state, which * the host initializes as {} per tool row and shares across call/result lanes. */ function spinnerState( context: RenderContext | ResultRenderContext ): EvalRenderState { const state = context.state; if (typeof state !== "object" || state === null) { throw new TypeError("eval render state must be an object"); } return state; } function spinnerFrameFor( context: RenderContext | ResultRenderContext ): number | undefined { return spinnerState(context).frame; } /** * Drive the self-animated spinner: start a 100 ms interval on the first * partial render and clear it on the terminal render. Only the RESULT lane * drives the interval — the HTML-export renderer invokes renderCall with * isPartial:true but never renders a terminal frame, so starting the timer * there would leak an interval that keeps the exporting process alive. */ function syncSpinner( context: RenderContext | ResultRenderContext, isPartial: boolean, options: { readonly invalidate: () => void } ): void { const state = spinnerState(context); if (!isPartial) { if (state.interval !== undefined) { clearInterval(state.interval); state.interval = undefined; } return; } if (state.interval !== undefined) { return; } state.frame = 0; state.interval = setInterval(() => { state.frame = (state.frame ?? 0) + 1; if (state.frame > MAX_SPINNER_TICKS) { if (state.interval !== undefined) { clearInterval(state.interval); } state.interval = undefined; return; } options.invalidate(); }, SPINNER_INTERVAL_MS); } function cellPresentation( status: CellStatus, spinnerFrame: number | undefined ): StatusPresentation { switch (status) { case "pending": return { label: "pending", icon: "○", color: "muted" }; case "running": return { label: "running", icon: spinner(spinnerFrame), color: "warning", }; case "detached": return { label: "detached", icon: "↗", color: "warning" }; case "complete": return { label: "done", icon: "✓", color: "success" }; case "error": return { label: "error", icon: "✗", color: "error" }; case "cancelled": return { label: "cancelled", icon: "×", color: "error" }; default: return assertNever(status); } } function renderPrefixed( text: string, environment: RenderEnvironment, prefixStyle: PrefixStyle ): string[] { const bodyLines = renderAllVisualLines( text, Math.max(1, environment.width - prefixStyle.prefix.length) ); if (bodyLines.length === 0) { return [ style(environment.theme, prefixStyle.color, prefixStyle.prefix.trimEnd()), ]; } return bodyLines.map( (line, index) => `${style(environment.theme, prefixStyle.color, index === 0 ? prefixStyle.prefix : prefixStyle.continuation)}${line}` ); } function cellHeader( cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges ): string { const presentation = cellPresentation(cell.status, environment.spinnerFrame); const title = cell.title === undefined ? "" : ` ${cell.title}`; let header = `eval ${cell.language}${title} ${presentation.label} ${presentation.icon}`; if (cell.durationMs !== undefined) { header += ` · ${formatDuration(cell.durationMs)}`; } if (badges.reset) { header += " · reset"; } if (badges.timeout !== undefined) { header += ` · timeout ${badges.timeout}s`; } return style(environment.theme, presentation.color, header); } function previewText( text: string, maxLines: number, width: number ): { readonly lines: string[]; readonly skipped: number } { const preview = truncateToVisualLines(text, maxLines, Math.max(1, width)); return { lines: preview.visualLines.map((line) => line.trimEnd()), skipped: preview.skippedCount, }; } function eventString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function eventNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function plural(count: number, singular: string, pluralNoun: string): string { return `${count} ${count === 1 ? singular : pluralNoun}`; } function statusIcon(op: string): string { if (op.startsWith("git_")) { return "⌁"; } switch (op) { case "read": case "write": case "cat": case "touch": return "▣"; case "ls": case "cd": case "pwd": case "mkdir": return "▤"; case "run": case "sh": return "▶"; case "completion": return "◇"; case "phase": return "◆"; default: return "•"; } } function formatStatusEvent( event: EvalStatusEvent, theme: Theme | undefined ): string { const op = event.op; const icon = style(theme, "muted", statusIcon(op)); const error = eventString(event.error); if (error !== undefined) { return `${icon} ${style(theme, "warning", op)}: ${style(theme, "dim", error)}`; } const parts: string[] = []; switch (op) { case "read": { parts.push(`${eventNumber(event.chars ?? event.bytes)} chars`); const path = eventString(event.path); if (path !== undefined) { parts.push(`from ${path}`); } break; } case "write": { parts.push(`${eventNumber(event.chars ?? event.bytes)} chars`); const path = eventString(event.path); if (path !== undefined) { parts.push(`to ${path}`); } break; } case "cat": parts.push(plural(eventNumber(event.files), "file", "files")); parts.push(`${eventNumber(event.chars)} chars`); break; case "ls": parts.push(plural(eventNumber(event.count), "entry", "entries")); break; case "env": { const action = eventString(event.action); const key = eventString(event.key); const value = eventString(event.value) ?? ""; if (action === "set" && key !== undefined) { parts.push(`set ${key}=${value.slice(0, 30)}`); } else if (action === "get" && key !== undefined) { parts.push(`${key}=${value.slice(0, 30)}`); } else { parts.push(plural(eventNumber(event.count), "variable", "variables")); } break; } case "git_status": { if (event.clean === true) { parts.push("clean"); } else { const changes: string[] = []; for (const key of ["staged", "modified", "untracked"] as const) { const count = eventNumber(event[key]); if (count > 0) { changes.push(`${count} ${key}`); } } parts.push(changes.join(", ") || "unknown"); } const branch = eventString(event.branch); if (branch !== undefined) { parts.push(`on ${branch}`); } break; } case "git_diff": parts.push(plural(eventNumber(event.lines), "line", "lines")); if (event.staged === true) { parts.push("staged"); } break; case "git_log": parts.push(plural(eventNumber(event.commits), "commit", "commits")); break; case "run": case "sh": { const command = eventString(event.command ?? event.cmd); if (command !== undefined) { parts.push(command); } if (typeof event.exitCode === "number") { parts.push(`exit ${event.exitCode}`); } break; } case "completion": { const model = eventString(event.model); const tier = eventString(event.tier); if (model !== undefined) { parts.push(model); } if (tier !== undefined && tier !== model) { parts.push(tier); } parts.push(`${eventNumber(event.chars)} chars`); break; } case "log": parts.push(eventString(event.message) ?? ""); break; case "phase": parts.push(eventString(event.title) ?? ""); break; case "status-events-omitted": parts.push(`${eventNumber(event.count)} earlier events omitted`); break; default: { if (event.count !== undefined) { parts.push(String(event.count)); } const path = eventString(event.path); if (path !== undefined) { parts.push(path); } } } const description = parts.filter((part) => part.length > 0).join(" · "); return `${icon} ${style(theme, "muted", op)}${description.length > 0 ? ` ${style(theme, "dim", description)}` : ""}`; } function renderStatusEvents( events: readonly EvalStatusEvent[], environment: RenderEnvironment ): string[] { // A bounded history stores its exact omission count in a leading marker event; fold that // count into the summary line so collapsing the preview can never understate omissions. const first = events[0]; const omittedByBound = first?.op === "status-events-omitted" && typeof first.count === "number" ? first.count : 0; const visible = omittedByBound > 0 ? events.slice(1) : events; const retained = environment.expanded ? visible : visible.slice(-STATUS_PREVIEW_COUNT); const skipped = visible.length - retained.length + omittedByBound; const lines: string[] = []; if (skipped > 0) { lines.push( style(environment.theme, "dim", `├ … ${skipped} earlier status events`) ); } for (const [index, event] of retained.entries()) { const branch = index === retained.length - 1 ? "└" : "├"; lines.push( `${style(environment.theme, "dim", branch)} ${formatStatusEvent(event, environment.theme)}` ); } return lines; } function agentStatus(value: unknown): AgentStatus { switch (value) { case "pending": case "running": case "completed": case "failed": case "aborted": return value; default: return "running"; } } function coalesceAgentEvents( events: readonly EvalStatusEvent[] ): EvalStatusEvent[] { const rows: EvalStatusEvent[] = []; const indexes = new Map(); for (const event of events) { const id = eventString(event.id); if (id === undefined) { rows.push(event); continue; } const index = indexes.get(id); if (index === undefined) { indexes.set(id, rows.length); rows.push(event); } else { rows[index] = event; } } return rows; } function agentPresentation( status: AgentStatus, spinnerFrame: number | undefined ): StatusPresentation { switch (status) { case "pending": return { label: "pending", icon: "○", color: "muted" }; case "running": return { label: "running", icon: spinner(spinnerFrame), color: "warning", }; case "completed": return { label: "done", icon: "✓", color: "success" }; case "failed": return { label: "failed", icon: "✗", color: "error" }; case "aborted": return { label: "aborted", icon: "×", color: "error" }; default: return assertNever(status); } } function renderAgentProgressEvents( events: readonly EvalStatusEvent[], environment: RenderEnvironment ): string[] { const rows = coalesceAgentEvents(events); const lines: string[] = []; // pi's Theme has no tree-token API; fixed ├/└/│ glyphs intentionally mirror omp. for (const [index, event] of rows.entries()) { const isLast = index === rows.length - 1; const status = agentStatus(event.status); const presentation = agentPresentation(status, environment.spinnerFrame); const id = eventString(event.id) ?? "agent"; const styledId = environment.theme === undefined ? id : environment.theme.bold(id); let body = `${style(environment.theme, presentation.color, presentation.icon)} ${styledId} ${presentation.label}`; if (status === "completed" || status === "failed" || status === "aborted") { const duration = eventNumber(event.durationMs); if (duration > 0) { body += ` · ${style(environment.theme, "dim", formatDuration(duration))}`; } } const branch = isLast ? "└ " : "├ "; const continuation = isLast ? " " : "│ "; appendLines( lines, renderPrefixed(body, environment, { prefix: branch, continuation, color: "dim", }) ); if (status !== "running") { continue; } const currentTool = eventString(event.currentTool); const lastIntent = eventString(event.lastIntent); if (currentTool === undefined && lastIntent === undefined) { continue; } const detail = currentTool === undefined ? (lastIntent ?? "") : `${currentTool}${lastIntent === undefined ? "" : `: ${lastIntent}`}`; appendLines( lines, renderPrefixed(detail, environment, { prefix: `${continuation}└ `, continuation: `${continuation} `, color: "dim", }) ); } return lines; } function renderCell( cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges ): string[] { const { cache, width, expanded, theme } = environment; const lines = renderPrefixed( cache.memo( [ "cellHeader", width, environment.spinnerFrame, cell.language, cell.status, cell.title, cell.durationMs, badges.reset, badges.timeout, theme, ], () => cellHeader(cell, environment, badges) ), environment, { prefix: "╭─ ", continuation: "│ ", color: "borderAccent" } ); const innerWidth = Math.max(1, width - 2); // The header is the only cell piece that embeds the spinner frame; the body // pieces are keyed without it so a tick re-renders one line, not the row. const codePreview = cache.memo( ["codePreview", innerWidth, expanded, cell.code, cell.language, theme], () => previewText( highlightedCode(cell.code, cell.language, theme), expanded ? Number.POSITIVE_INFINITY : CODE_PREVIEW_LINES, innerWidth ) ); if (codePreview.skipped > 0) { appendLines( lines, renderPrefixed(`${codePreview.skipped} earlier code lines`, environment, { prefix: "│ ", continuation: "│ ", color: "muted", }) ); } for (const line of codePreview.lines) { appendLines( lines, renderPrefixed(line, environment, { prefix: "│ ", continuation: "│ ", color: "borderMuted", }) ); } const output = stripOutputNotice(cell.output, environment.meta).trimEnd(); if (output.length > 0) { appendLines( lines, renderPrefixed("output", environment, { prefix: "├─ ", continuation: "│ ", color: "dim", }) ); const outputColor: ThemeColor = cell.status === "error" ? "error" : "toolOutput"; // Keyed on the raw cell.output reference: it is only reassigned when a new // chunk arrives, so unchanged ticks skip the split/style/layout entirely. const outputPreview = cache.memo( [ "outputPreview", innerWidth, expanded, cell.output, environment.meta, outputColor, theme, ], () => { const styledOutput = output .split("\n") .map((line) => style(theme, outputColor, line)) .join("\n"); return previewText( styledOutput, expanded ? Number.POSITIVE_INFINITY : OUTPUT_PREVIEW_LINES, innerWidth ); } ); if (outputPreview.skipped > 0) { appendLines( lines, renderPrefixed( `${outputPreview.skipped} earlier output lines`, environment, { prefix: "│ ", continuation: "│ ", color: "muted", } ) ); } for (const line of outputPreview.lines) { appendLines( lines, renderPrefixed(line, environment, { prefix: "│ ", continuation: "│ ", color: "borderMuted", }) ); } } const allEvents = cell.statusEvents ?? []; const statusEvents = allEvents.filter((event) => event.op !== "agent"); if (statusEvents.length > 0) { appendLines( lines, renderPrefixed("status", environment, { prefix: "├─ ", continuation: "│ ", color: "dim", }) ); for (const line of cache.memo( ["cellStatusEvents", width, expanded, allEvents, theme], () => renderStatusEvents(statusEvents, environment) )) { appendLines( lines, renderPrefixed(line, environment, { prefix: "│ ", continuation: "│ ", color: "borderMuted", }) ); } } lines.push(style(theme, "borderMuted", "╰─")); const agentEvents = allEvents.filter((event) => event.op === "agent"); if (agentEvents.length > 0) { appendLines( lines, cache.memo( [ "cellAgentEvents", width, expanded, environment.spinnerFrame, allEvents, theme, ], () => renderAgentProgressEvents(agentEvents, environment) ) ); } return lines; } function renderJsonOutputs( values: readonly unknown[], environment: RenderEnvironment ): string[] { const lines: string[] = []; const depth = environment.expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED; const lineCap = environment.expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED; const scalarLen = environment.expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED; for (const [index, value] of values.entries()) { appendLines( lines, renderAllVisualLines( style(environment.theme, "dim", `display[${index + 1}]`), environment.width ) ); const tree = renderJsonTreeLines( value, environment.theme, depth, lineCap, scalarLen ); for (const line of tree.lines) { appendLines(lines, renderAllVisualLines(line, environment.width)); } if (tree.truncated) { appendLines( lines, renderAllVisualLines( style(environment.theme, "dim", "…"), environment.width ) ); } } return lines; } function renderDetailedLines( details: EvalToolDetails, result: AgentToolResult, context: DetailedRenderContext ): string[] { const lines: string[] = []; const cells = details.cells ?? []; for (const [index, cell] of cells.entries()) { const run = isEvalRunInput(context.args) ? context.args : undefined; const badges = { reset: index === 0 && run?.reset === true, timeout: index === 0 ? run?.timeout : undefined, }; appendLines(lines, renderCell(cell, context.environment, badges)); if (index < cells.length - 1) { lines.push(""); } } const jsonOutputs = details.jsonOutputs ?? []; if (jsonOutputs.length > 0) { if (lines.length > 0) { lines.push(""); } appendLines( lines, context.environment.cache.memo( [ "dlJsonOutputs", context.environment.width, context.environment.expanded, details.jsonOutputs, context.environment.theme, ], () => renderJsonOutputs(jsonOutputs, context.environment) ) ); } if (context.showImageFallback) { for (const part of result.content) { if (part.type !== "image") { continue; } if (lines.length > 0) { lines.push(""); } appendLines( lines, context.environment.cache.memo( [ "dlImageFallback", context.environment.width, result.content, part.mimeType, ], () => renderAllVisualLines( `[image: ${sanitizeTerminalLabel(part.mimeType)}]`, context.environment.width ) ) ); } } if (details.phase !== undefined) { const phase = details.phase; appendLines( lines, context.environment.cache.memo( [ "dlPhase", context.environment.width, phase, context.environment.theme, ], () => renderAllVisualLines( style(context.environment.theme, "muted", `phase ${phase}`), context.environment.width ) ) ); } if (details.notice !== undefined) { const notice = details.notice; appendLines( lines, context.environment.cache.memo( [ "dlNotice", context.environment.width, notice, context.environment.theme, ], () => renderAllVisualLines( style(context.environment.theme, "dim", notice), context.environment.width ) ) ); } const warning = formatTruncationWarning(details.meta) ?? (details.truncated ? "[eval output truncated]" : null); if (warning !== null) { appendLines( lines, context.environment.cache.memo( [ "dlWarning", context.environment.width, details.meta, details.truncated, context.environment.theme, ], () => renderAllVisualLines( style(context.environment.theme, "warning", warning), context.environment.width ) ) ); } return lines; } function textOutput( result: AgentToolResult, showImageFallback: boolean ): string { const lines: string[] = []; for (const part of result.content) { if (part.type === "text") { lines.push(part.text); } else if (showImageFallback && part.type === "image") { lines.push(`[image: ${sanitizeTerminalLabel(part.mimeType)}]`); } } return lines.join("\n"); } function isEvalRunInput(args: EvalToolRequest): args is EvalToolInput { return args.action !== "peek" && args.action !== "stop"; } function toolCallRows( details: EvalToolDetails | undefined, theme: Theme | undefined ): ToolCallRow[] { if (!details?.toolCalls || details.toolCalls.length === 0) { return []; } return details.toolCalls.map((call) => { const status = call.ok ? "ok" : "error"; const row = { summary: `- tool.${call.name}: ${status}`, color: call.ok ? "success" : "error", } as const; const preview = toolCallPreview(call, theme); const enrichedRow = preview === undefined || preview.length === 0 ? row : { ...row, preview }; return call.error === undefined ? enrichedRow : { ...enrichedRow, error: call.error }; }); } function toolCallPreview( call: EvalToolCallSummary, theme: Theme | undefined ): string[] | undefined { if (!call.ok) { return; } switch (call.name) { case "read": { if (call.resultPreview === undefined) { return; } const args = toolCallArgs(call); return renderReadPreview( { path: stringArg(args, "path") ?? "", offset: numberArg(args, "offset"), limit: numberArg(args, "limit"), output: call.resultPreview, }, theme ); } case "grep": { if (call.resultPreview === undefined) { return; } return renderGrepPreview({ output: call.resultPreview }, theme); } case "edit": { const args = toolCallArgs(call); const edit = firstEditBlock(args); if (edit === undefined) { return; } return renderDiffPreview( { before: edit.oldText, after: edit.newText, path: stringArg(args, "path"), }, theme ); } default: return; } } function toolCallArgs( call: EvalToolCallSummary ): Record | undefined { if ( typeof call.args !== "object" || call.args === null || Array.isArray(call.args) ) { return; } return call.args as Record; } function stringArg( args: Record | undefined, key: string ): string | undefined { const value = args?.[key]; return typeof value === "string" ? value : undefined; } function numberArg( args: Record | undefined, key: string ): number | undefined { const value = args?.[key]; return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function firstEditBlock( args: Record | undefined ): { readonly oldText: string; readonly newText: string } | undefined { const edits = Array.isArray(args?.edits) ? args.edits : []; for (const edit of edits) { if (typeof edit !== "object" || edit === null) { continue; } const record = edit as Record; const oldText = stringArg(record, "oldText") ?? stringArg(record, "old_text"); const newText = stringArg(record, "newText") ?? stringArg(record, "new_text"); if (oldText !== undefined && newText !== undefined && oldText !== newText) { return { oldText, newText }; } } const oldText = stringArg(args, "oldText") ?? stringArg(args, "old_text"); const newText = stringArg(args, "newText") ?? stringArg(args, "new_text"); if (oldText !== undefined && newText !== undefined && oldText !== newText) { return { oldText, newText }; } } function nestedToolCallBlock( details: EvalToolDetails | undefined, theme: Theme | undefined, cwd: string, expanded: boolean, cache: RenderCache ): RenderBlock | undefined { const toolCalls = details?.toolCalls; if ( theme === undefined || toolCalls === undefined || !toolCalls.some((call) => call.args !== undefined) ) { return; } const legacyRows = toolCallRows(details, theme); return { kind: "dynamic", render: (width) => cache.memo( ["toolWidgets", width, expanded, toolCalls, theme, cwd], () => { const retainedCalls = expanded ? toolCalls : toolCalls.slice(-TOOL_CALL_PREVIEW_COUNT); const retainedRows = expanded ? legacyRows : legacyRows.slice(-TOOL_CALL_PREVIEW_COUNT); const skippedCount = toolCalls.length - retainedCalls.length; const toolCallNoun = skippedCount === 1 ? "call" : "calls"; const lines = expanded || skippedCount === 0 ? [] : renderAllVisualLines( style( theme, "muted", `${skippedCount} earlier tool ${toolCallNoun}` ), width ); const legacyBlock: Extract = { kind: "toolCalls", calls: retainedRows, expanded, theme, }; for (const [index, call] of retainedCalls.entries()) { if (call.args === undefined) { appendLines( lines, renderToolCall(retainedRows[index], legacyBlock, width) ); } else { appendLines( lines, renderToolCallWidget(call, { cwd, theme, expanded, width }) ); } } return lines; } ), }; } function resultStatus( details: EvalToolDetails | undefined, options: ToolRenderResultOptions, hostIsError: boolean ): "running" | "done" | "error" { if (details?.isError || hostIsError) { return "error"; } return options.isPartial ? "running" : "done"; } function resultHeader( details: EvalToolDetails | undefined, status: "running" | "done" | "error", theme: Theme | undefined ): string { const title = details?.title === undefined ? "" : ` ${details.title}`; let color: ThemeColor; switch (status) { case "running": color = "warning"; break; case "done": color = "success"; break; case "error": color = "error"; break; default: color = "muted"; break; } return style( theme, color, `eval ${details?.language ?? "?"}${title} ${status}` ); } function resultMetadata( details: EvalToolDetails | undefined, options: ToolRenderResultOptions, theme: Theme | undefined ): RenderBlock[] { const metadata: string[] = []; if (details?.phase) { metadata.push(`phase ${details.phase}`); } if (!options.isPartial && typeof details?.durationMs === "number") { metadata.push(`took ${details.durationMs}ms`); } if (metadata.length === 0) { return []; } return [{ kind: "text", text: style(theme, "muted", metadata.join(" | ")) }]; } export function renderEvalCall( args: EvalToolRequest, theme: Theme | undefined, context: RenderContext ): EvalRenderComponent { const component = componentFor(context); const cache = component.renderCache; const spinnerFrame = spinnerFrameFor(context); // pi 0.83.0 has no context.hasResult (api-gaps G3): pi stacks the call lane // and the result lane into one container, and the call context carries no // "a result exists" signal. So once execution has started the call lane // renders the compact header + code preview instead of a second framed box; // the result lane owns the running -> done frame (documented delta D3). if (!isEvalRunInput(args)) { component.setBlocks([ { kind: "text", text: style(theme, "toolTitle", `eval ${args.action} ${args.cell_id}`), }, ]); return component; } const sourceCode = typeof args.code === "string" ? args.code : ""; const renderLanguage: EvalLanguage = args.language === "py" || args.language === "js" || args.language === "rb" || args.language === "ts" ? args.language : "js"; if ( context.executionStarted === true || (theme === undefined && spinnerFrame === undefined) ) { const title = args.title === undefined ? "" : ` ${args.title}`; const reset = args.reset === true ? " reset" : ""; const timeout = args.timeout === undefined ? "" : ` timeout ${args.timeout}s`; component.setBlocks([ { kind: "text", text: style( theme, "toolTitle", `eval ${args.language ?? "?"}${title}${reset}${timeout}` ), }, { kind: "text", text: style( theme, "mdCodeBlock", sourceCode.trim().length > 0 ? sourceCode : "..." ), maxVisualLines: context.expanded ? undefined : CODE_PREVIEW_LINES, collapseKind: "code", theme, }, ]); return component; } component.setBlocks([ { kind: "dynamic", render: (width) => { const environment: RenderEnvironment = { expanded: context.expanded, theme, spinnerFrame, width, meta: undefined, cache, }; const cell: EvalCellResult = { index: 0, ...(args.title === undefined ? {} : { title: args.title }), code: sourceCode, language: renderLanguage, output: "", status: spinnerFrame === undefined ? "pending" : "running", }; return renderCell(cell, environment, { reset: args.reset === true, timeout: args.timeout, }); }, }, ]); return component; } export function renderEvalResult( result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme | undefined, context: ResultRenderContext ): EvalRenderComponent { const component = componentFor(context); const cache = component.renderCache; const details = result.details; const expanded = options.expanded || context.expanded; const spinnerFrame = spinnerFrameFor(context); // pi 0.83.0 has no context.imageProtocol (api-gaps G3c): inline images are // rendered by pi's own machinery; the [image: …] fallback keys off // context.showImages (documented delta D3). const showImageFallback = context.showImages; // Drive the self-animated spinner only from the result lane (see syncSpinner). syncSpinner(context, options.isPartial || context.isPartial, { invalidate: () => context.invalidate(), }); if (details?.cells !== undefined && details.cells.length > 0) { const blocks: RenderBlock[] = [ { kind: "dynamic", render: (width) => renderDetailedLines(details, result, { environment: { expanded, theme, spinnerFrame, width, meta: details.meta, cache, }, args: context.args, showImageFallback, }), }, ]; const calls = toolCallRows(details, theme); const nestedCalls = nestedToolCallBlock( details, theme, context.cwd, expanded, cache ); if (calls.length > 0) { blocks.push( { kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme } ); } component.setBlocks(blocks); return component; } const status = resultStatus(details, options, context.isError); const blocks: RenderBlock[] = [ { kind: "text", text: resultHeader(details, status, theme) }, ...resultMetadata(details, options, theme), { kind: "blank" }, ]; const rawOutput = textOutput(result, showImageFallback); const output = stripOutputNotice(rawOutput, details?.meta).trimEnd(); const hasRenderedImage = false; if (output.length > 0) { blocks.push({ kind: "text", text: style(theme, "toolOutput", output), maxVisualLines: expanded ? undefined : OUTPUT_PREVIEW_LINES, collapseKind: "output", theme, }); } else if (!hasRenderedImage) { blocks.push({ kind: "text", text: style(theme, "muted", "(no output)") }); } const statusEvents = details?.statusEvents ?? []; const nonAgentEvents = statusEvents.filter((event) => event.op !== "agent"); const agentEvents = statusEvents.filter((event) => event.op === "agent"); if (nonAgentEvents.length > 0 || agentEvents.length > 0) { blocks.push( { kind: "blank" }, { kind: "dynamic", render: (width) => { const environment: RenderEnvironment = { expanded, theme, spinnerFrame, width, meta: details?.meta, cache, }; return [ ...cache.memo( ["resultStatusEvents", width, expanded, statusEvents, theme], () => renderStatusEvents(nonAgentEvents, environment) ), ...cache.memo( [ "resultAgentEvents", width, expanded, spinnerFrame, statusEvents, theme, ], () => renderAgentProgressEvents(agentEvents, environment) ), ]; }, } ); } if ((details?.jsonOutputs?.length ?? 0) > 0) { blocks.push( { kind: "blank" }, { kind: "dynamic", render: (width) => cache.memo( ["resultJsonOutputs", width, expanded, details?.jsonOutputs, theme], () => renderJsonOutputs(details?.jsonOutputs ?? [], { expanded, theme, spinnerFrame, width, meta: details?.meta, cache, }) ), } ); } const calls = toolCallRows(details, theme); const nestedCalls = nestedToolCallBlock( details, theme, context.cwd, expanded, cache ); if (calls.length > 0) { blocks.push( { kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme } ); } if (details?.notice !== undefined) { blocks.push( { kind: "blank" }, { kind: "text", text: style(theme, "dim", details.notice) } ); } const warning = formatTruncationWarning(details?.meta) ?? (details?.truncated ? "[eval output truncated]" : null); if (warning !== null) { blocks.push( { kind: "blank" }, { kind: "text", text: style(theme, "warning", warning) } ); } component.setBlocks(blocks); return component; }