@quandev104/pi-style 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/dist/extensions/pi-style.js +632 -262
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/runtime.ts +29 -4
  6. package/extension-src/pi-style/domain/status-renderer.ts +40 -8
  7. package/extension-src/pi-style/domain/status.ts +15 -5
  8. package/extension-src/pi-style/domain/theme.ts +32 -1
  9. package/extension-src/pi-style/features/editor/index.ts +17 -5
  10. package/extension-src/pi-style/features/messages/index.ts +302 -61
  11. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  12. package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
  13. package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
  14. package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
  15. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  16. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  17. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  18. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  19. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
  20. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  21. package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
  22. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  23. package/extension-src/pi-style/pi/index.ts +2 -0
  24. package/extension-src/pi-style/shared/ansi.ts +17 -5
  25. package/extension-src/pi-style/shared/box.ts +70 -4
  26. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  27. package/package.json +1 -1
@@ -188,6 +188,38 @@ function countNewlines(text: string, from: number, to: number): number {
188
188
  return count;
189
189
  }
190
190
 
191
+ /** Index just past the `need`-th newline counted backwards from `end` (0 when
192
+ * the window holds fewer), so only `text.slice(index, end)` needs further
193
+ * processing. Plain char scan, no allocation. */
194
+ function findBackwardLineStart(text: string, need: number, end: number = text.length): number {
195
+ let found = 0;
196
+ for (let i = end - 1; i >= 0; i--) {
197
+ if (text.charCodeAt(i) === 10 && ++found >= need) return i + 1;
198
+ }
199
+ return 0;
200
+ }
201
+
202
+ /** Whitespace per `String.prototype.trim` (superset of ASCII blank/line
203
+ * terminators); anything else counts as visible output. */
204
+ function isOutputWhitespaceCode(code: number): boolean {
205
+ if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) return true;
206
+ if (code === 0x85 || code === 0xa0 || code === 0x1680) return true;
207
+ if (code >= 0x2000 && code <= 0x200a) return true;
208
+ return code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff;
209
+ }
210
+
211
+ /** End index (exclusive) of the last non-whitespace character in `text` — the
212
+ * streaming equivalent of `stripAnsi(text).trimEnd()`: trailing blank lines and
213
+ * padding never push the visible tail out of the processing window. ANSI
214
+ * escape bytes count as non-whitespace; a slice ending inside one still
215
+ * strips correctly downstream. */
216
+ function lastVisibleEnd(text: string): number {
217
+ for (let i = text.length - 1; i >= 0; i--) {
218
+ if (!isOutputWhitespaceCode(text.charCodeAt(i))) return i + 1;
219
+ }
220
+ return 0;
221
+ }
222
+
191
223
  function stripBashToolNoticeLines(text: string): string {
192
224
  const filteredLines = text
193
225
  .replace(/\r/g, "")
@@ -388,8 +420,19 @@ function renderBashStreamingResult(
388
420
  options: { expanded: boolean },
389
421
  context: BoxedToolContext,
390
422
  ): Component {
391
- const body = stripBashToolNoticeLines(stripAnsi(raw));
392
- const hasOutput = body.trim().length > 0;
423
+ // Tail-only processing: the preview collapses to maxCollapsedLines lines
424
+ // anyway, so only the last maxCollapsedLines + 10 raw lines (the same headroom
425
+ // the final collapsed scan uses, covering notice lines stripped from the
426
+ // tail) get ANSI stripping/truncation work, and trailing blank lines never
427
+ // push real content out of the window (the raw-string equivalent of the old
428
+ // whole-buffer stripAnsi + trimEnd). Streaming passes stay O(tail) as the
429
+ // output grows instead of re-stripping the whole buffer each pass.
430
+ const contentEnd = lastVisibleEnd(raw);
431
+ const hasOutput = contentEnd > 0;
432
+ const tailStart = hasOutput
433
+ ? findBackwardLineStart(raw, getToolsRenderConfig().maxCollapsedLines + 10, contentEnd)
434
+ : 0;
435
+ const body = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart, contentEnd)));
393
436
  const elapsed = getStateElapsedMs(context.state);
394
437
  const emptyLines: string[] = [theme.fg("dim", "No output received yet")];
395
438
  if (!hasOutput && isInteractiveCommand(context?.args?.command) && (elapsed ?? 0) >= 1000) {
@@ -429,17 +472,7 @@ function renderBashFinalResult(
429
472
  if (!options.expanded) {
430
473
  // Collapsed: only process the tail of the output (notices stripped per line).
431
474
  const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
432
- let nlCount = 0;
433
- let tailStart = 0;
434
- for (let i = statusStripped.length - 1; i >= 0; i--) {
435
- if (statusStripped.charCodeAt(i) === 10) {
436
- nlCount++;
437
- if (nlCount >= scanLines) {
438
- tailStart = i + 1;
439
- break;
440
- }
441
- }
442
- }
475
+ const tailStart = findBackwardLineStart(statusStripped, scanLines);
443
476
  const tail = stripBashToolNoticeLines(stripAnsi(statusStripped.slice(tailStart)));
444
477
  const totalLinesBefore = tailStart > 0 ? countNewlines(statusStripped, 0, tailStart) : 0;
445
478
  const preview = createBashResultPreview(theme, tail, options, outputColor);
@@ -508,17 +541,7 @@ function createBashResultPreview(
508
541
  if (!expanded) {
509
542
  // Collapsed: only process the tail of the output
510
543
  const needed = cfg.maxCollapsedLines;
511
- let totalNewlines = 0;
512
- let scanFrom = 0; // default: take full text if fewer than needed newlines
513
- for (let i = text.length - 1; i >= 0; i--) {
514
- if (text.charCodeAt(i) === 10) {
515
- totalNewlines++;
516
- if (totalNewlines === needed) {
517
- scanFrom = i + 1;
518
- break;
519
- }
520
- }
521
- }
544
+ const scanFrom = findBackwardLineStart(text, needed); // full text when fewer newlines
522
545
 
523
546
  if (text.length === 0) {
524
547
  cacheKey = cacheId;
@@ -548,10 +571,14 @@ function createBashResultPreview(
548
571
  return cacheLines;
549
572
  }
550
573
 
551
- // Expanded: process all lines
574
+ // Expanded: only the tail lines the expanded budget can show receive
575
+ // clamp/truncate/color work; earlier lines collapse into one `… N earlier
576
+ // lines` head row, so per-line cost scales with maxExpandedLines instead
577
+ // of the full output.
552
578
  const normalized = replaceTabs(text);
553
- const logicalLines = normalized.split("\n").map((l) => clampLineLength(l));
554
- const hasOutput = !(logicalLines.length === 1 && logicalLines[0] === "");
579
+ const rawLines = normalized.split("\n");
580
+ const totalLines = rawLines.length;
581
+ const hasOutput = !(totalLines === 1 && rawLines[0] === "");
555
582
 
556
583
  if (!hasOutput) {
557
584
  cacheKey = cacheId;
@@ -559,17 +586,17 @@ function createBashResultPreview(
559
586
  return cacheLines;
560
587
  }
561
588
 
562
- const truncatedLines = logicalLines.map((line) => safeTruncateToWidth(line, bodyWidth, "…"));
563
- const expandedLines = truncatedLines.length === 1 && truncatedLines[0] === "" ? [] : truncatedLines;
564
589
  const applyColor = (l: string) =>
565
590
  color === "error"
566
591
  ? formatToolOutputLine(theme, l, "error")
567
592
  : cfg.dimOutput
568
593
  ? formatToolOutputLine(theme, l)
569
594
  : formatToolOutputLine(theme, l, "text");
570
- if (cfg.maxExpandedLines > 0 && expandedLines.length > cfg.maxExpandedLines) {
571
- const truncated = expandedLines.slice(-cfg.maxExpandedLines).map(applyColor);
572
- const remaining = expandedLines.length - cfg.maxExpandedLines;
595
+ const renderRawLine = (line: string) => safeTruncateToWidth(clampLineLength(line), bodyWidth, "…");
596
+
597
+ if (cfg.maxExpandedLines > 0 && totalLines > cfg.maxExpandedLines) {
598
+ const truncated = rawLines.slice(-cfg.maxExpandedLines).map((line) => applyColor(renderRawLine(line)));
599
+ const remaining = totalLines - cfg.maxExpandedLines;
573
600
  truncated.unshift(theme.fg("dim", `… ${remaining} earlier lines`));
574
601
  cacheKey = cacheId;
575
602
  cacheLines = truncated;
@@ -577,7 +604,7 @@ function createBashResultPreview(
577
604
  }
578
605
 
579
606
  cacheKey = cacheId;
580
- cacheLines = expandedLines.map(applyColor);
607
+ cacheLines = rawLines.map((line) => applyColor(renderRawLine(line)));
581
608
  return cacheLines;
582
609
  },
583
610
  };
@@ -778,6 +805,10 @@ interface BashTreeState {
778
805
  finished: boolean;
779
806
  revision: number;
780
807
  renderCache?: FinalSemanticRenderCache;
808
+ /** Raw output length at the last streaming parse attempt: partial passes
809
+ * with smaller growth than PARTIAL_REPARSE_THRESHOLD skip the re-parse (the
810
+ * final pass always parses the settled output in full). */
811
+ lastParsedLength?: number;
781
812
  }
782
813
 
783
814
  /** Classified semantic command: a bash tree (ls/find/grep), a git card, or a
@@ -834,6 +865,11 @@ function isGitActionClass(cls: BashSemanticClass): boolean {
834
865
  return !isBashTreeClass(cls) && (cls as GitSemanticClass).kind === "action";
835
866
  }
836
867
 
868
+ /** Minimum raw-output growth (chars) before a streaming partial pass re-parses
869
+ * a live tree command's output; smaller deltas keep the current tree until the
870
+ * final pass re-parses everything. */
871
+ const PARTIAL_REPARSE_THRESHOLD = 4096;
872
+
837
873
  function parseSemanticOutput(cls: BashSemanticClass, output: string): ParsedSemantic | null {
838
874
  if (isBashTreeClass(cls)) return parseBashTreeOutput(cls, output);
839
875
  if (isGhClass(cls)) return parseGhOutput(cls, output);
@@ -945,6 +981,7 @@ export const bashTool: BoxedToolDefinition = {
945
981
  existing.finished = false;
946
982
  existing.revision++;
947
983
  delete existing.renderCache;
984
+ delete existing.lastParsedLength;
948
985
  }
949
986
  existing.command = command;
950
987
  existing.cls = cls;
@@ -980,14 +1017,26 @@ export const bashTool: BoxedToolDefinition = {
980
1017
  }
981
1018
  if (!options.isPartial || isBashTreeClass(cls)) {
982
1019
  const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
983
- const parsed = parseSemanticOutput(cls, output);
984
1020
  const state = semanticStates.get(context.toolCallId);
1021
+ // Live tree classes re-parse on streaming passes, but only once the raw
1022
+ // output grew ≥ PARTIAL_REPARSE_THRESHOLD chars since the last parse
1023
+ // attempt: re-parsing the full buffer on every partial pass made
1024
+ // streaming O(n²). Small deltas keep the current tree; the final pass
1025
+ // always re-parses, so the settled registry state matches the ungated
1026
+ // path byte for byte.
1027
+ const shouldParse =
1028
+ !options.isPartial ||
1029
+ state === undefined ||
1030
+ state.lastParsedLength === undefined ||
1031
+ output.length - state.lastParsedLength >= PARTIAL_REPARSE_THRESHOLD;
1032
+ const parsed = shouldParse ? parseSemanticOutput(cls, output) : undefined;
985
1033
  if (parsed) {
986
1034
  if (state) {
987
1035
  state.parsed = parsed;
988
1036
  state.finished = !options.isPartial;
989
1037
  state.revision++;
990
1038
  delete state.renderCache;
1039
+ if (options.isPartial) state.lastParsedLength = output.length;
991
1040
  } else
992
1041
  semanticStates.set(context.toolCallId, {
993
1042
  cls,
@@ -995,6 +1044,7 @@ export const bashTool: BoxedToolDefinition = {
995
1044
  parsed,
996
1045
  finished: !options.isPartial,
997
1046
  revision: 0,
1047
+ ...(options.isPartial ? { lastParsedLength: output.length } : {}),
998
1048
  });
999
1049
  // `git diff` / `git show` render a boxed adaptive-diff result (one frame
1000
1050
  // per file); `gh run view --job=<id>` renders a boxed log result. Every
@@ -1008,14 +1058,25 @@ export const bashTool: BoxedToolDefinition = {
1008
1058
  }
1009
1059
  return EMPTY_BASH_TREE_RESULT;
1010
1060
  }
1061
+ if (!shouldParse) {
1062
+ // Skipped re-parse (sub-threshold growth): keep the current panel. A
1063
+ // live parsed tree still owns the display (the call panel renders it, the
1064
+ // result adds nothing); a fallback keeps streaming raw output into the
1065
+ // open box below.
1066
+ if (state?.parsed !== undefined) return EMPTY_BASH_TREE_RESULT;
1067
+ }
1011
1068
  // Unparseable output (ls -l, raw rg summary, non-git output): the boxed
1012
1069
  // shell owns the result; flag the call panel to render nothing so the
1013
- // two don't duplicate.
1014
- if (state) {
1015
- state.fallback = true;
1016
- state.finished = !options.isPartial;
1017
- state.revision++;
1018
- delete state.renderCache;
1070
+ // two don't duplicate. Skipped passes (sub-threshold growth) leave the
1071
+ // current panel untouched.
1072
+ if (shouldParse) {
1073
+ if (state) {
1074
+ state.fallback = true;
1075
+ state.finished = !options.isPartial;
1076
+ state.revision++;
1077
+ delete state.renderCache;
1078
+ if (options.isPartial) state.lastParsedLength = output.length;
1079
+ }
1019
1080
  }
1020
1081
  }
1021
1082
  } else if (options.isPartial) {
@@ -215,6 +215,12 @@ export interface BatchResultData {
215
215
  * and records batch completion once every member has settled. The member's
216
216
  * display detail stays as registered by the call renderer (the result context's
217
217
  * args may be normalized differently).
218
+ *
219
+ * `data.entries === undefined` means "keep the registered entries": callers
220
+ * that already registered final output (see hasFinalBatchOutput) omit the
221
+ * field on warm re-render passes, and that must never count as a change —
222
+ * comparing against the stored array would otherwise bump the revision on
223
+ * every pass.
218
224
  */
219
225
  export function registerBatchResult(
220
226
  meta: BatchToolMeta,
@@ -231,7 +237,7 @@ export function registerBatchResult(
231
237
  member.status !== nextStatus ||
232
238
  member.isError !== nextIsError ||
233
239
  member.errorText !== (nextIsError ? data.errorText : undefined) ||
234
- member.outputEntries !== data.entries;
240
+ (data.entries !== undefined && member.outputEntries !== data.entries);
235
241
  member.status = nextStatus;
236
242
  member.isError = nextIsError;
237
243
  if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
@@ -246,6 +252,16 @@ export function registerBatchResult(
246
252
  return { batch, isLeader: batch.leaderId === context.toolCallId };
247
253
  }
248
254
 
255
+ /** True when the member for `toolCallId` has settled with final parsed output
256
+ * (done, non-error, entries registered). Result renderers use this to skip
257
+ * re-parsing an unchanged final output on warm re-render passes. */
258
+ export function hasFinalBatchOutput(toolCallId: string): boolean {
259
+ const batch = batchByCallId.get(toolCallId);
260
+ if (!batch) return false;
261
+ const member = batch.members.find((entry) => entry.toolCallId === toolCallId);
262
+ return member !== undefined && member.outputEntries !== undefined && member.status === "done" && !member.isError;
263
+ }
264
+
249
265
  interface BatchStatus {
250
266
  readonly total: number;
251
267
  readonly done: number;
@@ -117,24 +117,15 @@ export const editTool: BoxedToolDefinition = {
117
117
  });
118
118
  }
119
119
 
120
- // Resolve language for syntax highlighting
120
+ // Resolve the edited path (cache-key input + language hint source).
121
121
  const message = firstText(result.content as Array<{ type: string; text?: string }>);
122
122
  const argPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
123
123
  const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
124
- const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
125
-
126
- // Build diff rows + adaptive layout
127
- const rows = buildSplitRows(diff);
128
124
  const expanded = options.expanded;
129
- const shouldHighlight =
130
- Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
125
+ // Stats feed the footer, which is part of the cache key (cheap line scan —
126
+ // unlike the row/component construction below, which must not run on hits).
131
127
  const stats = countDiffStats(diff);
132
128
 
133
- // Render adaptive diff (unified/split per width) with syntax colors for small outputs.
134
- const maxRows = expanded ? 160 : 36;
135
- const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
136
- const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
137
-
138
129
  return memoizedStateComponent(
139
130
  context.state,
140
131
  "__piStyleEditDiffResult",
@@ -146,8 +137,21 @@ export const editTool: BoxedToolDefinition = {
146
137
  sourcePath ?? "",
147
138
  editDiffFooter(theme, result, context, stats),
148
139
  ),
149
- () =>
150
- renderBoxedToolResult(
140
+ () => {
141
+ // Expensive construction (buildSplitRows + AdaptiveDiffComponent,
142
+ // ~0.4ms for a 160-row diff) runs only on cache misses, never per
143
+ // render pass. Everything below is a pure function of the key inputs.
144
+ const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
145
+ const rows = buildSplitRows(diff);
146
+ const shouldHighlight =
147
+ Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
148
+
149
+ // Render adaptive diff (unified/split per width) with syntax colors for small outputs.
150
+ const maxRows = expanded ? 160 : 36;
151
+ const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
152
+ const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
153
+
154
+ return renderBoxedToolResult(
151
155
  theme,
152
156
  {
153
157
  render(width: number): string[] {
@@ -162,7 +166,8 @@ export const editTool: BoxedToolDefinition = {
162
166
  ...(expandHint ? { dividerRightLabel: expandHint } : {}),
163
167
  footerLines: [editDiffFooter(theme, result, context, stats)],
164
168
  },
165
- ),
169
+ );
170
+ },
166
171
  );
167
172
  },
168
173
  };
@@ -11,6 +11,7 @@ import {
11
11
  type BatchToolMeta,
12
12
  EMPTY_BATCH_COMPONENT,
13
13
  emptyBatchResult,
14
+ hasFinalBatchOutput,
14
15
  registerBatchCall,
15
16
  registerBatchResult,
16
17
  renderBatchAwareCall,
@@ -48,14 +49,18 @@ export const findTool: BoxedToolDefinition = {
48
49
  return renderBatchAwareCall(theme, batch);
49
50
  },
50
51
  result(result, options, _theme, context) {
51
- const output = stripAnsi(getTextOutput(result)).trimEnd();
52
- const entries = context.isError ? undefined : parseFindOutput(output);
52
+ const isError = Boolean(context.isError);
53
+ // Result renderers re-fire on every repaint/scroll; once the final output
54
+ // is parsed and registered, skip stripping/parsing the same text again.
55
+ const settled = !options.isPartial && !isError && hasFinalBatchOutput(context.toolCallId);
56
+ const output = settled ? "" : stripAnsi(getTextOutput(result)).trimEnd();
57
+ const entries = settled || isError ? undefined : parseFindOutput(output);
53
58
  registerBatchResult(
54
59
  FIND_META,
55
60
  {
56
61
  isPartial: Boolean(options.isPartial),
57
- isError: Boolean(context.isError),
58
- errorText: context.isError ? output || undefined : undefined,
62
+ isError,
63
+ errorText: isError ? output || undefined : undefined,
59
64
  ...(entries !== undefined ? { entries } : {}),
60
65
  },
61
66
  context,
@@ -26,7 +26,7 @@ import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../
26
26
  import { parseSimpleBashCommand } from "./command-shape.js";
27
27
  import { pluralForm, TREE_INDENT } from "./output-tree.js";
28
28
  import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
29
- import type { BoxedToolContext } from "./shared.js";
29
+ import { type BoxedToolContext, getRenderCacheKey, memoizedStateComponent } from "./shared.js";
30
30
 
31
31
  // ── Classification ──────────────────────────────────────────────────────────
32
32
 
@@ -1871,7 +1871,10 @@ interface DiffFileBox {
1871
1871
 
1872
1872
  /** Build a complete boxed-diff result component for a parsed `git diff`/`show`.
1873
1873
  * The call panel renders the boxless Git header; this component renders one
1874
- * `╭…╰` frame per file (or a single `No changes` frame for an empty diff). */
1874
+ * `╭…╰` frame per file (or a single `No changes` frame for an empty diff).
1875
+ * Construction is memoized on the call state: repeated result passes reuse
1876
+ * the cached component (identity-stable, invalidate propagates inward) so
1877
+ * the per-file `AdaptiveDiffComponent` build never re-runs per render pass. */
1875
1878
  export function renderGitDiffResult(
1876
1879
  theme: BoxTheme,
1877
1880
  parsed: GitDiffParsed,
@@ -1879,6 +1882,47 @@ export function renderGitDiffResult(
1879
1882
  context: BoxedToolContext,
1880
1883
  ): Component {
1881
1884
  const expanded = Boolean(options.expanded);
1885
+
1886
+ // Cheap cache key capturing everything that affects output — theme, show vs
1887
+ // diff, expansion, file count/totals, and per-file identity (path, body
1888
+ // length, counts, binary/status) — computed WITHOUT building rows or
1889
+ // components; the expensive build runs only on cache misses.
1890
+ let totalAdditions = 0;
1891
+ let totalRemovals = 0;
1892
+ const sigParts: string[] = [];
1893
+ for (const file of parsed.files) {
1894
+ totalAdditions += file.additions;
1895
+ totalRemovals += file.removals;
1896
+ sigParts.push(
1897
+ `${file.path}:${file.body.length}:${file.additions}:${file.removals}:${file.binary ? 1 : 0}:${file.status ?? ""}`,
1898
+ );
1899
+ }
1900
+ const sig = sigParts.join(";").slice(0, 2048);
1901
+
1902
+ return memoizedStateComponent(
1903
+ context.state,
1904
+ "__piStyleGitDiffResult",
1905
+ getRenderCacheKey(
1906
+ "git-diff-result",
1907
+ theme,
1908
+ String(parsed.show),
1909
+ String(expanded),
1910
+ parsed.files.length,
1911
+ totalAdditions,
1912
+ totalRemovals,
1913
+ sig,
1914
+ ),
1915
+ () => buildGitDiffResultComponent(theme, parsed, expanded, context),
1916
+ );
1917
+ }
1918
+
1919
+ /** Uncached boxed git-diff construction: one `╭…╰` frame per file. */
1920
+ function buildGitDiffResultComponent(
1921
+ theme: BoxTheme,
1922
+ parsed: GitDiffParsed,
1923
+ expanded: boolean,
1924
+ context: BoxedToolContext,
1925
+ ): Component {
1882
1926
  const elapsedMs = getStateElapsedMs(context.state);
1883
1927
  const fileCount = parsed.files.length;
1884
1928
 
@@ -166,14 +166,21 @@ export const grepTool: BoxedToolDefinition = {
166
166
  return renderGrepPanel(theme, context.toolCallId);
167
167
  },
168
168
  result(result, options, _theme, context) {
169
- const output = stripAnsi(getTextOutput(result)).trimEnd();
170
169
  const isError = Boolean(context.isError);
170
+ const isPartial = Boolean(options.isPartial);
171
+ // Result renderers re-fire on every repaint/scroll; once final matches
172
+ // are registered the registry is already final — skip stripping/parsing.
173
+ const state = grepPanels.get(context.toolCallId);
174
+ if (!isPartial && !isError && state !== undefined && state.matches !== undefined && !state.isPartial) {
175
+ return EMPTY_GREP_RESULT;
176
+ }
177
+ const output = stripAnsi(getTextOutput(result)).trimEnd();
171
178
  const matches = isError ? [] : parseGrepOutput(output);
172
179
  registerGrepResult(context.toolCallId, {
173
180
  matches,
174
181
  isError,
175
182
  errorText: isError ? output || undefined : undefined,
176
- isPartial: Boolean(options.isPartial),
183
+ isPartial,
177
184
  });
178
185
  return EMPTY_GREP_RESULT;
179
186
  },
@@ -11,6 +11,7 @@ import {
11
11
  type BatchToolMeta,
12
12
  EMPTY_BATCH_COMPONENT,
13
13
  emptyBatchResult,
14
+ hasFinalBatchOutput,
14
15
  registerBatchCall,
15
16
  registerBatchResult,
16
17
  renderBatchAwareCall,
@@ -40,14 +41,18 @@ export const lsTool: BoxedToolDefinition = {
40
41
  return renderBatchAwareCall(theme, batch);
41
42
  },
42
43
  result(result, options, _theme, context) {
43
- const output = stripAnsi(getTextOutput(result)).trimEnd();
44
- const entries = context.isError ? undefined : parseLsOutput(output);
44
+ const isError = Boolean(context.isError);
45
+ // Result renderers re-fire on every repaint/scroll; once the final output
46
+ // is parsed and registered, skip stripping/parsing the same text again.
47
+ const settled = !options.isPartial && !isError && hasFinalBatchOutput(context.toolCallId);
48
+ const output = settled ? "" : stripAnsi(getTextOutput(result)).trimEnd();
49
+ const entries = settled || isError ? undefined : parseLsOutput(output);
45
50
  registerBatchResult(
46
51
  LIST_META,
47
52
  {
48
53
  isPartial: Boolean(options.isPartial),
49
- isError: Boolean(context.isError),
50
- errorText: context.isError ? output || undefined : undefined,
54
+ isError,
55
+ errorText: isError ? output || undefined : undefined,
51
56
  ...(entries !== undefined ? { entries } : {}),
52
57
  },
53
58
  context,
@@ -174,18 +174,12 @@ function renderQuickEditResult(
174
174
  });
175
175
  }
176
176
 
177
- const rows = buildSplitRows(diff);
178
177
  const expanded = options.expanded;
179
178
  const argPath = String(context?.args?.path ?? "");
180
- const language = argPath ? getLanguageFromPath(argPath) : undefined;
181
- const shouldHighlight =
182
- Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
179
+ // Stats feed the footer, which is part of the cache key (cheap line scan —
180
+ // unlike the row/component construction below, which must not run on hits).
183
181
  const stats = countDiffStats(diff);
184
182
 
185
- const maxRows = expanded ? 160 : 36;
186
- const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
187
- const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
188
-
189
183
  return memoizedStateComponent(
190
184
  context.state,
191
185
  "__piStyleQuickEditDiffResult",
@@ -198,8 +192,20 @@ function renderQuickEditResult(
198
192
  argPath,
199
193
  quickEditDiffFooter(theme, result, context, stats),
200
194
  ),
201
- () =>
202
- renderBoxedToolResult(
195
+ () => {
196
+ // Expensive construction (buildSplitRows + AdaptiveDiffComponent) runs
197
+ // only on cache misses, never per render pass. Everything below is a
198
+ // pure function of the key inputs.
199
+ const rows = buildSplitRows(diff);
200
+ const language = argPath ? getLanguageFromPath(argPath) : undefined;
201
+ const shouldHighlight =
202
+ Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
203
+
204
+ const maxRows = expanded ? 160 : 36;
205
+ const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
206
+ const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
207
+
208
+ return renderBoxedToolResult(
203
209
  theme,
204
210
  {
205
211
  render(width: number): string[] {
@@ -214,7 +220,8 @@ function renderQuickEditResult(
214
220
  ...(expandHint ? { dividerRightLabel: expandHint } : {}),
215
221
  footerLines: [quickEditDiffFooter(theme, result, context, stats)],
216
222
  },
217
- ),
223
+ );
224
+ },
218
225
  );
219
226
  }
220
227
 
@@ -32,13 +32,15 @@ export const readTool: BoxedToolDefinition = {
32
32
  return renderBatchAwareCall(theme, batch);
33
33
  },
34
34
  result(result, options, _theme, context) {
35
- const output = stripAnsi(getTextOutput(result)).trimEnd();
35
+ // The strip is only needed for error text — keep it off the success path
36
+ // (result renderers re-fire on every repaint/scroll).
37
+ const errorText = context.isError ? stripAnsi(getTextOutput(result)).trimEnd() || undefined : undefined;
36
38
  registerBatchResult(
37
39
  READ_META,
38
40
  {
39
41
  isPartial: Boolean(options.isPartial),
40
42
  isError: Boolean(context.isError),
41
- errorText: context.isError ? output || undefined : undefined,
43
+ errorText,
42
44
  },
43
45
  context,
44
46
  );
@@ -181,8 +181,34 @@ type StateComponentCacheEntry = {
181
181
  component: Component;
182
182
  };
183
183
 
184
+ /** Cache-key string parts at or below this length join verbatim; longer ones
185
+ * collapse to `length:hash` so the joined key length stays bounded regardless
186
+ * of raw output size. */
187
+ const CACHE_KEY_LONG_PART_THRESHOLD = 64;
188
+
189
+ /** 32-bit FNV-1a hash of a string as 8 lowercase hex digits. Local mirror of the
190
+ * compatibility probe's fingerprint hashing (the pi/ layer stays unreachable
191
+ * from features): deterministic, collision-safe for cache identity. */
192
+ function fnv1aHex(text: string): string {
193
+ let hash = 2166136261;
194
+ for (let i = 0; i < text.length; i++) {
195
+ hash ^= text.charCodeAt(i);
196
+ hash = Math.imul(hash, 16777619) >>> 0;
197
+ }
198
+ return hash.toString(16).padStart(8, "0");
199
+ }
200
+
201
+ /** Fold one join part: strings longer than CACHE_KEY_LONG_PART_THRESHOLD
202
+ * collapse to `length:hash`; numbers/booleans pass through unchanged. */
203
+ function boundedCacheKeyPart(part: string | number | boolean): string | number | boolean {
204
+ if (typeof part !== "string" || part.length <= CACHE_KEY_LONG_PART_THRESHOLD) return part;
205
+ return `${part.length}:${fnv1aHex(part)}`;
206
+ }
207
+
184
208
  export function getRenderCacheKey(prefix: string, theme: BoxTheme, ...parts: Array<string | number | boolean>): string {
185
- return [prefix, themeCacheKey(theme), getToolsRenderCacheSignature(), ...parts].join("|");
209
+ const pieces: Array<string | number | boolean> = [prefix, themeCacheKey(theme), getToolsRenderCacheSignature()];
210
+ for (const part of parts) pieces.push(boundedCacheKeyPart(part));
211
+ return pieces.join("|");
186
212
  }
187
213
 
188
214
  export function memoizedStateComponent(
@@ -306,6 +306,18 @@ export function invalidateTurnMembers(turn: TurnState): void {
306
306
  }
307
307
  }
308
308
 
309
+ /**
310
+ * Drop the turn's captured invalidate callbacks (`agent_end`, after the
311
+ * collapse re-render). The closures pin component state for the rest of the
312
+ * session otherwise; `memberByCallId` entries stay so scrollback keeps
313
+ * resolving the turn. Later expand toggles re-render via Pi's updateDisplay
314
+ * selectors and re-capture fresh callbacks; a missing callback is already
315
+ * skipped gracefully by invalidateTurnMembers.
316
+ */
317
+ export function releaseTurnInvalidators(turn: TurnState): void {
318
+ for (const member of turn.members) invalidateByCallId.delete(member.toolCallId);
319
+ }
320
+
309
321
  /**
310
322
  * Freeze a member's wall-clock elapsed into the registry (idempotent; the
311
323
  * value is frozen by the renderer state once the terminal result rendered).
@@ -7,6 +7,7 @@ import {
7
7
  invalidateTurnMembers,
8
8
  rebuildTurnRegistryFromEntries,
9
9
  registerTurnFromMessage,
10
+ releaseTurnInvalidators,
10
11
  } from "../features/tools/boxed/turn-summary.js";
11
12
  import { requestToolPresentationRender } from "../features/tools/index.js";
12
13
  import { registerPiStyleCommand } from "./commands.js";
@@ -142,6 +143,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
142
143
  const run = finishAgentRun();
143
144
  if (run) {
144
145
  invalidateTurnMembers(run);
146
+ releaseTurnInvalidators(run);
145
147
  requestToolPresentationRender();
146
148
  }
147
149
  });