import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui"; interface ToolCallBoxTheme { fg(color: string, text: string): string; bold(text: string): string; } /** Options for the boxed frame. */ export interface ToolCallBoxOptions { /** * Columns of padding between each border and the content. Defaults to 1, the * frame's single space on each side before outputPad existed. */ outputPad?: number; } const MIN_BOX_WIDTH = 8; const DEFAULT_OUTPUT_PAD = 1; /** The two vertical border characters. */ const BORDER_COLUMNS = 2; const TITLE = "Tools"; interface FrameSpec { theme: ToolCallBoxTheme; outputPad: number; } function border(theme: ToolCallBoxTheme, text: string): string { return theme.fg("border", text); } function normalizeOutputPad(outputPad: number | undefined): number { if (outputPad === undefined || !Number.isFinite(outputPad)) { return DEFAULT_OUTPUT_PAD; } return Math.max(0, Math.floor(outputPad)); } /** Width available to the wrapped component: the frame minus both borders and both pads. */ function contentWidth(width: number, outputPad: number): number { return Math.max(1, width - BORDER_COLUMNS - outputPad * 2); } function frameFits(width: number, outputPad: number): boolean { return width >= MIN_BOX_WIDTH && width - BORDER_COLUMNS - outputPad * 2 >= 1; } function contentLine(line: string, width: number, outputPad: number, theme: ToolCallBoxTheme): string { const innerWidth = contentWidth(width, outputPad); const content = truncateToWidth(line, innerWidth, "", true); const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content))); const sidePadding = " ".repeat(outputPad); return `${border(theme, "│")}${sidePadding}${content}${padding}${sidePadding}${border(theme, "│")}`; } function topBorder(width: number, theme: ToolCallBoxTheme): string { const innerWidth = Math.max(0, width - 2); const prefix = "─ "; const suffix = " "; const label = theme.fg("toolTitle", theme.bold(TITLE)); const titleWidth = visibleWidth(prefix + TITLE + suffix); // The title needs ten columns including the corners; below that a plain // border keeps the frame inside the requested width instead of overflowing. if (titleWidth > innerWidth) { return border(theme, "╭" + "─".repeat(innerWidth) + "╮"); } const gap = innerWidth - titleWidth; return `${border(theme, "╭" + prefix)}${label}${border(theme, suffix + "─".repeat(gap) + "╮")}`; } function bottomBorder(width: number, theme: ToolCallBoxTheme): string { return `${border(theme, "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")}`; } /** * Wrap a tool renderer's component so it renders inside an ASCII frame while * remaining a transparent stand-in for the wrapped component. * * The frame (or the raw fallback for widths too narrow for the borders and pads) * is cached by width: TuiMainScreen re-renders the whole transcript on every * frame, and without this cache every finalized tool would re-run its inner * renderer + border construction per frame (measured ~24 ms for 1000 boxed * tools). The width is the only cache key because a re-wrap of the same * component and role refreshes the spec and invalidates that frame before the * next render, and content mutations invalidate through the proxy. Reading the * spec before the cache-hit check would only repeat that work on the hot path. * * A Proxy is required, not a plain object wrapper: Pi's built-in renderers * (bash, grep, ...) reuse the previous component through `lastComponent` and * mutate it in place (`setText`, `clear`, `addChild`, `state`). A Proxy * forwards all of those to the real component, and bumps a mutation counter so * the frame cache is dropped exactly when the wrapped content changes. */ function framedBox( component: Component, getSpec: () => FrameSpec, frame: (width: number, lines: string[], spec: FrameSpec) => string[], ): Component { let cacheWidth = -1; let cacheLines: string[] | undefined; let cacheIsFramed = false; const target = component as unknown as Record; return new Proxy(target, { get(_t, prop) { if (prop === "render") { return (width: number): string[] => { // Re-wrapping refreshes the spec and invalidates this cache, and content // mutations invalidate through the proxy, so width is the only key needed. if (cacheWidth === width && cacheLines !== undefined && cacheIsFramed) { return cacheLines; } const spec = getSpec(); if (!frameFits(width, spec.outputPad)) { return component.render(width); } const lines = frame(width, component.render(contentWidth(width, spec.outputPad)), spec); cacheWidth = width; cacheLines = lines; cacheIsFramed = true; return lines; }; } if (prop === "invalidate") { return () => { cacheWidth = -1; cacheLines = undefined; cacheIsFramed = false; component.invalidate?.(); }; } const value = Reflect.get(target, prop, target); if (typeof value === "function") { // Renderers mutate the reused component through these entry points; // any such mutation makes the cached frame stale. if (prop === "setText" || prop === "clear" || prop === "addChild" || prop === "removeChild") { return (...args: unknown[]) => { const result = (value as (...a: unknown[]) => unknown).apply(target, args); cacheWidth = -1; cacheLines = undefined; cacheIsFramed = false; return result; }; } return (value as (...a: unknown[]) => unknown).bind(target); } return value; }, }) as unknown as Component; } /** * Pi's renderers hand the previous component back through `lastComponent`, so a * component can be wrapped on every updateDisplay. Idempotent wrapping (one * framed proxy per wrapped component and role) prevents frames from stacking; * the theme and outputPad cells are refreshed on each wrap so a theme or padding * change rebuilds frames with the new values instead of the first-wrap ones. */ const framedWrappers = new WeakMap(); function wrapFramed( component: Component, theme: ToolCallBoxTheme, enabled: boolean, role: "call" | "result", outputPad: number, ): Component { if (!enabled) { return component; } const target = component as object; const found = framedWrappers.get(target); const entry = found ?? { spec: { theme, outputPad } }; if (found) { entry.spec.theme = theme; entry.spec.outputPad = outputPad; } else { framedWrappers.set(target, entry); } const existing = role === "call" ? entry.call : entry.result; if (existing) { // updateDisplay always accompanies content/state changes. Re-wrapping means // the renderer is about to rebuild content, so drop any cached frame now; // the hot path (spinner ticks, input, terminal events) re-renders without // updateDisplay and still hits the width-keyed cache. existing.invalidate?.(); return existing; } const getSpec = (): FrameSpec => entry.spec; const frame = role === "call" ? (width: number, lines: string[], spec: FrameSpec) => [ topBorder(width, spec.theme), ...lines.map((line) => contentLine(line, width, spec.outputPad, spec.theme)), ] : (width: number, lines: string[], spec: FrameSpec) => [ ...lines.map((line) => contentLine(line, width, spec.outputPad, spec.theme)), bottomBorder(width, spec.theme), ]; const proxy = framedBox(component, getSpec, frame); if (role === "call") { entry.call = proxy; } else { entry.result = proxy; } // Register the proxy itself so renderers handing the wrapped component back // through lastComponent re-wrap to the same proxy instead of stacking frames. framedWrappers.set(proxy, entry); return proxy; } /** * Wrap a tool call renderer's component in the top half of the boxed frame. * * Split framing lets Pi keep its normal call/result renderers while presenting * the two adjacent components as one bordered tool transaction: the call half * renders the top border + content and the result half renders content + bottom * border. Under renderShell "self" the host stacks the two halves adjacently, * so the pair forms one contiguous ASCII box. * * `options.outputPad` pads the content by that many columns on each side and * shrinks the inner render width to match. It defaults to 1, today's single * space on each side. */ export function boxToolCall( component: Component, theme: ToolCallBoxTheme, enabled: boolean, options?: ToolCallBoxOptions, ): Component { return wrapFramed(component, theme, enabled, "call", normalizeOutputPad(options?.outputPad)); } /** * Wrap a tool result renderer's component in the bottom half of the boxed frame * (see `boxToolCall` for the split framing). * * `options.outputPad` pads the content by that many columns on each side and * shrinks the inner render width to match. It defaults to 1, today's single * space on each side. */ export function boxToolResult( component: Component, theme: ToolCallBoxTheme, enabled: boolean, options?: ToolCallBoxOptions, ): Component { return wrapFramed(component, theme, enabled, "result", normalizeOutputPad(options?.outputPad)); }