/** * pi-tool-ui — Claude Code-style tool rows for pi. * * What it does: replaces the collapsed rendering of a known set of tools with a * two-line shape — `⏺ Tool(args)` and an indented `⎿ summary` — so a turn reads * as a list of actions instead of a wall of output. * * How it does it: by patching `ToolExecutionComponent.prototype.render`, the * component every tool row is drawn by. The alternative — filling the public * `renderCall` / `renderResult` slots through `registerTool` — only ever * reaches tools this package registers first, and pi resolves same-named tools * by *first* registration with no way to inherit another package's `execute`. * That put every third-party tool permanently out of reach: `ffgrep` and * `fffind` kept their own multi-line rendering next to six compact built-ins. * Patching the shared component is what makes one look cover both. See * `patch.ts` for the guards that keep that trade honest. * * What it deliberately does not do: * - No change to agent behaviour. No tool is registered, replaced, wrapped or * hidden; `execute`, parameters and descriptions are untouched, and the * model sees exactly the tools it saw before. * - No hidden rows. Every claimed row is drawn; every unclaimed one is handed * back to pi intact. Ctrl+O expands to pi's own renderer, in full. * - No configuration. There is one look and no switches. * * `edit` is deliberately not claimed: its diff is the point of the row, and a * one-line summary would bury it. Unknown tools are left alone for the same * reason — a summary this package cannot compute is worse than pi's own output. */ import { readFileSync } from "node:fs"; import { dirname, join, parse } from "node:path"; import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { findPiVersion, type InstallResult, installPatch, MIN_PI_VERSION, type ToolRow, uninstallPatch, } from "./patch.ts"; import { specFor } from "./rows.ts"; /** Leading glyph of a call row, as in Claude Code. */ const BULLET = "⏺"; /** Elbow that ties a result to the call above it. */ const ELBOW = "⎿"; /** * The patch owns the whole row, including the left margin pi's `Box` used to * provide. One column keeps these rows lined up with the ones still drawn by * pi (`edit`, and any unclaimed tool). */ const INDENT = " "; /** Everything the renderer needs that only the extension context can supply. */ interface State { theme?: Theme; install: InstallResult; } /** * `⏺ Grep(pattern: "timeout", path: "src")`, cut to one line. * * A long bash command or path would otherwise wrap to three or four lines and * undo the point of the collapsed view. Whitespace inside the detail collapses * to single spaces first, so a multi-line command still reads as one call. */ function callRow(title: string, detail: string, theme: Theme, width: number): string { const flat = detail.replace(/\s+/g, " ").trim(); const head = `${INDENT}${theme.fg("accent", BULLET)} ${theme.fg("toolTitle", theme.bold(title))}`; const room = width - visibleWidth(`${INDENT}${BULLET} ${title}()`); const body = room > 0 ? truncateToWidth(flat, room, "…") : ""; return `${head}${theme.fg("muted", `(${body})`)}`; } /** ` ⎿ 38 matches · 6 files` */ function resultRow( summary: string, theme: Theme, isError: boolean, width: number, ): string { const role = isError ? "error" : "muted"; const lead = `${INDENT} ${theme.fg("muted", ELBOW)} `; const room = width - visibleWidth(`${INDENT} ${ELBOW} `); const body = room > 0 ? truncateToWidth(summary, room, "…") : ""; return `${lead}${theme.fg(role, body)}`; } /** * Draw one row, or return `undefined` to leave it to pi. * * The leading `""` line is deliberate: pi's native rendering padded the tool * block above (a `Spacer`, then the background `Box`), and this patch bypasses * both — without an empty first line the row would sit flush against the * content above it. * * Bailing out is the answer whenever anything needed is missing — an unclaimed * tool, no theme captured yet — because a row pi draws is always correct and a * row drawn from half the facts is not. */ export function drawRow( row: ToolRow, width: number, state: State, ): string[] | undefined { const theme = state.theme; if (!theme || typeof row.toolName !== "string") return undefined; const spec = specFor(row.toolName); if (!spec) return undefined; const detail = spec.detail(row.args ?? {}); const call = callRow(spec.title, detail, theme, width); // No result yet: the call row alone is the running state. The absence of the // `⎿` line is what says "still going" — pi's spinner lives in the renderer // this patch replaced, so there is nothing to animate here. const result = row.result; const running = !result || row.isPartial === true; // `edit` (and any `settledOnly` tool) is pi's while it runs: its live diff // preview lives in pi's renderer and would be lost if claimed early. The // same goes for a settled error — pi renders the failure message itself. if (spec.settledOnly && (running || result?.isError === true)) return undefined; if (running) return ["", call]; const isError = result.isError === true; let summary: string; try { summary = spec.summary(result); } catch { // A summary that throws is a bug in this package, not a reason to lose the // row. Fall back to pi's rendering, which still shows the real output. return undefined; } return ["", call, resultRow(summary, theme, isError, width)]; } export default function (pi: ExtensionAPI): void { const state: State = { install: { installed: false, reason: "not attempted" } }; state.install = installPatch( (row, width) => drawRow(row, width, state), findPiVersion( (path) => readFileSync(path, "utf8"), process.argv[1], dirname, join, process.argv[1] ? parse(process.argv[1]).root : "/", ), ); // The theme is only reachable through an extension context, and the patched // `render` receives none — so it is captured on every event that carries one // and read at paint time. Until the first event lands, rows fall back to pi. const capture = (_event: unknown, ctx: { ui?: { theme?: Theme } }): void => { if (ctx.ui?.theme) state.theme = ctx.ui.theme; }; pi.on("session_start", capture); pi.on("agent_start", capture); pi.on("turn_start", capture); pi.on("session_shutdown", () => { uninstallPatch(); }); pi.registerCommand("tool-ui", { description: "Show pi-tool-ui rendering status", handler: async (_args, ctx) => { const { installed, reason } = state.install; ctx.ui.notify( installed ? `pi-tool-ui active (pi >= ${MIN_PI_VERSION})` : `pi-tool-ui inactive — ${reason}. Rows keep pi's native rendering.`, installed ? "info" : "warning", ); }, }); }