aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext/slop-tool-boxes.ts
blob: 2fef6b4fbdbd699422346391a3506a37bae88c56 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
/**
 * 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<string>(); // toolCallIds executing in THIS session
const rowsById = new Map<string, FrameState>(); // 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<string, { start: number; end?: number }>();

// Tools that get a right-aligned runtime stamped into their bottom border.
const RUNTIME_TOOLS = new Set<string>(["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 "<tool> <path>" 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 <path>").
const HEADER_SUMMARY: Record<string, (args: any) => 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<string, string> = {
  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<string, unknown>; // 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 "<tool> <path>" 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);
}