/** * The `ToolExecutionComponent.prototype.render` patch. * * Why a patch and not `registerTool`: pi resolves same-named tools by *first* * registration (`ExtensionRunner.getAllRegisteredTools` — "first registration * per name wins"), and `ExtensionAPI` exposes no way to read another * extension's `execute`. So a rendering-only extension can never take over a * tool a third-party package registered — `ffgrep`, `fffind` and the like stay * on their own renderer no matter what this package does through the public * slots. * * The component that draws *every* tool row is public API * (`ToolExecutionComponent`, exported from the package root), and the tool name * is just an instance field it reads at paint time. Patching its `render` lands * outside the per-tool renderer lookup, so one implementation covers built-ins * and third-party tools alike. * * What is being bet on, and the guards that make the bet safe: * - The class stays exported and keeps `render(width)` / `updateDisplay()`. * Checked before installing; a shape mismatch skips the patch entirely. * - pi's version is one this package was built against. Checked against * `MIN_PI_VERSION`; an unknown version is allowed through (the shape check * already passed), an unsupported one is not. * - The instance fields `toolName`, `args`, `result`, `isPartial`, `expanded` * exist. They are `private` in TypeScript, which is compile-time only, but * they carry no runtime guarantee — so a row without a usable `toolName` is * handed back to pi rather than drawn wrong. * * Every failure path degrades to pi's own rendering. Nothing here can hide a * row: the fallback is always `predecessor.call(this, width)`. * * Multiple packages may patch the same prototype. The record is keyed by a * global symbol carrying this package's name, so an install chains onto * whatever was there before instead of overwriting it, and `owners` lets a * disabled extension fall through without uninstalling for everyone else. */ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent"; /** Oldest pi whose component shape this was verified against. */ export const MIN_PI_VERSION = "0.84.0"; /** Shared across every copy of this package that ends up loaded. */ const PATCH_MAGIC = Symbol.for("pi-tool-ui.tool-patch"); /** Unique per extension instance, so one copy disabling itself frees only its own slot. */ const PATCH_OWNER = Symbol("pi-tool-ui-owner"); /** The row as it exists at runtime. Everything is optional on purpose. */ export interface ToolRow { toolName?: string; args?: Record; expanded?: boolean; isPartial?: boolean; result?: { content?: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean; }; } /** * Draw a row, or return `undefined` to let pi draw it. * * `undefined` is the answer for anything this package does not recognise — * `edit`, and any tool whose output it cannot summarise — which is what keeps * their full native output intact. */ export type RowRenderer = (row: ToolRow, width: number) => string[] | undefined; interface PatchRecord { predecessor: (this: unknown, width: number) => string[]; owners: Map; } interface PatchTarget { [PATCH_MAGIC]?: PatchRecord; render?: (width: number) => string[]; updateDisplay?: () => void; } /** Compare dotted numeric versions. Suffixes like `-beta.1` are ignored. */ export function compareVersions(left: string, right: string): number { const parse = (value: string): number[] => (value.split("-")[0] ?? "") .split(".") .map((part) => Number.parseInt(part, 10) || 0); const a = parse(left); const b = parse(right); for (let i = 0; i < Math.max(a.length, b.length); i++) { const diff = (a[i] ?? 0) - (b[i] ?? 0); if (diff !== 0) return diff > 0 ? 1 : -1; } return 0; } export function isSupportedPiVersion( version: string, minimum: string = MIN_PI_VERSION, ): boolean { return compareVersions(version, minimum) >= 0; } /** * Find the running pi's version by walking up from the entrypoint. * * Returns `undefined` when it cannot be determined — a linked checkout, an * unusual launcher — in which case the shape check alone decides. */ export function findPiVersion( read: (path: string) => string, entrypoint: string | undefined, dirname: (path: string) => string, join: (...parts: string[]) => string, root: string, ): string | undefined { if (!entrypoint) return undefined; let current = dirname(entrypoint); while (current && current !== root) { try { const manifest = JSON.parse(read(join(current, "package.json"))) as { name?: unknown; version?: unknown; }; if ( manifest.name === "@earendil-works/pi-coding-agent" && typeof manifest.version === "string" ) { return manifest.version; } } catch { // Not a manifest, or not the one being looked for. Keep walking. } const parent = dirname(current); if (parent === current) break; current = parent; } return undefined; } /** Why the patch is not installed, for `/tool-ui`. Empty when it is. */ export interface InstallResult { installed: boolean; reason: string; } /** * Install the row renderer, chaining onto any patch already in place. * * `render` is replaced once per prototype; later installs only add an owner. * Owners are consulted in insertion order and the first non-`undefined` answer * wins, so a second package patching the same prototype still gets a turn. */ export function installPatch( renderer: RowRenderer, piVersion: string | undefined, ): InstallResult { try { const proto = ToolExecutionComponent.prototype as unknown as PatchTarget; if ( !proto || typeof proto.render !== "function" || typeof proto.updateDisplay !== "function" ) { return { installed: false, reason: "prototype shape unavailable" }; } if (piVersion && !isSupportedPiVersion(piVersion)) { return { installed: false, reason: `pi ${piVersion} unsupported (need >= ${MIN_PI_VERSION})`, }; } let record = proto[PATCH_MAGIC]; if (!record) { const predecessor = proto.render; record = { predecessor, owners: new Map() }; Object.defineProperty(proto, PATCH_MAGIC, { configurable: true, enumerable: false, value: record, }); const installed = record; proto.render = function patchedRender(this: unknown, width: number): string[] { const row = this as ToolRow; // Ctrl+O is pi's own control and always wins: an expanded row goes // straight back to the built-in renderer, in full. if (!row.expanded) { // A row with no usable name cannot be classified, so it is pi's to // draw. Same for anything no owner claims. if (typeof row.toolName === "string" && row.toolName !== "") { for (const draw of installed.owners.values()) { const drawn = draw(row, width); if (drawn) return drawn; } } } return installed.predecessor.call(this, width); }; } record.owners.set(PATCH_OWNER, renderer); return { installed: true, reason: "" }; } catch (error) { return { installed: false, reason: error instanceof Error ? error.message : String(error), }; } } /** * Drop this package's renderer. * * The patched `render` stays in place — another package may still be chained * onto it — but with no owners left it is a pass-through to `predecessor`. */ export function uninstallPatch(): void { try { const proto = ToolExecutionComponent.prototype as unknown as PatchTarget; proto[PATCH_MAGIC]?.owners.delete(PATCH_OWNER); } catch { // Nothing to undo. } }