UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

147 lines • 7.01 kB
/** * Presentation for the codemode tool. * * The call shows the script; the result lists the nested tool calls with their status as they * run and the cost of its model calls, followed by the script output without the "Script completed" * header, and the time the script has been running (`Elapsed`) or took (`Took`), like bash rows. * Nested calls are not separate tool rows because they never reach the model as tool calls. */ import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import { getTextOutput, replaceTabs, startToolTimer, str, toolTimerText } from "../../core/tools/render-utils.js"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import { VisualLinePreview } from "../../modes/interactive/components/visual-truncate.js"; import { CodeHighlighter } from "../../modes/interactive/theme/theme.js"; const CODE_PREVIEW_LINES = 10; const CALL_PREVIEW_COUNT = 8; const OUTPUT_PREVIEW_LINES = 5; const COLLAPSED_ARGS_CHARS = 80; const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/; function expandHint(theme, hidden, noun) { return `${theme.fg("muted", `... (${hidden} more ${noun},`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } function formatDuration(ms) { if (ms === undefined) return ""; return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`; } /** Cents for larger amounts, two significant digits for the fractions of a cent classifier calls cost. */ function formatCost(cost) { return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`; } function statusIcon(call, theme) { switch (call.status) { case "running": return theme.fg("warning", "…"); case "ok": return theme.fg("success", "✓"); case "error": return theme.fg("error", "✗"); case "cancelled": return theme.fg("muted", "⊘"); } } function formatCall(call, theme, expanded) { const args = !expanded && call.args.length > COLLAPSED_ARGS_CHARS ? `${call.args.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : call.args; const duration = formatDuration(call.durationMs); let line = `${statusIcon(call, theme)} ${theme.fg("toolTitle", call.name)}`; if (args) line += ` ${theme.fg("muted", args)}`; if (duration) line += ` ${theme.fg("dim", duration)}`; if (call.cost) line += ` ${theme.fg("dim", formatCost(call.cost))}`; if (expanded && call.error) line += `\n ${theme.fg("error", call.error.split("\n").join("\n "))}`; return line; } export const codemodeRenderers = { renderCall(args, theme, context) { startToolTimer(context); // The code includes the `// @options:` line, so options show as part of the script. const code = str(args?.code); const title = theme.fg("toolTitle", theme.bold("codemode")); const component = context.lastComponent ?? new Container(); component.clear(); if (code === null) { component.addChild(new Text(`${title} ${theme.fg("error", "[invalid arg]")}`, 0, 0)); return component; } component.addChild(new Text(title, 0, 0)); if (code) { const state = context.state; state.scriptHighlighter ??= new CodeHighlighter(); const highlighted = state.scriptHighlighter .highlight(replaceTabs(code.replace(/\r/g, "").trimEnd()), "javascript") .join("\n"); component.addChild(context.expanded ? new Text(highlighted, 0, 0) : new VisualLinePreview({ text: highlighted, maxVisualLines: CODE_PREVIEW_LINES, keep: "start", formatHint: (hidden) => expandHint(theme, hidden, "lines"), })); } return component; }, renderResult(result, options, theme, context) { const timerText = toolTimerText(context, options.isPartial); const component = context.lastComponent ?? new Container(); component.clear(); const calls = result.details?.calls ?? []; if (calls.length > 0) { const shown = options.expanded ? calls : calls.slice(-CALL_PREVIEW_COUNT); const lines = shown.map((call) => formatCall(call, theme, options.expanded)); if (shown.length < calls.length) { lines.unshift(`${theme.fg("muted", `... (${calls.length - shown.length} earlier calls,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`); } // Collapsed rows hide earlier calls, so the total covers every call. const priced = calls.filter((call) => call.cost); if (priced.length > 1) { const total = priced.reduce((sum, call) => sum + (call.cost ?? 0), 0); lines.push(theme.fg("muted", `Model calls: ${formatCost(total)}`)); } component.addChild(new Spacer(1)); component.addChild(new Text(lines.join("\n"), 0, 0)); } // Drop the "Script completed\nWall time ...\nOutput:\n" header. Rejected input (invalid options) // has no header. const [first, ...rest] = result.content; const hasHeader = first?.type === "text" && SCRIPT_HEADER.test(first.text); const output = options.isPartial ? "" : getTextOutput({ ...result, content: hasHeader ? rest : result.content }, context.showImages).trim(); if (output) { const color = context.isError ? "error" : "toolOutput"; const styled = replaceTabs(output) .split("\n") .map((line) => theme.fg(color, line)) .join("\n"); component.addChild(new Spacer(1)); if (options.expanded) { component.addChild(new Text(styled, 0, 0)); } else { // Limit wrapped lines, not logical ones: script output is often one long JSON line. component.addChild(new VisualLinePreview({ text: styled, maxVisualLines: OUTPUT_PREVIEW_LINES, keep: "start", formatHint: (hidden) => expandHint(theme, hidden, "lines"), })); // The collapsed preview hides the truncation notice at the end, so name the file here. const fullOutputPath = result.details?.fullOutputPath; if (fullOutputPath) component.addChild(new Text(theme.fg("muted", `Full output: ${fullOutputPath}`), 0, 0)); } } if (timerText) { component.addChild(new Spacer(1)); component.addChild(new Text(theme.fg("muted", timerText), 0, 0)); } return component; }, }; //# sourceMappingURL=renderer.js.map