/** * sl0p Tool Boxes * * Reframes the built-in tool rows (bash, read, edit, write, grep, find, ls) * in the same rounded-border "turn" style used by the last-prompt * topbar widget — a labelled top border and a rounded bottom, with the body * simply indented to sit under the box interior. While a tool executes the * top/bottom borders pulse green; when it finalizes they solidify to the status * color. * * There are no side rails: we run the agent inside a zellij fork that draws its * own border around the pane, so per-box verticals were redundant chrome. * * It does NOT reimplement tool behavior or output: it grabs each built-in * tool *definition* (execute + native renderCall/renderResult, so syntax * highlighting, diffs, truncation warnings etc. are preserved) and wraps the * lines those native renderers produce inside the frame. * * Border/state palette (matches last-prompt.ts): * pending -> warning (◉) * success -> dim (✓) * error -> error (✗) * * Note: overriding built-in tools makes interactive mode print a one-time * "overriding built-in tool" warning per tool. That is expected. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createBashToolDefinition, createReadToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, keyHint, highlightCode, getLanguageFromPath, } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; // Minimal structural types so we don't fight the full generics. type Theme = any; type Component = { render(width: number): string[]; invalidate?: () => void }; type ToolDef = { name: string; label?: string; description: string; parameters: any; promptSnippet?: string; execute: (...a: any[]) => any; renderCall?: (args: any, theme: Theme, context: any) => Component; renderResult?: (result: any, options: any, theme: Theme, context: any) => Component; }; const H = "─"; // End caps for the top/bottom rules. Now that there are no side rails, a corner // glyph (rounded OR square) would curve/turn toward a vertical that isn't there, // so the caps are just more horizontal — the rules read as straight rails. // Set these back to ╭╮╰╯ to restore the rounded box. const TL = H; const TR = H; const BL = H; const BR = H; type Status = "pending" | "ok" | "error"; type Paint = (s: string) => string; function borderColorFor(status: Status): string { return status === "error" ? "error" : status === "pending" ? "warning" : "dim"; } // ── Pulsing border for in-progress tools ──────────────────────────────────── // Design after three flicker bugs — the invariant is: WE NEVER FORCE A REPAINT. // // 1. "Active" is driven strictly by live tool_execution_start/end events keyed // by toolCallId, never by render-time status. On session restore (`pi -c`) // finished rows re-render but fire no start event, so they never animate. // (An earlier version inferred "pending" from the default status and started // a 10fps invalidate storm over every restored row — the startup lockup.) // // 2. There is NO animation timer. The pulse color is a pure function of // wall-clock time, so any render shows the current color. It advances only // on renders that were going to happen anyway — chiefly the natural re-renders // that streaming tool output already triggers ("the stream hydrates the // pulse", with irregular cadence). Forcing periodic re-renders of a large, // taller-than-screen streaming box is exactly what flickered. A silent tool // just holds a static active color until it streams output or finishes. // // 3. Only the horizontal top/bottom borders pulse. The body lines are a // STATIC color (gray dotted while in-flight, solid status color once // settled), so a repaint never has to recolor body lines. // // The pulse is a single hue — green, i.e. "in progress" — modulated in // BRIGHTNESS only (dim green <-> bright green). An earlier version cycled a // 4-color synthwave palette over 4s, which read as a slow hue drift rather than // a pulse. const PULSE_DIM: [number, number, number] = [0x0d, 0x40, 0x23]; // deep green, near-idle const PULSE_BRIGHT: [number, number, number] = [0x3b, 0xff, 0x7a]; // hot green, peak // One full dim->bright->dim beat. This is the ONLY knob for pulse speed. // // SAFETY FLOOR: do not go below ~400ms. A brightness pulse is a flicker, and // 3-30Hz is the photosensitive-seizure band (this repo already shipped one // seizure-grade flicker bug — see NOTES.md). 800ms = 1.25Hz: fast enough to // read as a heartbeat, well clear of the danger band. // // UPPER practical bound comes from render cadence, not taste: the pulse never // forces a repaint (invariant above), it just samples wall-clock time on renders // that were happening anyway (~10fps while the spinner runs). At 800ms that is // ~8 samples per beat — smooth. Much faster and consecutive renders land on // distant brightnesses, so it degrades into random jitter instead of a pulse. const CYCLE_MS = 800; function lerp(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t); } // phase in [0,1) -> green at that point in the beat. // Raised cosine for a smooth ramp with no direction-reversal snap, then gamma // (^1.7) to bias it toward the dim end: the border rests dark and spikes bright, // which reads as a *pulse* rather than a sine-wave fade. function pulseRGB(phase: number): [number, number, number] { const p = (((phase % 1) + 1) % 1); const wave = (1 - Math.cos(2 * Math.PI * p)) / 2; // 0 -> 1 -> 0 const t = Math.pow(wave, 1.7); return [ lerp(PULSE_DIM[0], PULSE_BRIGHT[0], t), lerp(PULSE_DIM[1], PULSE_BRIGHT[1], t), lerp(PULSE_DIM[2], PULSE_BRIGHT[2], t), ]; } function fgRGB(rgb: [number, number, number], s: string): string { return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[39m`; } const activeIds = new Set(); // toolCallIds executing in THIS session const rowsById = new Map(); // toolCallId -> row state (start/end nudges) // toolCallId -> execution timing. `end` is set on tool_execution_end; while a // tool is still running `end` is undefined and elapsed is measured live (only // advancing on renders that were going to happen anyway — no forced repaint). const timingById = new Map(); // Tools that get a right-aligned runtime stamped into their bottom border. const RUNTIME_TOOLS = new Set(["bash"]); function fmtDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; if (s < 10) return `${s.toFixed(1)}s`; if (s < 60) return `${Math.round(s)}s`; const m = Math.floor(s / 60); const rem = Math.round(s % 60); return `${m}m${rem}s`; } // Painted ` 1.2s ` label for the bottom border, or undefined if untimed. function runtimeLabel(st: FrameState, theme: Theme): string | undefined { const id = st.toolCallId; if (!id) return undefined; const t = timingById.get(id); if (!t) return undefined; const ms = (t.end ?? Date.now()) - t.start; return " " + theme.fg("muted", fmtDuration(ms)) + " "; } function pulsePaint(): Paint { // Pure function of wall-clock time — no counter, no timer. const rgb = pulseRGB((Date.now() % CYCLE_MS) / CYCLE_MS); return (s: string) => fgRGB(rgb, s); } // Deterministic "held" green for a top border we are not allowed to animate // (box taller than the viewport — see canPulseTopBorder). Picking a fixed // mid-beat color instead of "whatever the pulse happened to be when content last // changed" makes the frozen border look intentional rather than randomly stuck. const HOLD_PAINT: Paint = ((rgb: [number, number, number]) => (s: string) => fgRGB(rgb, s))( pulseRGB(0.5), ); // Rows of terminal chrome that are never transcript: the input box, footer, // status line and the last-prompt topbar widget. Deliberately generous — // over-estimating just means we freeze a box we could have animated (harmless), // while under-estimating risks the full-screen redraw we are avoiding. const RESERVED_ROWS = 14; // May the CALL slot animate its top border? // // The flicker-#4 bug is specifically "recolor a line that sits ABOVE the // viewport": tui.js does `if (firstChanged < prevViewportTop) fullRender(true)`, // so an off-screen top border repaints the entire screen on every render tick. // That only happens once the box is taller than the screen. While the whole box // still fits, firstChanged lands inside the viewport and tui diffs from there // down — cheap for a small box, and it means short tool calls get a top border // that pulses in unison with the bottom one. // // `resultH` is one frame stale (the call slot renders before the result slot). // That is fine: the box only grows, so the worst case is a single extra full // redraw on the frame it crosses the threshold, then it freezes. function canPulseTopBorder(st: FrameState, callH: number): boolean { const rows = process.stdout.rows; if (!rows) return false; // unknown viewport -> assume the worst, freeze const boxH = callH + (st.resultH ?? 0); return boxH <= rows - RESERVED_ROWS; } function solidPaint(theme: Theme, color: string): Paint { return (s: string) => theme.fg(color, s); } function markActive(id: string | undefined): void { if (id) activeIds.add(id); } function clearActive(id: string | undefined): void { if (id) activeIds.delete(id); } // Settle any still-active rows (e.g. a missed end event) and clear. No timer. function stopAllAnim(): void { for (const id of activeIds) rowsById.get(id)?.ctxInvalidate?.(); activeIds.clear(); } // `paint` overrides the pending color so the in-flight ◉ beats in sync with the // green borders instead of sitting there as a static amber dot. It lives on the // same line as the top border, so painting it costs no extra repaint. function statusIcon(status: Status, theme: Theme, paint?: Paint): string { if (status === "error") return theme.fg("error", "✗"); if (status === "pending") return (paint ?? ((s: string) => theme.fg("warning", s)))("◉"); return theme.fg("success", "✓"); } // Collapse $HOME to ~ for tidier header paths. function prettyPath(p: string): string { const home = process.env.HOME; return home && p.startsWith(home) ? "~" + p.slice(home.length) : p; } function stripAnsi(s: string): string { // eslint-disable-next-line no-control-regex return s.replace(/\x1b\[[0-9;]*m/g, ""); } // ── Diff rendering (edit tool) ────────────────────────────────────────────── // Instead of recoloring the native green/red diff lines, we render the diff // ourselves from the raw diff string (result.details.diff / the live preview): // syntax-highlight each code line, draw a `± lineNum` gutter, and lay a subtle // bg tint across added/removed rows so add/remove is signaled by BOTH a gutter // marker and a background band while the code keeps real syntax colors. const DIFF_ADDED_BG = "#0c2417"; // dark green band behind added lines const DIFF_REMOVED_BG = "#2a0f16"; // dark red band behind removed lines function bgAnsi(hex: string): string { const h = hex.replace("#", ""); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `\x1b[48;2;${r};${g};${b}m`; } // Lay a background band under an already-fg-colored (syntax-highlighted) string // and pad to `width`. highlightCode/theme.fg reset only the foreground // (\x1b[39m), which leaves our bg intact; but a stray full/bg reset would punch // a hole, so we re-assert the bg after any \x1b[0m / \x1b[49m inside the content. function fillBg(hex: string, s: string, width: number): string { let vis = s; if (visibleWidth(vis) > width) vis = truncateToWidth(vis, width, "…"); const pad = Math.max(0, width - visibleWidth(vis)); const bg = bgAnsi(hex); const body = vis // eslint-disable-next-line no-control-regex .replace(/\x1b\[0m/g, "\x1b[0m" + bg) // eslint-disable-next-line no-control-regex .replace(/\x1b\[49m/g, "\x1b[49m" + bg); return bg + body + " ".repeat(pad) + "\x1b[49m"; } type DiffRow = { kind: "add" | "del" | "ctx" | "sep"; num: string; content: string }; // Parse the display diff string (lines like `+12 code`, `- 3 code`, ` 4 code`, // or ` ...` skip separators — see generateDiffString) into typed rows. function parseDiffRows(diffText: string): DiffRow[] { const rows: DiffRow[] = []; for (const raw of diffText.split("\n")) { if (raw === "") continue; const m = raw.match(/^([+\- ])(\s*\d*) (.*)$/); if (!m) { rows.push({ kind: "ctx", num: "", content: raw.replace(/^[+\- ]/, "") }); continue; } const num = m[2].trim(); const content = m[3].replace(/\t/g, " "); if (num === "" && content === "...") { rows.push({ kind: "sep", num: "", content: "" }); continue; } const kind = m[1] === "+" ? "add" : m[1] === "-" ? "del" : "ctx"; rows.push({ kind, num, content }); } return rows; } // Render a display diff string into framed body lines: syntax-highlighted code, // a `± lineNum` gutter, and a bg band on add/remove rows. function renderEditDiff( diffText: string, path: string | undefined, inner: number, theme: Theme, ): string[] { if (!diffText) return []; const lang = path ? getLanguageFromPath(path) : undefined; const rows = parseDiffRows(diffText); const numW = rows.reduce((w, r) => Math.max(w, r.num.length), 1); const gutterW = numW + 4; // marker(1) + space + num(numW) + space const out: string[] = []; for (const r of rows) { if (r.kind === "sep") { out.push(theme.fg("muted", " ".repeat(numW + 2) + "⋯")); continue; } // Highlight the code content (single line) with the file's language. const hl = (highlightCode(r.content, lang)[0] ?? r.content) as string; const marker = r.kind === "add" ? theme.fg("toolDiffAdded", "+") : r.kind === "del" ? theme.fg("toolDiffRemoved", "-") : " "; const numStr = theme.fg("muted", r.num.padStart(numW, " ")); const full = `${marker} ${numStr} ${hl}`; if (r.kind === "add") out.push(fillBg(DIFF_ADDED_BG, full, inner)); else if (r.kind === "del") out.push(fillBg(DIFF_REMOVED_BG, full, inner)); else { let line = full; if (visibleWidth(line) > inner) line = truncateToWidth(line, inner, "…"); out.push(line); } } void gutterW; return out; } // When we hoist the path into the header, drop the native call's redundant // leading " " line (and any following blank spacer) while keeping // the rest of its body (e.g. edit renders its diff in renderCall). function stripRedundantCallLines(lines: string[], name: string): string[] { let i = 0; while (i < lines.length) { const t = stripAnsi(lines[i]).trim(); if (t === "" || t === name || t.startsWith(name + " ")) { i++; continue; } break; } return lines.slice(i); } // Per-tool header summary: the key argument hoisted into the top border so we // can drop the redundant native call line (e.g. "edit "). const HEADER_SUMMARY: Record string | undefined> = { edit: (a) => (a?.path ? prettyPath(a.path) : undefined), read: (a) => (a?.path ? prettyPath(a.path) : undefined), write: (a) => (a?.path ? prettyPath(a.path) : undefined), ls: (a) => (a?.path ? prettyPath(a.path) : undefined), }; const READ_PREVIEW_LINES = 10; // Per-tool result-body override. Returning undefined falls back to the native // renderResult. Read hides its content unless expanded, so we synthesize a // short inline preview of the chunk it read (native full output on expand). const RESULT_PREVIEW: Record< string, (result: any, options: any, theme: Theme) => string[] | undefined > = { read: (result, options, theme) => { if (options?.expanded) return undefined; // expanded -> use native full output const block = Array.isArray(result?.content) ? result.content.find((b: any) => b?.type === "text") : undefined; const text = typeof block?.text === "string" ? block.text : ""; if (!text.trim()) return undefined; const all = text.split("\n"); while (all.length && all[all.length - 1].trim() === "") all.pop(); const shown = all.slice(0, READ_PREVIEW_LINES).map((l: string) => theme.fg("toolOutput", l)); const more = all.length - READ_PREVIEW_LINES; if (more > 0) { shown.push(theme.fg("muted", `… ${more} more lines (`) + keyHint("app.tools.expand", "to expand") + theme.fg("muted", ")")); } return shown; }, }; // Top border: ── ✓ bash ──────────────── (label carries its own colors) function frameTop(width: number, label: string, paint: Paint): string { const innerWidth = Math.max(0, width - 2); const leftLen = innerWidth > 2 ? 1 : 0; let content = ` ${label} `; // Keep at least one trailing dash; truncate an over-long header so it never // overflows the box width. const maxContent = Math.max(0, innerWidth - leftLen - 1); if (visibleWidth(content) > maxContent) content = truncateToWidth(content, maxContent, "…"); const rightLen = Math.max(0, innerWidth - leftLen - visibleWidth(content)); return ( paint(TL) + paint(H.repeat(leftLen)) + content + paint(H.repeat(rightLen)) + paint(TR) ); } // Bottom border, optionally with an already-painted `label` stamped near the // right edge (kept clear of the corner by one trailing dash). Falls back to a // plain border if the label can't fit. function frameBottom(width: number, paint: Paint, label?: string): string { const innerWidth = Math.max(0, width - 2); if (label) { const segW = visibleWidth(label); const rightLen = 1; if (segW + rightLen + 1 <= innerWidth) { const leftLen = innerWidth - segW - rightLen; return paint(BL + H.repeat(leftLen)) + label + paint(H.repeat(rightLen) + BR); } } return paint(BL + H.repeat(innerWidth) + BR); } // Per-tool body tint. With the side rails gone, a faint full-width background // band is what gives each box its identity at a glance — you can tell a bash run // from a grep from an edit without reading the header. Keep these VERY dark: they // sit under real syntax highlighting, so anything with punch destroys contrast. // Drop a tool from this map (or set it to undefined) to leave that body untinted. const TOOL_BG: Record = { bash: "#101a22", // cool slate — shell read: "#0f1a15", // green edit: "#1a1526", // violet write: "#20141c", // magenta grep: "#221a0f", // amber find: "#0f1c20", // teal ls: "#171717", // neutral }; // Indent inner lines to sit under the box interior. The side rails were dropped // (the zellij pane already draws a border around the whole pane, so per-box // verticals were redundant chrome) — only the labelled top/bottom horizontals // delimit a box now. Layout width is still width-4, so content wraps exactly as // before and keeps a 2-col breathing margin on the right. // // With a `bg` the line is padded to the full pane width so the band is a solid // block (that padding is the one place trailing whitespace is unavoidable — it // IS the tint). fillBg re-asserts the bg after any reset inside the content, so // edit's own add/remove diff bands nest correctly: the diff band paints, ends, // and the tool tint resumes underneath it. // // The tint is STATIC per tool, so this keeps the anti-flicker property: body // lines stay byte-identical across pulse frames and only the horizontal // top/bottom borders repaint while a tool streams. (NOTES.md) function frameBody(lines: string[], width: number, bg?: string): string[] { const innerWidth = Math.max(0, width - 4); return lines.map((raw) => { let line = raw; if (visibleWidth(line) > innerWidth) line = truncateToWidth(line, innerWidth, "…"); return bg ? fillBg(bg, ` ${line}`, width) : ` ${line}`; }); } type FrameState = { inner: Record; // private state bag handed to the native renderer callInner?: Component; resultInner?: Component; status: Status; resultActive: boolean; // true once the result slot starts rendering (it then owns the bottom border) ctxInvalidate?: () => void; // row rerender trigger (updateDisplay + requestRender) toolCallId?: string; // used to match live tool_execution_start/end events // Inner-line caches persisted on the row state so they survive updateDisplay() // rebuilds. Keyed by width; reset when the underlying args/result change (or on // invalidate/theme change). callArgsRef?: any; callPreviewRef?: any; // edit: identity of the async-computed preview, to bust the cache summaryText?: string; callW: number; callLines?: string[]; // Active-render freeze cache (anti-flicker): while streaming, hold the pulse // color steady per (width, content) so the top border—often scrolled off the // top of a tall box—stays byte-identical between the working-spinner's render // ticks. Recoloring an off-screen line forces a full-screen redraw. callActiveW?: number; callActiveInner?: string[]; callActiveLines?: string[]; // Height of the result slot as of its last render, so the call slot can tell // whether the whole box still fits on screen (see canPulseTopBorder). resultH?: number; resultRef?: any; resultExpanded?: boolean; resultW: number; resultLines?: string[]; }; function ensureState(context: any): FrameState { if (!context.state.__slopFrame) { context.state.__slopFrame = { inner: {}, status: "pending" as Status, resultActive: false, callW: -1, resultW: -1, }; } const st = context.state.__slopFrame as FrameState; if (context.toolCallId) { st.toolCallId = context.toolCallId; rowsById.set(context.toolCallId, st); } return st; } // A row animates only if its tool is executing in THIS session (event-driven). function isActiveRow(st: FrameState): boolean { return !!st.toolCallId && activeIds.has(st.toolCallId); } // A context for the native renderer: its own state bag + its own lastComponent. function childCtx(context: any, st: FrameState, slot: "call" | "result") { return { ...context, state: st.inner, lastComponent: slot === "call" ? st.callInner : st.resultInner, }; } function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { const displayName = def.name; const summarize = HEADER_SUMMARY[def.name]; pi.registerTool({ name: def.name, label: def.label ?? def.name, description: def.description, parameters: def.parameters, promptSnippet: def.promptSnippet, renderShell: "self", // we draw our own frame + background async execute(toolCallId, params, signal, onUpdate, ctx) { return def.execute(toolCallId, params, signal, onUpdate, ctx); }, renderCall(args, theme, context) { const st = ensureState(context); st.ctxInvalidate = context.invalidate; // Rebuild the native call component only when the args actually change // (not on every pulse-driven updateDisplay). edit draws its diff here; the // redundant leading " " line is stripped in the inner cache. if (args !== st.callArgsRef) { st.callInner = def.renderCall ? def.renderCall(args, theme, childCtx(context, st, "call")) : undefined; st.callArgsRef = args; st.summaryText = summarize ? summarize(args) : undefined; st.callW = -1; } // (Whether this row animates is decided by live tool_execution events, not // here — see isActiveRow / the event handlers in the default export.) // Inner-line cache lives on `st` (survives updateDisplay). Only the native // render + diff regex are expensive; the frame is rebuilt cheaply so it can // re-paint with the pulse color per render without recomputing content. const innerLines = (width: number): string[] => { const inner = Math.max(1, width - 4); // edit: render our own syntax-highlighted diff from the live preview // (computed async by the native renderCall we invoked above and stashed // on st.inner.callComponent.preview). Bust the width cache when the // preview object identity changes (pending -> resolved). if (displayName === "edit") { const pv: any = (st.inner as any).callComponent?.preview; if (pv !== st.callPreviewRef) { st.callPreviewRef = pv; st.callW = -1; } if (st.callW === width && st.callLines) return st.callLines; let lines: string[]; if (!pv) lines = []; else if ("error" in pv) lines = theme.fg("error", pv.error).split("\n"); else lines = renderEditDiff(pv.diff, args?.path, inner, theme); st.callW = width; st.callLines = lines; return lines; } if (st.callW === width && st.callLines) return st.callLines; let lines = st.callInner ? st.callInner.render(inner) : []; if (st.summaryText) lines = stripRedundantCallLines(lines, displayName); st.callW = width; st.callLines = lines; return lines; }; let cw = -1; let cl: string[] = []; return { render(width: number): string[] { const activeNow = isActiveRow(st); const status: Status = activeNow ? "pending" : st.status; const inner = innerLines(width); // IMPORTANT (anti-flicker): body lines carry NO border color; only the // horizontal top/bottom borders pulse. Anything that recolors every body // line each frame forces a full-box repaint while a tool streams output // (edit/write) and reads as violent flicker. With uncolored bodies, // streaming only redraws the new content line and the pulse only touches // 1-2 border lines. (NOTES.md) const rt = RUNTIME_TOOLS.has(displayName) ? runtimeLabel(st, theme) : undefined; const build = (border: Paint): string[] => { // Label is rebuilt per paint so the pending ◉ pulses with the border. const base = statusIcon(status, theme, activeNow ? border : undefined) + " " + theme.fg("toolTitle", theme.bold(displayName)); const label = st.summaryText ? base + " " + theme.fg("accent", st.summaryText) : base; const out = [frameTop(width, label, border), ...frameBody(inner, width, TOOL_BG[displayName])]; // While the result slot hasn't started rendering yet (still-running // tool), close the box here so the frame is never bottomless. if (!st.resultActive) out.push(frameBottom(width, border, rt)); return out; }; if (activeNow) { cw = -1; // settled cache must rebuild once the row finishes // Pre-output (no result slot yet): the whole box is short + visible, // so keep the live pulse (and ticking runtime in the bottom border). if (!st.resultActive) return build(pulsePaint()); // Result is streaming below and this slot owns the TOP border. If the // whole box still fits on screen, that border is in-viewport and safe // to animate, so top and bottom beat together. if (canPulseTopBorder(st, 1 + inner.length)) { st.callActiveW = -1; // frozen lines are stale once we resume pulsing return build(pulsePaint()); } // Box is taller than the screen: the top border is (or is about to // be) scrolled ABOVE the viewport. Freeze it at a fixed green while // content + width are unchanged, so it stays byte-identical between // the spinner's render ticks; otherwise every tick recolors an // off-screen line and tui.js escalates to a full-screen redraw // (violent flicker on tall, top-clipped tool boxes). See NOTES.md. if (width !== st.callActiveW || inner !== st.callActiveInner) { st.callActiveW = width; st.callActiveInner = inner; st.callActiveLines = build(HOLD_PAINT); } return st.callActiveLines!; } if (width === cw) return cl; // settled: solid borders in the status color const solid = solidPaint(theme, borderColorFor(status)); cl = build(solid); cw = width; return cl; }, invalidate() { cw = -1; st.callW = -1; st.callActiveW = -1; // drop the frozen active lines on theme change st.callArgsRef = undefined; // rebuild native on theme change st.callInner?.invalidate?.(); }, }; }, renderResult(result, options, theme, context) { const st = ensureState(context); st.ctxInvalidate = context.invalidate; st.status = context.isError ? "error" : options.isPartial ? "pending" : "ok"; st.resultActive = true; // the result slot now owns the bottom border // NOTE: do NOT call context.invalidate() here. renderResult runs during // rendering, so invalidating would schedule another render that calls // renderResult again -> infinite render loop (hangs pi). The pulse timer // drives invalidation from OUTSIDE the render pass instead. // Rebuild the native result component only when result/expand changes. if (result !== st.resultRef || options.expanded !== st.resultExpanded) { st.resultInner = def.renderResult ? def.renderResult(result, options, theme, childCtx(context, st, "result")) : { render: () => { const c = result?.content?.[0]; const text = c?.type === "text" ? String(c.text) : ""; return text.split("\n"); }, }; st.resultRef = result; st.resultExpanded = options.expanded; st.resultW = -1; } // (Animation is event-driven; see isActiveRow / default export handlers.) const preview = RESULT_PREVIEW[displayName]; const innerLines = (width: number): string[] => { if (st.resultW === width && st.resultLines) return st.resultLines; const inner = Math.max(1, width - 4); let lines: string[] | undefined; // Completed edit: render our own syntax-highlighted diff from the final // details.diff. Falls through to native when there's no diff (e.g. error). if (displayName === "edit") { const diff = (result as any)?.details?.diff; if (diff) lines = renderEditDiff(diff, (context as any)?.args?.path, inner, theme); } if (lines === undefined && preview) lines = preview(result, options, theme); if (lines === undefined) { lines = st.resultInner ? st.resultInner.render(inner) : []; } st.resultW = width; st.resultLines = lines; return lines; }; let cw = -1; let cl: string[] = []; return { render(width: number): string[] { const inner = innerLines(width); const activeNow = isActiveRow(st); const rt = RUNTIME_TOOLS.has(displayName) ? runtimeLabel(st, theme) : undefined; const build = (border: Paint): string[] => [ ...frameBody(inner, width, TOOL_BG[displayName]), frameBottom(width, border, rt), ]; st.resultH = inner.length + 1; // body + bottom border, for canPulseTopBorder if (activeNow) { cw = -1; return build(pulsePaint()); } if (width === cw) return cl; cl = build(solidPaint(theme, borderColorFor(st.status))); cw = width; return cl; }, invalidate() { cw = -1; st.resultW = -1; st.resultRef = undefined; // rebuild native on theme change st.resultInner?.invalidate?.(); }, }; }, }); } export default function (pi: ExtensionAPI) { const cwd = process.cwd(); const defs: ToolDef[] = [ createBashToolDefinition(cwd) as unknown as ToolDef, createReadToolDefinition(cwd) as unknown as ToolDef, createEditToolDefinition(cwd) as unknown as ToolDef, createWriteToolDefinition(cwd) as unknown as ToolDef, createGrepToolDefinition(cwd) as unknown as ToolDef, createFindToolDefinition(cwd) as unknown as ToolDef, createLsToolDefinition(cwd) as unknown as ToolDef, ]; for (const def of defs) registerFramedTool(pi, def); // Animation is driven ONLY by live execution events, so restored/finished rows // (e.g. on `pi -c`) never animate. A row pulses from tool_execution_start until // tool_execution_end; the refresh() nudges the row to repaint immediately. const refresh = (id: string | undefined) => { if (id) rowsById.get(id)?.ctxInvalidate?.(); }; pi.on("tool_execution_start", (event: any) => { markActive(event.toolCallId); if (event.toolCallId) timingById.set(event.toolCallId, { start: Date.now() }); refresh(event.toolCallId); }); pi.on("tool_execution_end", (event: any) => { clearActive(event.toolCallId); const t = event.toolCallId ? timingById.get(event.toolCallId) : undefined; if (t) t.end = Date.now(); refresh(event.toolCallId); }); // Safety net: guarantee the timer stops even if an end event is missed // (abort/error). agent_settled fires when the run is no longer producing tool // output; session_shutdown on teardown. pi.on("agent_settled", stopAllAnim); pi.on("session_shutdown", stopAllAnim); }