diff --git a/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts b/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts new file mode 100644 index 00000000..514cd001 --- /dev/null +++ b/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts @@ -0,0 +1,246 @@ +/** + * Built-in Tool Renderer Example - Custom rendering for built-in tools + * + * Demonstrates how to override the rendering of built-in tools (read, bash, + * edit, write) without changing their behavior. Each tool is re-registered + * with the same name, delegating execution to the original implementation + * while providing compact custom renderCall/renderResult functions. + * + * This is useful for users who prefer more concise tool output, or who want + * to highlight specific information (e.g., showing only the diff stats for + * edit, or just the exit code for bash). + * + * How it works: + * - registerTool() with the same name as a built-in replaces it entirely + * - We create instances of the original tools via createReadTool(), etc. + * and delegate execute() to them + * - renderCall() controls what's shown when the tool is invoked + * - renderResult() controls what's shown after execution completes + * - The `expanded` flag in renderResult indicates whether the user has + * toggled the tool output open (via ctrl+e or clicking) + * + * Usage: + * pi -e ./built-in-tool-renderer.ts + */ + +import type { BashToolDetails, EditToolDetails, ExtensionAPI, ReadToolDetails } from "@mariozechner/pi-coding-agent"; +import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; + +export default function (pi: ExtensionAPI) { + const cwd = process.cwd(); + + // --- Read tool: show path and line count --- + 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) { + let text = theme.fg("toolTitle", theme.bold("read ")); + text += theme.fg("accent", args.path); + if (args.offset || args.limit) { + const parts: string[] = []; + if (args.offset) parts.push(`offset=${args.offset}`); + if (args.limit) parts.push(`limit=${args.limit}`); + text += theme.fg("dim", ` (${parts.join(", ")})`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Reading..."), 0, 0); + + const details = result.details as ReadToolDetails | undefined; + const content = result.content[0]; + + if (content?.type === "image") { + return new Text(theme.fg("success", "Image loaded"), 0, 0); + } + + if (content?.type !== "text") { + return new Text(theme.fg("error", "No content"), 0, 0); + } + + const lineCount = content.text.split("\n").length; + let text = theme.fg("success", `${lineCount} lines`); + + if (details?.truncation?.truncated) { + text += theme.fg("warning", ` (truncated from ${details.truncation.totalLines})`); + } + + if (expanded) { + const lines = content.text.split("\n").slice(0, 15); + for (const line of lines) { + text += `\n${theme.fg("dim", line)}`; + } + if (lineCount > 15) { + text += `\n${theme.fg("muted", `... ${lineCount - 15} more lines`)}`; + } + } + + return new Text(text, 0, 0); + }, + }); + + // --- Bash tool: show command and exit code --- + 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) { + let text = theme.fg("toolTitle", theme.bold("$ ")); + const cmd = args.command.length > 80 ? `${args.command.slice(0, 77)}...` : args.command; + text += theme.fg("accent", cmd); + if (args.timeout) { + text += theme.fg("dim", ` (timeout: ${args.timeout}s)`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Running..."), 0, 0); + + const details = result.details as BashToolDetails | undefined; + const content = result.content[0]; + const output = content?.type === "text" ? content.text : ""; + + const exitMatch = output.match(/exit code: (\d+)/); + const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null; + const lineCount = output.split("\n").filter((l) => l.trim()).length; + + let text = ""; + if (exitCode === 0 || exitCode === null) { + text += theme.fg("success", "done"); + } else { + text += theme.fg("error", `exit ${exitCode}`); + } + text += theme.fg("dim", ` (${lineCount} lines)`); + + if (details?.truncation?.truncated) { + text += theme.fg("warning", " [truncated]"); + } + + if (expanded) { + const lines = output.split("\n").slice(0, 20); + for (const line of lines) { + text += `\n${theme.fg("dim", line)}`; + } + if (output.split("\n").length > 20) { + text += `\n${theme.fg("muted", "... more output")}`; + } + } + + return new Text(text, 0, 0); + }, + }); + + // --- Edit tool: show path and diff stats --- + const originalEdit = createEditTool(cwd); + pi.registerTool({ + name: "edit", + label: "edit", + description: originalEdit.description, + parameters: originalEdit.parameters, + + async execute(toolCallId, params, signal, onUpdate) { + return originalEdit.execute(toolCallId, params, signal, onUpdate); + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("edit ")); + text += theme.fg("accent", args.path); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0); + + const details = result.details as EditToolDetails | undefined; + const content = result.content[0]; + + if (content?.type === "text" && content.text.startsWith("Error")) { + return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0); + } + + if (!details?.diff) { + return new Text(theme.fg("success", "Applied"), 0, 0); + } + + // Count additions and removals from the diff + 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 text = theme.fg("success", `+${additions}`); + text += theme.fg("dim", " / "); + text += theme.fg("error", `-${removals}`); + + if (expanded) { + for (const line of diffLines.slice(0, 30)) { + if (line.startsWith("+") && !line.startsWith("+++")) { + text += `\n${theme.fg("success", line)}`; + } else if (line.startsWith("-") && !line.startsWith("---")) { + text += `\n${theme.fg("error", line)}`; + } else { + text += `\n${theme.fg("dim", line)}`; + } + } + if (diffLines.length > 30) { + text += `\n${theme.fg("muted", `... ${diffLines.length - 30} more diff lines`)}`; + } + } + + return new Text(text, 0, 0); + }, + }); + + // --- Write tool: show path and size --- + 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) { + let text = theme.fg("toolTitle", theme.bold("write ")); + text += theme.fg("accent", args.path); + const lineCount = args.content.split("\n").length; + text += theme.fg("dim", ` (${lineCount} lines)`); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0); + + const content = result.content[0]; + if (content?.type === "text" && content.text.startsWith("Error")) { + return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0); + } + + return new Text(theme.fg("success", "Written"), 0, 0); + }, + }); +}