61a57c1d21
- Changed render methods to return component-owned `readonly string[]` rows. - Added `RenderStablePrefix` row reuse to avoid repainting unchanged streaming content. - Stabilized streaming gutters and spinner placement to reduce preview jitter and flicker. - Finalized commit-safe transcript behavior for tool-call previews and added streaming edge-case tests.
753 lines
25 KiB
TypeScript
753 lines
25 KiB
TypeScript
/**
|
|
* TUI rendering for the eval tool.
|
|
*
|
|
* Split out from `eval.ts` so the renderer can be imported by `renderers.ts`
|
|
* without dragging the eval *runtime* (JS/Python backends -> agent bridge ->
|
|
* task executor -> sdk -> extension loader -> root barrel) into the renderer
|
|
* module graph. That transitive chain re-enters `renderers.ts` while `eval.ts`
|
|
* is still initializing, which previously crashed module load with a TDZ
|
|
* `Cannot access 'evalToolRenderer' before initialization`.
|
|
*/
|
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
import { Markdown, Text } from "@oh-my-pi/pi-tui";
|
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
import { settings } from "../config/settings";
|
|
import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
|
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
|
|
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
|
|
import { markFramedBlockComponent, renderCodeCell } from "../tui";
|
|
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";
|
|
import { formatStyledTruncationWarning, stripOutputNotice } from "./output-meta";
|
|
import {
|
|
formatBadge,
|
|
formatDuration,
|
|
formatStatusIcon,
|
|
formatTitle,
|
|
replaceTabs,
|
|
shortenPath,
|
|
truncateToWidth,
|
|
wrapBrackets,
|
|
} from "./render-utils";
|
|
export const EVAL_DEFAULT_PREVIEW_LINES = 10;
|
|
|
|
function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
|
|
return language === "js" ? "javascript" : "python";
|
|
}
|
|
|
|
interface EvalRenderCellArg {
|
|
language?: string;
|
|
code?: string;
|
|
title?: string;
|
|
}
|
|
|
|
interface EvalRenderArgs {
|
|
cells?: EvalRenderCellArg[];
|
|
__partialJson?: string;
|
|
}
|
|
|
|
interface EvalRenderContext {
|
|
output?: string;
|
|
expanded?: boolean;
|
|
previewLines?: number;
|
|
timeout?: number;
|
|
}
|
|
|
|
interface EvalRenderCell {
|
|
language: EvalLanguage;
|
|
code: string;
|
|
title?: string;
|
|
}
|
|
|
|
function normalizeRenderLanguage(value: string | undefined): EvalLanguage {
|
|
return value === "js" ? "js" : "python";
|
|
}
|
|
|
|
function getRenderCells(args: EvalRenderArgs | undefined): EvalRenderCell[] {
|
|
const raw = args?.cells;
|
|
if (!Array.isArray(raw)) return [];
|
|
const out: EvalRenderCell[] = [];
|
|
for (const cell of raw) {
|
|
if (!cell || typeof cell !== "object") continue;
|
|
const code = typeof cell.code === "string" ? cell.code : "";
|
|
out.push({
|
|
language: normalizeRenderLanguage(typeof cell.language === "string" ? cell.language : undefined),
|
|
code,
|
|
title: typeof cell.title === "string" ? cell.title : undefined,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
type AgentEventStatus = "pending" | "running" | "completed" | "failed" | "aborted";
|
|
|
|
/**
|
|
* Append or replace a status event. `agent` events are progress snapshots keyed
|
|
* by `id`, so they coalesce in place (preserving first-seen order); every other
|
|
* op is a discrete action and simply appends. Keeps the persisted event list
|
|
* bounded even when a subagent emits hundreds of throttled progress ticks.
|
|
*/
|
|
export function upsertStatusEvent(events: EvalStatusEvent[], event: EvalStatusEvent): void {
|
|
if (event.op === "agent" && typeof event.id === "string") {
|
|
const id = event.id;
|
|
const idx = events.findIndex(e => e.op === "agent" && e.id === id);
|
|
if (idx >= 0) {
|
|
events[idx] = event;
|
|
return;
|
|
}
|
|
}
|
|
events.push(event);
|
|
}
|
|
|
|
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 agentEventStatus(value: unknown): AgentEventStatus {
|
|
switch (value) {
|
|
case "pending":
|
|
case "running":
|
|
case "completed":
|
|
case "failed":
|
|
case "aborted":
|
|
return value;
|
|
default:
|
|
return "running";
|
|
}
|
|
}
|
|
|
|
/** Append the toolCount · context · cost · model stat run, mirroring the task tool. */
|
|
function formatAgentStats(event: EvalStatusEvent, theme: Theme): string {
|
|
let line = "";
|
|
const toolCount = eventNumber(event.toolCount);
|
|
if (toolCount > 0) {
|
|
line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(toolCount)} ${theme.icon.extensionTool}`)}`;
|
|
}
|
|
const contextTokens = eventNumber(event.contextTokens);
|
|
if (contextTokens > 0) {
|
|
const contextWindow = eventNumber(event.contextWindow);
|
|
const ctx =
|
|
contextWindow > 0
|
|
? formatContextUsage((contextTokens / contextWindow) * 100, contextWindow)
|
|
: formatNumber(contextTokens);
|
|
line += `${theme.sep.dot}${theme.fg("dim", ctx)}`;
|
|
}
|
|
const cost = eventNumber(event.cost);
|
|
if (cost > 0) {
|
|
line += `${theme.sep.dot}${theme.fg("statusLineCost", `$${cost.toFixed(2)}`)}`;
|
|
}
|
|
const model = eventString(event.model);
|
|
if (model && settings.get("task.showResolvedModelBadge")) {
|
|
line += `${theme.sep.dot}${theme.fg("dim", truncateToWidth(replaceTabs(model), 30))}`;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
/**
|
|
* Render coalesced `agent()` progress as a Task-tool-style tree, one entry per
|
|
* subagent: a status line (icon · id · stats) plus, while running, the current
|
|
* tool/intent. Drawn below the cell box so progress streams live.
|
|
*/
|
|
function renderAgentProgressEvents(events: EvalStatusEvent[], theme: Theme, spinnerFrame?: number): string[] {
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < events.length; i++) {
|
|
const event = events[i];
|
|
const isLast = i === events.length - 1;
|
|
const prefix = theme.fg("dim", isLast ? theme.tree.last : theme.tree.branch);
|
|
const cont = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
|
|
|
|
const status = agentEventStatus(event.status);
|
|
const iconStatus =
|
|
status === "completed"
|
|
? "done"
|
|
: status === "failed"
|
|
? "error"
|
|
: status === "aborted"
|
|
? "aborted"
|
|
: status === "pending"
|
|
? "pending"
|
|
: "running";
|
|
const iconColor =
|
|
status === "completed" ? "success" : status === "failed" || status === "aborted" ? "error" : "accent";
|
|
const icon =
|
|
status === "completed"
|
|
? theme.styledSymbol("tool.eval", "accent")
|
|
: theme.fg(iconColor, formatStatusIcon(iconStatus, theme, status === "running" ? spinnerFrame : undefined));
|
|
|
|
const id = eventString(event.id) ?? "agent";
|
|
let line = `${prefix} ${icon} ${theme.fg("accent", theme.bold(id))}`;
|
|
|
|
if (status === "failed" || status === "aborted") {
|
|
line += ` ${formatBadge(status, iconColor, theme)}`;
|
|
}
|
|
|
|
const currentTool = eventString(event.currentTool);
|
|
const lastIntent = eventString(event.lastIntent);
|
|
if (status === "running" && !currentTool && !lastIntent) {
|
|
const preview = eventString(event.taskPreview);
|
|
if (preview) line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(preview), 48))}`;
|
|
}
|
|
|
|
line += formatAgentStats(event, theme);
|
|
if (status === "completed" || status === "failed" || status === "aborted") {
|
|
const durationMs = eventNumber(event.durationMs);
|
|
if (durationMs > 0) line += `${theme.sep.dot}${theme.fg("dim", formatDuration(durationMs))}`;
|
|
}
|
|
lines.push(line);
|
|
|
|
if (status === "running") {
|
|
if (currentTool) {
|
|
let toolLine = `${cont}${theme.tree.hook} ${theme.fg("muted", currentTool)}`;
|
|
const detail = lastIntent ?? eventString(event.currentToolArgs);
|
|
if (detail) toolLine += `: ${theme.fg("dim", truncateToWidth(replaceTabs(detail), 48))}`;
|
|
lines.push(toolLine);
|
|
} else if (lastIntent) {
|
|
lines.push(`${cont}${theme.tree.hook} ${theme.fg("dim", truncateToWidth(replaceTabs(lastIntent), 48))}`);
|
|
}
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/** Format a status event as a single line for display. */
|
|
function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
|
|
const { op, ...data } = event;
|
|
|
|
type AvailableIcon = "icon.file" | "icon.folder" | "icon.git" | "icon.package";
|
|
const opIcons: Record<string, AvailableIcon> = {
|
|
read: "icon.file",
|
|
write: "icon.file",
|
|
append: "icon.file",
|
|
cat: "icon.file",
|
|
touch: "icon.file",
|
|
ls: "icon.folder",
|
|
cd: "icon.folder",
|
|
pwd: "icon.folder",
|
|
mkdir: "icon.folder",
|
|
tree: "icon.folder",
|
|
git_status: "icon.git",
|
|
git_diff: "icon.git",
|
|
git_log: "icon.git",
|
|
git_show: "icon.git",
|
|
git_branch: "icon.git",
|
|
git_file_at: "icon.git",
|
|
git_has_changes: "icon.git",
|
|
run: "icon.package",
|
|
sh: "icon.package",
|
|
env: "icon.package",
|
|
batch: "icon.package",
|
|
completion: "icon.package",
|
|
log: "icon.package",
|
|
phase: "icon.package",
|
|
};
|
|
|
|
const iconKey = opIcons[op] ?? "icon.file";
|
|
const icon = theme.styledSymbol(iconKey, "muted");
|
|
|
|
const parts: string[] = [];
|
|
|
|
if (data.error) {
|
|
return `${icon} ${theme.fg("warning", op)}: ${theme.fg("dim", String(data.error))}`;
|
|
}
|
|
|
|
switch (op) {
|
|
case "read":
|
|
parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
|
|
if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
|
|
break;
|
|
case "write":
|
|
case "append":
|
|
parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
|
|
if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
|
|
break;
|
|
case "cat":
|
|
parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""}`);
|
|
parts.push(`${data.chars} chars`);
|
|
break;
|
|
case "ls":
|
|
parts.push(`${data.count} entr${(data.count as number) !== 1 ? "ies" : "y"}`);
|
|
break;
|
|
case "env":
|
|
if (data.action === "set") {
|
|
parts.push(`set ${data.key}=${truncateToWidth(String(data.value ?? ""), 30)}`);
|
|
} else if (data.action === "get") {
|
|
parts.push(`${data.key}=${truncateToWidth(String(data.value ?? ""), 30)}`);
|
|
} else {
|
|
parts.push(`${data.count} variable${(data.count as number) !== 1 ? "s" : ""}`);
|
|
}
|
|
break;
|
|
case "git_status":
|
|
if (data.clean) {
|
|
parts.push("clean");
|
|
} else {
|
|
const statusParts: string[] = [];
|
|
if (data.staged) statusParts.push(`${data.staged} staged`);
|
|
if (data.modified) statusParts.push(`${data.modified} modified`);
|
|
if (data.untracked) statusParts.push(`${data.untracked} untracked`);
|
|
parts.push(statusParts.join(", ") || "unknown");
|
|
}
|
|
if (data.branch) parts.push(`on ${data.branch}`);
|
|
break;
|
|
case "git_log":
|
|
parts.push(`${data.commits} commit${(data.commits as number) !== 1 ? "s" : ""}`);
|
|
break;
|
|
case "git_diff":
|
|
parts.push(`${data.lines} line${(data.lines as number) !== 1 ? "s" : ""}`);
|
|
if (data.staged) parts.push("(staged)");
|
|
break;
|
|
case "diff":
|
|
if (data.identical) {
|
|
parts.push("files identical");
|
|
} else {
|
|
parts.push("files differ");
|
|
}
|
|
break;
|
|
case "batch":
|
|
parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
|
|
break;
|
|
case "completion":
|
|
if (data.model) parts.push(String(data.model));
|
|
if (data.tier && data.tier !== data.model) parts.push(`(${data.tier})`);
|
|
parts.push(`${data.chars ?? 0} chars`);
|
|
break;
|
|
case "wc":
|
|
parts.push(`${data.lines}L ${data.words}W ${data.chars}C`);
|
|
break;
|
|
case "cd":
|
|
case "pwd":
|
|
case "mkdir":
|
|
case "touch":
|
|
if (data.path) parts.push(shortenPath(String(data.path)));
|
|
break;
|
|
case "log":
|
|
parts.push(String(data.message ?? ""));
|
|
break;
|
|
case "phase":
|
|
parts.push(String(data.title ?? ""));
|
|
break;
|
|
default:
|
|
if (data.count !== undefined) {
|
|
parts.push(String(data.count));
|
|
}
|
|
if (data.path) {
|
|
parts.push(shortenPath(String(data.path)));
|
|
}
|
|
}
|
|
|
|
const desc = parts.length > 0 ? parts.join(" · ") : "";
|
|
return `${icon} ${theme.fg("muted", op)}${desc ? ` ${theme.fg("dim", desc)}` : ""}`;
|
|
}
|
|
|
|
/** Format status event with expanded detail lines. */
|
|
function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string[] {
|
|
const lines: string[] = [];
|
|
const { op, ...data } = event;
|
|
|
|
lines.push(formatStatusEvent(event, theme));
|
|
|
|
const addItems = (items: unknown[], formatter: (item: unknown) => string, max = 5) => {
|
|
const arr = Array.isArray(items) ? items : [];
|
|
for (let i = 0; i < Math.min(arr.length, max); i++) {
|
|
lines.push(` ${theme.fg("dim", formatter(arr[i]))}`);
|
|
}
|
|
if (arr.length > max) {
|
|
lines.push(` ${theme.fg("dim", `… ${arr.length - max} more`)}`);
|
|
}
|
|
};
|
|
|
|
const addPreview = (preview: string, maxLines = 3) => {
|
|
const previewLines = String(preview).split("\n").slice(0, maxLines);
|
|
for (const line of previewLines) {
|
|
lines.push(` ${theme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}`);
|
|
}
|
|
const totalLines = String(preview).split("\n").length;
|
|
if (totalLines > maxLines) {
|
|
lines.push(` ${theme.fg("dim", `… ${totalLines - maxLines} more lines`)}`);
|
|
}
|
|
};
|
|
|
|
switch (op) {
|
|
case "ls":
|
|
if (data.items) addItems(data.items as unknown[], m => String(m));
|
|
break;
|
|
case "env":
|
|
if (data.keys) addItems(data.keys as unknown[], k => String(k), 10);
|
|
break;
|
|
case "git_log":
|
|
if (data.entries) {
|
|
addItems(data.entries as unknown[], e => {
|
|
const entry = e as { sha: string; subject: string };
|
|
return `${entry.sha} ${truncateToWidth(entry.subject, 50)}`;
|
|
});
|
|
}
|
|
break;
|
|
case "git_status":
|
|
if (data.files) addItems(data.files as unknown[], f => String(f));
|
|
break;
|
|
case "git_branch":
|
|
if (data.branches) addItems(data.branches as unknown[], b => String(b));
|
|
break;
|
|
case "read":
|
|
case "cat":
|
|
case "head":
|
|
case "tail":
|
|
case "tree":
|
|
case "diff":
|
|
case "git_diff":
|
|
case "sh":
|
|
if (data.preview) addPreview(String(data.preview));
|
|
break;
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
/** Render status events as tree lines. */
|
|
function renderStatusEvents(events: EvalStatusEvent[], theme: Theme, expanded: boolean): string[] {
|
|
if (events.length === 0) return [];
|
|
|
|
const maxCollapsed = 3;
|
|
const maxExpanded = 10;
|
|
const displayCount = expanded ? Math.min(events.length, maxExpanded) : Math.min(events.length, maxCollapsed);
|
|
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < displayCount; i++) {
|
|
const isLast = i === displayCount - 1 && (expanded || events.length <= maxCollapsed);
|
|
const branch = isLast ? theme.tree.last : theme.tree.branch;
|
|
|
|
if (expanded) {
|
|
const eventLines = formatStatusEventExpanded(events[i], theme);
|
|
lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`);
|
|
const continueBranch = isLast ? " " : `${theme.tree.vertical} `;
|
|
for (let j = 1; j < eventLines.length; j++) {
|
|
lines.push(`${theme.fg("dim", continueBranch)}${eventLines[j]}`);
|
|
}
|
|
} else {
|
|
lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme)}`);
|
|
}
|
|
}
|
|
|
|
if (!expanded && events.length > maxCollapsed) {
|
|
lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxCollapsed} more`)}`);
|
|
} else if (expanded && events.length > maxExpanded) {
|
|
lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxExpanded} more`)}`);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
function formatCellOutputLines(
|
|
cell: EvalCellResult,
|
|
expanded: boolean,
|
|
previewLines: number,
|
|
theme: Theme,
|
|
width: number,
|
|
): { lines: readonly string[]; hiddenCount: number } {
|
|
if (!cell.output) {
|
|
return { lines: [], hiddenCount: 0 };
|
|
}
|
|
|
|
if (cell.hasMarkdown && cell.status !== "error") {
|
|
const md = new Markdown(cell.output, 0, 0, getMarkdownTheme());
|
|
const allLines = md.render(width);
|
|
const displayLines = expanded ? allLines : allLines.slice(-previewLines);
|
|
const hiddenCount = allLines.length - displayLines.length;
|
|
return { lines: displayLines, hiddenCount };
|
|
}
|
|
|
|
const rawLines = cell.output.split("\n");
|
|
const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
|
|
const hiddenCount = rawLines.length - displayLines.length;
|
|
const outputLines = displayLines.map(line => {
|
|
const cleaned = replaceTabs(line);
|
|
return cell.status === "error" ? theme.fg("error", cleaned) : theme.fg("toolOutput", cleaned);
|
|
});
|
|
|
|
return { lines: outputLines, hiddenCount };
|
|
}
|
|
|
|
export const evalToolRenderer = {
|
|
renderCall(args: EvalRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
|
const cells = getRenderCells(args);
|
|
|
|
if (cells.length === 0) {
|
|
const promptSym = uiTheme.fg("accent", ">>>");
|
|
const text = formatTitle(`${promptSym} …`, uiTheme);
|
|
return new Text(text, 0, 0);
|
|
}
|
|
|
|
let cached: { key: string; width: number; result: string[] } | undefined;
|
|
|
|
return markFramedBlockComponent({
|
|
render: (width: number): readonly string[] => {
|
|
const key = `${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
|
|
if (cached && cached.key === key && cached.width === width) {
|
|
return cached.result;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < cells.length; i++) {
|
|
const cell = cells[i];
|
|
const cellLines = renderCodeCell(
|
|
{
|
|
code: cell.code,
|
|
language: languageForHighlighter(cell.language),
|
|
index: i,
|
|
total: cells.length,
|
|
title: cell.title,
|
|
status: "pending",
|
|
width,
|
|
// Always render the full source: the code is fixed input, not the
|
|
// streaming part, so it is never compacted.
|
|
codeMaxLines: Number.POSITIVE_INFINITY,
|
|
expanded: options.expanded,
|
|
},
|
|
uiTheme,
|
|
);
|
|
lines.push(...cellLines);
|
|
if (i < cells.length - 1) {
|
|
lines.push("");
|
|
}
|
|
}
|
|
cached = { key, width, result: lines };
|
|
return lines;
|
|
},
|
|
invalidate: () => {
|
|
cached = undefined;
|
|
},
|
|
});
|
|
},
|
|
|
|
renderResult(
|
|
result: { content: Array<{ type: string; text?: string }>; details?: EvalToolDetails },
|
|
options: RenderResultOptions & { renderContext?: EvalRenderContext },
|
|
uiTheme: Theme,
|
|
_args?: EvalRenderArgs,
|
|
): Component {
|
|
const details = result.details;
|
|
|
|
const rawOutput =
|
|
options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();
|
|
// Strip the LLM-facing notice (appended by wrappedExecute) before display;
|
|
// the styled `warningLine` below carries the same text in ⟨…⟩ form.
|
|
const output = stripOutputNotice(rawOutput, details?.meta).trimEnd();
|
|
|
|
const jsonOutputs = details?.jsonOutputs ?? [];
|
|
const treeExpanded = options.renderContext?.expanded ?? options.expanded;
|
|
const treeDepth = treeExpanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
|
|
const treeLineCap = treeExpanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
|
|
const treeScalarLen = treeExpanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
|
|
const labelOutputs = jsonOutputs.length > 1;
|
|
const jsonLines = jsonOutputs.flatMap((value, index) => {
|
|
const tree = renderJsonTreeLines(value, uiTheme, treeDepth, treeLineCap, treeScalarLen);
|
|
const body = tree.truncated ? [...tree.lines, uiTheme.fg("dim", "…")] : tree.lines;
|
|
return labelOutputs ? [uiTheme.fg("dim", `display[${index + 1}]`), ...body] : body;
|
|
});
|
|
|
|
const timeoutSeconds = options.renderContext?.timeout;
|
|
const timeoutLine =
|
|
typeof timeoutSeconds === "number"
|
|
? uiTheme.fg("dim", wrapBrackets(`Timeout: ${timeoutSeconds}s`, uiTheme))
|
|
: undefined;
|
|
let warningLine: string | undefined;
|
|
if (details?.meta?.truncation) {
|
|
warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
|
|
}
|
|
const noticeLine = details?.notice ? uiTheme.fg("dim", wrapBrackets(details.notice, uiTheme)) : undefined;
|
|
|
|
const cellResults = details?.cells;
|
|
if (cellResults && cellResults.length > 0) {
|
|
let cached: { key: string; width: number; result: string[] } | undefined;
|
|
|
|
return markFramedBlockComponent({
|
|
render: (width: number): readonly string[] => {
|
|
const expanded = options.renderContext?.expanded ?? options.expanded;
|
|
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
|
|
const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
|
|
if (cached && cached.key === key && cached.width === width) {
|
|
return cached.result;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < cellResults.length; i++) {
|
|
const cell = cellResults[i];
|
|
const allEvents = cell.statusEvents ?? [];
|
|
const agentEvents = allEvents.filter(e => e.op === "agent");
|
|
const otherEvents = agentEvents.length > 0 ? allEvents.filter(e => e.op !== "agent") : allEvents;
|
|
const statusLines = renderStatusEvents(otherEvents, uiTheme, expanded);
|
|
const outputContent = formatCellOutputLines(cell, expanded, previewLines, uiTheme, width);
|
|
const outputLines = [...outputContent.lines];
|
|
if (!expanded && outputContent.hiddenCount > 0) {
|
|
outputLines.push(
|
|
uiTheme.fg("dim", `… ${outputContent.hiddenCount} more lines (ctrl+o to expand)`),
|
|
);
|
|
}
|
|
if (statusLines.length > 0) {
|
|
if (outputLines.length > 0) {
|
|
outputLines.push(uiTheme.fg("dim", "Status"));
|
|
}
|
|
outputLines.push(...statusLines);
|
|
}
|
|
const cellLines = renderCodeCell(
|
|
{
|
|
code: cell.code,
|
|
language: languageForHighlighter(cell.language ?? details?.language),
|
|
index: i,
|
|
total: cellResults.length,
|
|
title: cell.title,
|
|
status: cell.status,
|
|
spinnerFrame: options.spinnerFrame,
|
|
duration: cell.durationMs,
|
|
output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
|
|
outputMaxLines: outputLines.length,
|
|
// Code is fixed input — always shown in full, never compacted.
|
|
// Only `output` honors the collapsed preview cap above.
|
|
codeMaxLines: Number.POSITIVE_INFINITY,
|
|
expanded,
|
|
width,
|
|
},
|
|
uiTheme,
|
|
);
|
|
lines.push(...cellLines);
|
|
if (agentEvents.length > 0) {
|
|
lines.push(...renderAgentProgressEvents(agentEvents, uiTheme, options.spinnerFrame));
|
|
}
|
|
if (i < cellResults.length - 1) {
|
|
lines.push("");
|
|
}
|
|
}
|
|
if (jsonLines.length > 0) {
|
|
if (lines.length > 0) {
|
|
lines.push("");
|
|
}
|
|
lines.push(...jsonLines);
|
|
}
|
|
if (timeoutLine) {
|
|
lines.push(timeoutLine);
|
|
}
|
|
if (noticeLine) {
|
|
lines.push(noticeLine);
|
|
}
|
|
if (warningLine) {
|
|
lines.push(warningLine);
|
|
}
|
|
cached = { key, width, result: lines };
|
|
return lines;
|
|
},
|
|
invalidate: () => {
|
|
cached = undefined;
|
|
},
|
|
});
|
|
}
|
|
|
|
const displayOutput = output;
|
|
const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
|
|
|
|
const statusEvents = details?.statusEvents ?? [];
|
|
const statusLines = renderStatusEvents(
|
|
statusEvents,
|
|
uiTheme,
|
|
options.renderContext?.expanded ?? options.expanded,
|
|
);
|
|
|
|
if (!combinedOutput && statusLines.length === 0) {
|
|
const lines = [timeoutLine, noticeLine, warningLine].filter(Boolean) as string[];
|
|
return new Text(lines.join("\n"), 0, 0);
|
|
}
|
|
|
|
if (!combinedOutput && statusLines.length > 0) {
|
|
const lines = [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, noticeLine, warningLine].filter(
|
|
Boolean,
|
|
) as string[];
|
|
return new Text(lines.join("\n"), 0, 0);
|
|
}
|
|
|
|
if (options.renderContext?.expanded ?? options.expanded) {
|
|
const styledOutput = combinedOutput
|
|
.split("\n")
|
|
.map(line => uiTheme.fg("toolOutput", line))
|
|
.join("\n");
|
|
const lines = [
|
|
styledOutput,
|
|
...(statusLines.length > 0 ? [uiTheme.fg("dim", "Status"), ...statusLines] : []),
|
|
timeoutLine,
|
|
noticeLine,
|
|
warningLine,
|
|
].filter(Boolean) as string[];
|
|
return new Text(lines.join("\n"), 0, 0);
|
|
}
|
|
|
|
const styledOutput = combinedOutput
|
|
.split("\n")
|
|
.map(line => uiTheme.fg("toolOutput", line))
|
|
.join("\n");
|
|
const textContent = `\n${styledOutput}`;
|
|
|
|
let cachedWidth: number | undefined;
|
|
let cachedLines: readonly string[] | undefined;
|
|
let cachedSkipped: number | undefined;
|
|
let cachedPreviewLines: number | undefined;
|
|
|
|
return {
|
|
render: (width: number): readonly string[] => {
|
|
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
|
|
if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) {
|
|
const result = truncateToVisualLines(textContent, previewLines, width);
|
|
cachedLines = result.visualLines;
|
|
cachedSkipped = result.skippedCount;
|
|
cachedWidth = width;
|
|
cachedPreviewLines = previewLines;
|
|
}
|
|
const outputLines: string[] = [];
|
|
if (cachedSkipped && cachedSkipped > 0) {
|
|
outputLines.push("");
|
|
const skippedLine = uiTheme.fg(
|
|
"dim",
|
|
`… (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
|
|
);
|
|
outputLines.push(truncateToWidth(skippedLine, width));
|
|
}
|
|
outputLines.push(...cachedLines);
|
|
if (statusLines.length > 0) {
|
|
outputLines.push(truncateToWidth(uiTheme.fg("dim", "Status"), width));
|
|
for (const statusLine of statusLines) {
|
|
outputLines.push(truncateToWidth(statusLine, width));
|
|
}
|
|
}
|
|
if (timeoutLine) {
|
|
outputLines.push(truncateToWidth(timeoutLine, width));
|
|
}
|
|
if (noticeLine) {
|
|
outputLines.push(truncateToWidth(noticeLine, width));
|
|
}
|
|
if (warningLine) {
|
|
outputLines.push(truncateToWidth(warningLine, width));
|
|
}
|
|
return outputLines;
|
|
},
|
|
invalidate: () => {
|
|
cachedWidth = undefined;
|
|
cachedLines = undefined;
|
|
cachedSkipped = undefined;
|
|
cachedPreviewLines = undefined;
|
|
},
|
|
};
|
|
},
|
|
|
|
mergeCallAndResult: true,
|
|
inline: true,
|
|
};
|