/** * Claude Code-style rendering for Pi's built-in read/bash/write/edit tools. * * Rendering only: execute() is delegated to the original tools, so behavior, * permissions, hooks and command rewriting are untouched. No file I/O happens * in any renderer, so streaming and expansion stay smooth. * * Style rules (Claude Code look): * - Tool names are capitalized: Bash(), Read(), Write(), Update() * - Status dots: gray while running, green on success, red on error * - Assistant messages get a leading white circle (●) * - Update() renders every diff hunk as its own block — multiple changes in * one file are all shown, never folded into a single collapsed region * - Deleted diff lines: red; added lines: green + bold */ import type { BashToolDetails, EditToolDetails, ExtensionAPI, ReadToolDetails, Theme, } from "@earendil-works/pi-coding-agent"; import { createBashTool, createEditTool, createReadTool, createWriteTool, keyHint, } from "@earendil-works/pi-coding-agent"; import { Text, type Component } from "@earendil-works/pi-tui"; import { assistantDotTransformer } from "./markdown.js"; /** Tweak everything here. */ const STYLE = { dot: "\u2B24", // tool-call bullet (⬤, bigger than ●); fall back to \u25CF if your font lacks it dotGap: " ", // gap between bullet and tool name elbow: "\u23BF", // result marker (⎿) indent: " ", // result indentation maxCmdLen: 120, // truncate long commands to this length collapsedOutputLines: 20, // default bash preview: at most this many lines maxExpandedOutputLines: 500, // safety cap when ctrl+o expands bash output maxLineLen: 200, // truncate over-long output lines (prevents wrap flood) maxHunks: 20, // safety cap: render at most this many diff hunks maxDiffLineLen: 200, // truncate over-long diff lines colors: { dot: "success", // bullet color when done pendingDot: "muted", // bullet color while running (gray) errDot: "error", // bullet color on failure (red) title: "toolTitle", // tool name color arg: "accent", // path/command color info: "dim", // secondary text ok: "success", err: "error", warn: "warning", muted: "muted", text: "text", diffAdded: "toolDiffAdded", // added lines (bright green) diffRemoved: "toolDiffRemoved", // removed lines (bright red) diffContext: "toolDiffContext", // context lines (gray) }, } as const; /** Minimal render context shape we rely on. */ interface RenderCtx { lastComponent?: Component; state: Record; isPartial?: boolean; isError?: boolean; executionStarted?: boolean; argsComplete?: boolean; expanded?: boolean; args?: unknown; } type DotState = "running" | "ok" | "err"; /** * Render the status dot (●) for a tool row. * * Claude Code convention: * - gray → tool call is still running (not complete) * - green → tool finished successfully * - red → tool failed * * The color comes from theme tokens, so it follows the active theme. */ function dotFor(state: DotState, theme: Theme): string { const color = state === "running" ? STYLE.colors.pendingDot : state === "err" ? STYLE.colors.errDot : STYLE.colors.dot; return theme.fg(color, theme.bold(STYLE.dot)) + STYLE.dotGap; } /** * Map the render context to a dot state. * * isPartial stays true until the final result lands, so a tool row is * "running" while the LLM streams its args and while execute() works. * After that the row is green (ok) or red (err) based on isError. */ function dotState(ctx: RenderCtx): DotState { // isPartial stays true until the final result lands. if (ctx.isPartial) return "running"; return ctx.isError ? "err" : "ok"; } function elbow(theme: Theme): string { return theme.fg(STYLE.colors.info, `${STYLE.indent}${STYLE.elbow}`); } function oneLine(s: string, max: number = STYLE.maxCmdLen): string { const flat = (s ?? "").replace(/\s*\n\s*/g, " ; ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; } function replaceTabs(text: string, width = 3): string { return text.replace(/\t/g, " ".repeat(width)); } function isErrorText(text: string): boolean { const t = (text ?? "").trim(); return t.startsWith("Error") || t.startsWith("error:"); } /** Shared Text instance reuse: avoids re-allocating components every render. */ function getText(context: RenderCtx): Text { return (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); } function setText(context: RenderCtx, text: string): Text { const comp = getText(context); comp.setText(text); return comp; } /** * Render a status head plus up to `cap` output lines. * * Leading/trailing blank lines are trimmed, but interior blank lines are kept * so multi-line output (tables, code, logs) stays readable. */ function outputBlock(output: string, expanded: boolean, theme: Theme, prefix = ""): string { const rawLines = (output ?? "").replace(/\n+$/, "").split("\n"); let start = 0; while (start < rawLines.length && rawLines[start].trim() === "") start++; const lines = rawLines.slice(start); const head = `${elbow(theme)} ${prefix || theme.fg(STYLE.colors.info, "no output")}`; if (lines.length === 0) return head; const cap = expanded ? STYLE.maxExpandedOutputLines : STYLE.collapsedOutputLines; let body = ""; for (let i = 0; i < Math.min(lines.length, cap); i++) { body += `\n${STYLE.indent} `; body += theme.fg(STYLE.colors.text, truncateLine(lines[i], STYLE.maxLineLen)); } if (lines.length > cap) { const hint = expanded ? `… +${lines.length - cap} lines` : `… +${lines.length - cap} more (${keyHint("app.tools.expand", "to expand")})`; body += `\n${STYLE.indent} ${theme.fg(STYLE.colors.muted, hint)}`; } return head + body; } // --------------------------------------------------------------------------- // Update() diff rendering: every hunk gets its own block, always visible. // --------------------------------------------------------------------------- interface DiffLine { prefix: "+" | "-" | " "; lineNum: string; content: string; } interface DiffHunk { lines: DiffLine[]; range: string; // e.g. "12-18" } /** * Parse one display-diff line into its parts. * * Format produced by generateDiffString(): * "+123 content" → added line (new-file line 123) * "-123 content" → removed line (old-file line 123) * " 123 content" → context line * * The line-number field is right-aligned to the file width and may be empty * on the "..." skip marker (handled by the caller before this function). * Returns null for anything that is not a diff line. */ function parseDiffLine(line: string): DiffLine | null { const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); if (!match) return null; return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2], content: match[3] }; } /** * Split the display diff string into hunks. The diff format from * generateDiffString() collapses unchanged regions into " ... " markers, so * every change region between markers becomes one standalone hunk. */ function parseDiffHunks(diff: string): DiffHunk[] { const hunks: DiffHunk[] = []; let current: DiffLine[] = []; let min = Infinity; let max = -Infinity; const flush = () => { if (current.length === 0) return; hunks.push({ lines: current, range: Number.isFinite(min) ? (min === max ? `${min}` : `${min}-${max}`) : "", }); current = []; min = Infinity; max = -Infinity; }; for (const raw of diff.split("\n")) { if (raw.trim() === "...") { flush(); continue; } const parsed = parseDiffLine(raw); if (!parsed) continue; current.push(parsed); // Only changed lines count toward the hunk range — context lines are // display padding, not the "10-20" region the user cares about. if (parsed.prefix !== " ") { const num = parseInt(parsed.lineNum, 10); if (Number.isFinite(num)) { if (num < min) min = num; if (num > max) max = num; } } } flush(); return hunks; } function truncateLine(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } function renderHunk(hunk: DiffHunk, theme: Theme): string { let out = ""; for (const line of hunk.lines) { const content = truncateLine(replaceTabs(line.content), STYLE.maxDiffLineLen); const num = line.lineNum.padStart(4, " "); const prefix = line.prefix === " " ? " " : line.prefix; const raw = `${prefix}${num} ${content}`; if (line.prefix === "-") { // Deleted line: red foreground (toolDiffRemoved token). No // strikethrough — the red color alone distinguishes removals. out += `\n${STYLE.indent} `; out += theme.fg(STYLE.colors.diffRemoved, raw); } else if (line.prefix === "+") { // Added line: bright green + bold for strong contrast against the // dimmed context lines around it (toolDiffAdded token). out += `\n${STYLE.indent} `; out += theme.fg(STYLE.colors.diffAdded, theme.bold(raw)); } else { out += `\n${STYLE.indent} `; out += theme.fg(STYLE.colors.diffContext, raw); } } return out; } function renderDiffBlocks(diff: string, theme: Theme, cache: Record): string { const key = `diff:${diff}`; let hunks = cache[key] as DiffHunk[] | undefined; if (!hunks) { hunks = parseDiffHunks(diff); cache[key] = hunks; } let out = ""; for (let i = 0; i < Math.min(hunks.length, STYLE.maxHunks); i++) { const hunk = hunks[i]; out += `\n${elbow(theme)} `; out += theme.fg(STYLE.colors.info, theme.bold(hunk.range || "diff")); out += renderHunk(hunk, theme); } if (hunks.length > STYLE.maxHunks) { out += `\n${STYLE.indent} `; out += theme.fg(STYLE.colors.muted, `… +${hunks.length - STYLE.maxHunks} more diff hunks`); } return out; } // --------------------------------------------------------------------------- // Assistant messages: leading white circle. // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { const cwd = process.cwd(); // White circle on assistant text messages (display-only; session untouched). // Logic lives in markdown.ts as a pure function (unit-tested there). pi.registerMarkdownTransformer((markdown, context) => assistantDotTransformer(markdown, context), ); const originalBash = createBashTool(cwd); pi.registerTool({ name: "bash", label: "Bash", description: originalBash.description, parameters: originalBash.parameters, async execute(toolCallId, params, signal, onUpdate) { return originalBash.execute(toolCallId, params, signal, onUpdate); }, renderCall(args, theme, context: RenderCtx) { let text = `${dotFor(dotState(context), theme)}`; text += theme.fg(STYLE.colors.title, theme.bold("Bash")); text += theme.fg(STYLE.colors.arg, `(${oneLine(args.command)})`); if (args.timeout) text += theme.fg(STYLE.colors.info, ` (${args.timeout}s)`); return setText(context, text); }, renderResult(result, { expanded, isPartial }, theme, context: RenderCtx) { if (isPartial) { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.warn, "Running…")}`); } const details = result.details as BashToolDetails | undefined; const content = result.content[0]; const output = content?.type === "text" ? content.text : ""; if (context.isError || isErrorText(output)) { const first = output.split("\n")[0] ?? "command failed"; return setText( context, `${elbow(theme)} ${theme.fg(STYLE.colors.err, oneLine(first, 200))}`, ); } const exitMatch = output.match(/exited with code (\d+)/); const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null; const failed = exitCode !== null && exitCode !== 0; const lineCount = output.split("\n").filter((l) => l.trim()).length; const status = failed ? theme.fg(STYLE.colors.err, `exit ${exitCode}`) : theme.fg(STYLE.colors.ok, `done (${lineCount} lines)`); const trunc = details?.truncation?.truncated ? theme.fg(STYLE.colors.warn, " [truncated]") : ""; const prefix = `${status}${trunc}`; if (!output.trim()) return setText(context, `${elbow(theme)} ${prefix}`); if (failed) { // Failed runs: status head only — the error text is already on the // isError path (bash throws), so never dump output under a red head. return setText(context, `${elbow(theme)} ${prefix}`); } return setText(context, outputBlock(output, expanded, theme, prefix)); }, }); const originalRead = createReadTool(cwd); pi.registerTool({ name: "read", label: "Read", description: originalRead.description, parameters: originalRead.parameters, async execute(toolCallId, params, signal, onUpdate) { return originalRead.execute(toolCallId, params, signal, onUpdate); }, renderCall(args, theme, context: RenderCtx) { let text = `${dotFor(dotState(context), theme)}`; text += theme.fg(STYLE.colors.title, theme.bold("Read")); text += theme.fg(STYLE.colors.arg, `(${args.path})`); const parts: string[] = []; if (args.offset) parts.push(`offset=${args.offset}`); if (args.limit) parts.push(`limit=${args.limit}`); if (parts.length) text += theme.fg(STYLE.colors.info, ` [${parts.join(", ")}]`); return setText(context, text); }, renderResult(result, { isPartial }, theme, context: RenderCtx) { if (isPartial) { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.warn, "Reading…")}`); } const details = result.details as ReadToolDetails | undefined; const content = result.content[0]; if (content?.type === "image") { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.ok, "Image loaded")}`); } if (content?.type !== "text") { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.err, "No content")}`); } const outText = content.text; if (context.isError || isErrorText(outText)) { const first = outText.split("\n")[0] ?? "read failed"; return setText( context, `${elbow(theme)} ${theme.fg(STYLE.colors.err, oneLine(first, 200))}`, ); } const lineCount = outText.split("\n").length; let head = theme.fg(STYLE.colors.ok, `Read ${lineCount} lines`); if (details?.truncation?.truncated) { head += theme.fg(STYLE.colors.warn, ` (truncated from ${details.truncation.totalLines})`); } // Deliberately no expansion: Read stays a one-line summary. return setText(context, `${elbow(theme)} ${head}`); }, }); const originalEdit = createEditTool(cwd); pi.registerTool({ name: "edit", label: "Update", description: originalEdit.description, parameters: originalEdit.parameters, async execute(toolCallId, params, signal, onUpdate) { return originalEdit.execute(toolCallId, params, signal, onUpdate); }, renderCall(args, theme, context: RenderCtx) { let text = `${dotFor(dotState(context), theme)}`; text += theme.fg(STYLE.colors.title, theme.bold("Update")); text += theme.fg(STYLE.colors.arg, `(${args.path})`); const n = Array.isArray(args.edits) ? args.edits.length : undefined; if (n !== undefined) text += theme.fg(STYLE.colors.info, ` [${n} block${n === 1 ? "" : "s"}]`); return setText(context, text); }, renderResult(result, { isPartial }, theme, context: RenderCtx) { if (isPartial) { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.warn, "Editing…")}`); } const details = result.details as EditToolDetails | undefined; const content = result.content[0]; const outText = content?.type === "text" ? content.text : ""; if (context.isError || isErrorText(outText)) { const first = outText.split("\n")[0] ?? "edit failed"; return setText( context, `${elbow(theme)} ${theme.fg(STYLE.colors.err, oneLine(first, 200))}`, ); } if (!details?.diff) { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.ok, "Updated")}`); } const diffLines = details.diff.split("\n"); let additions = 0; let removals = 0; for (const line of diffLines) { if (line.startsWith("+") && !line.startsWith("+++")) additions++; if (line.startsWith("-") && !line.startsWith("---")) removals++; } let head = theme.fg(STYLE.colors.ok, "Updated"); head += theme.fg(STYLE.colors.info, " with "); head += theme.fg(STYLE.colors.ok, `+${additions}`); if (removals > 0) head += theme.fg(STYLE.colors.err, ` −${removals}`); // Every hunk is always shown — Update() never folds multiple changes // into one collapsed region. const blocks = renderDiffBlocks(details.diff, theme, context.state); return setText(context, `${elbow(theme)} ${head}${blocks}`); }, }); const originalWrite = createWriteTool(cwd); pi.registerTool({ name: "write", label: "Write", description: originalWrite.description, parameters: originalWrite.parameters, async execute(toolCallId, params, signal, onUpdate) { return originalWrite.execute(toolCallId, params, signal, onUpdate); }, renderCall(args, theme, context: RenderCtx) { let text = `${dotFor(dotState(context), theme)}`; text += theme.fg(STYLE.colors.title, theme.bold("Write")); text += theme.fg(STYLE.colors.arg, `(${args.path})`); if (typeof args.content === "string") { const lineCount = args.content.split("\n").length; text += theme.fg(STYLE.colors.info, ` [${lineCount} lines]`); } return setText(context, text); }, renderResult(result, { isPartial }, theme, context: RenderCtx) { if (isPartial) { return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.warn, "Writing…")}`); } const content = result.content[0]; const outText = content?.type === "text" ? content.text : ""; if (context.isError || isErrorText(outText)) { const first = outText.split("\n")[0] ?? "write failed"; return setText( context, `${elbow(theme)} ${theme.fg(STYLE.colors.err, oneLine(first, 200))}`, ); } // Deliberately no expansion: Write stays a one-line summary. return setText(context, `${elbow(theme)} ${theme.fg(STYLE.colors.ok, "Written")}`); }, }); }