@quandev104/pi-style 0.2.0 → 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 (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/dist/extensions/pi-style.js +1256 -491
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/index.ts +3 -2
  6. package/extension-src/pi-style/app/runtime.ts +99 -86
  7. package/extension-src/pi-style/app/snapshot.ts +41 -2
  8. package/extension-src/pi-style/domain/status-renderer.ts +40 -8
  9. package/extension-src/pi-style/domain/status.ts +15 -5
  10. package/extension-src/pi-style/domain/theme.ts +32 -1
  11. package/extension-src/pi-style/features/editor/index.ts +97 -66
  12. package/extension-src/pi-style/features/messages/index.ts +469 -90
  13. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  14. package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
  18. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  19. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  21. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  22. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -22
  23. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  24. package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
  25. package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
  26. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  27. package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
  28. package/extension-src/pi-style/pi/index.ts +28 -13
  29. package/extension-src/pi-style/pi/session-usage.ts +204 -21
  30. package/extension-src/pi-style/shared/ansi.ts +17 -5
  31. package/extension-src/pi-style/shared/box.ts +83 -6
  32. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  33. package/package.json +1 -1
@@ -8,10 +8,10 @@
8
8
  // consume no vertical space.
9
9
  //
10
10
  // Design notes:
11
- // - No caching in the batch panel: it reads the module-level registry on every
12
- // render, so member completions (which trigger ui.requestRender via Pi's
13
- // tool_execution_end handler) are picked up without cross-component
14
- // invalidation plumbing.
11
+ // - Live batches render directly from the registry so member completions (which
12
+ // trigger ui.requestRender via Pi's tool_execution_end handler) are picked up
13
+ // without cross-component invalidation plumbing. Once a batch is finalized,
14
+ // width/config-stable renders reuse the cached line array.
15
15
  // - Batch boundaries: a new batch starts when the active batch is closed. The
16
16
  // active batch closes when a non-batchable tool call is dispatched
17
17
  // (boxed/index.ts), when a new message starts (pi/index.ts), and on session
@@ -31,7 +31,7 @@
31
31
 
32
32
  import type { Component } from "@earendil-works/pi-tui";
33
33
  import { stripAnsi } from "../../../shared/ansi.js";
34
- import { type BoxTheme, dimLine, formatToolTitlePrefix } from "../../../shared/box.js";
34
+ import { type BoxTheme, dimLine, formatToolTitlePrefix, themeCacheKey } from "../../../shared/box.js";
35
35
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
36
36
  import {
37
37
  fileIcon,
@@ -42,7 +42,7 @@ import {
42
42
  TREE_CHILD_INDENT,
43
43
  TREE_INDENT,
44
44
  } from "./output-tree.js";
45
- import { getToolsRenderConfig } from "./session-config.js";
45
+ import { getToolsRenderCacheSignature, getToolsRenderConfig } from "./session-config.js";
46
46
  import type { BoxedToolContext } from "./shared.js";
47
47
 
48
48
  /** Quiet tools whose calls group into a single batch panel. */
@@ -78,6 +78,11 @@ export interface BatchMember {
78
78
  outputEntries?: string[];
79
79
  }
80
80
 
81
+ type BatchRenderCache = {
82
+ key: string;
83
+ lines: string[];
84
+ };
85
+
81
86
  export interface BatchState {
82
87
  readonly meta: BatchToolMeta;
83
88
  readonly leaderId: string;
@@ -85,6 +90,8 @@ export interface BatchState {
85
90
  completedAt?: number;
86
91
  closed: boolean;
87
92
  readonly members: BatchMember[];
93
+ revision: number;
94
+ renderCache?: BatchRenderCache;
88
95
  }
89
96
 
90
97
  /** Tree head limit: only the first few members are listed, the rest collapse. */
@@ -130,6 +137,7 @@ function createBatch(
130
137
  leaderId,
131
138
  startedAt: performance.now(),
132
139
  closed: false,
140
+ revision: 0,
133
141
  members: [
134
142
  {
135
143
  toolCallId: leaderId,
@@ -146,6 +154,11 @@ function createBatch(
146
154
  return batch;
147
155
  }
148
156
 
157
+ function bumpBatchRevision(batch: BatchState): void {
158
+ batch.revision++;
159
+ delete batch.renderCache;
160
+ }
161
+
149
162
  /**
150
163
  * Register a call renderer invocation. Idempotent per toolCallId: re-fires
151
164
  * (updateDisplay on the same component) reuse the call's existing batch, even
@@ -161,9 +174,12 @@ export function registerBatchCall(
161
174
  if (existing) {
162
175
  const member = existing.members.find((entry) => entry.toolCallId === context.toolCallId);
163
176
  if (member) {
177
+ const changed =
178
+ member.detail !== detail || member.pattern !== opts.pattern || member.pathLabel !== opts.pathLabel;
164
179
  member.detail = detail;
165
180
  if (opts.pattern !== undefined) member.pattern = opts.pattern;
166
181
  if (opts.pathLabel !== undefined) member.pathLabel = opts.pathLabel;
182
+ if (changed) bumpBatchRevision(existing);
167
183
  }
168
184
  return { batch: existing, isLeader: existing.leaderId === context.toolCallId };
169
185
  }
@@ -182,6 +198,7 @@ export function registerBatchCall(
182
198
  };
183
199
  current.members.push(member);
184
200
  batchByCallId.set(context.toolCallId, current);
201
+ bumpBatchRevision(current);
185
202
  return { batch: current, isLeader: false };
186
203
  }
187
204
 
@@ -198,6 +215,12 @@ export interface BatchResultData {
198
215
  * and records batch completion once every member has settled. The member's
199
216
  * display detail stays as registered by the call renderer (the result context's
200
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.
201
224
  */
202
225
  export function registerBatchResult(
203
226
  meta: BatchToolMeta,
@@ -208,18 +231,37 @@ export function registerBatchResult(
208
231
  if (!batch || batch.meta.toolName !== meta.toolName) return { batch: undefined, isLeader: false };
209
232
  const member = batch.members.find((entry) => entry.toolCallId === context.toolCallId);
210
233
  if (member) {
211
- member.status = data.isPartial ? "running" : "done";
212
- member.isError = !data.isPartial && data.isError;
234
+ const nextStatus = data.isPartial ? "running" : "done";
235
+ const nextIsError = !data.isPartial && data.isError;
236
+ const changed =
237
+ member.status !== nextStatus ||
238
+ member.isError !== nextIsError ||
239
+ member.errorText !== (nextIsError ? data.errorText : undefined) ||
240
+ (data.entries !== undefined && member.outputEntries !== data.entries);
241
+ member.status = nextStatus;
242
+ member.isError = nextIsError;
213
243
  if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
214
244
  else delete member.errorText;
215
245
  if (data.entries !== undefined) member.outputEntries = data.entries;
246
+ if (changed) bumpBatchRevision(batch);
216
247
  }
217
248
  if (batch.completedAt === undefined && batch.members.every((entry) => entry.status === "done")) {
218
249
  batch.completedAt = performance.now();
250
+ bumpBatchRevision(batch);
219
251
  }
220
252
  return { batch, isLeader: batch.leaderId === context.toolCallId };
221
253
  }
222
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
+
223
265
  interface BatchStatus {
224
266
  readonly total: number;
225
267
  readonly done: number;
@@ -459,9 +501,17 @@ function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: Batch
459
501
  */
460
502
  export function renderBatchAwareCall(theme: BoxTheme, batch: BatchState): Component {
461
503
  return {
462
- invalidate() {},
504
+ invalidate() {
505
+ delete batch.renderCache;
506
+ },
463
507
  render(width: number): string[] {
464
- return renderBatchPanelLines(theme, batch, batchStatus(batch), width);
508
+ const status = batchStatus(batch);
509
+ if (!status.allDone) return renderBatchPanelLines(theme, batch, status, width);
510
+ const cacheKey = [themeCacheKey(theme), getToolsRenderCacheSignature(), width, batch.revision].join("|");
511
+ if (batch.renderCache?.key === cacheKey) return batch.renderCache.lines;
512
+ const lines = renderBatchPanelLines(theme, batch, status, width);
513
+ batch.renderCache = { key: cacheKey, lines };
514
+ return lines;
465
515
  },
466
516
  };
467
517
  }
@@ -24,6 +24,8 @@ import {
24
24
  type BoxedToolContext,
25
25
  type BoxedToolDefinition,
26
26
  displayPath,
27
+ getRenderCacheKey,
28
+ memoizedStateComponent,
27
29
  noteBoxedCallState,
28
30
  noteBoxedResultPhase,
29
31
  noteExecutionStart,
@@ -115,38 +117,56 @@ export const editTool: BoxedToolDefinition = {
115
117
  });
116
118
  }
117
119
 
118
- // Resolve language for syntax highlighting
120
+ // Resolve the edited path (cache-key input + language hint source).
119
121
  const message = firstText(result.content as Array<{ type: string; text?: string }>);
120
122
  const argPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
121
123
  const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
122
- const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
123
-
124
- // Build diff rows + adaptive layout
125
- const rows = buildSplitRows(diff);
126
124
  const expanded = options.expanded;
127
- const shouldHighlight =
128
- 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).
129
127
  const stats = countDiffStats(diff);
130
128
 
131
- // Render adaptive diff (unified/split per width) with syntax colors for small outputs.
132
- const maxRows = expanded ? 160 : 36;
133
- const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
134
- const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
129
+ return memoizedStateComponent(
130
+ context.state,
131
+ "__piStyleEditDiffResult",
132
+ getRenderCacheKey(
133
+ "edit-diff-result",
134
+ theme,
135
+ Boolean(expanded),
136
+ diff,
137
+ sourcePath ?? "",
138
+ editDiffFooter(theme, result, context, stats),
139
+ ),
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;
135
148
 
136
- return renderBoxedToolResult(
137
- theme,
138
- {
139
- render(width: number): string[] {
140
- return diffView.render(width);
141
- },
142
- invalidate(): void {
143
- diffView.invalidate();
144
- },
145
- },
146
- {
147
- dividerLabel: diffDividerLabel(theme, stats),
148
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
149
- footerLines: [editDiffFooter(theme, result, context, stats)],
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(
155
+ theme,
156
+ {
157
+ render(width: number): string[] {
158
+ return diffView.render(width);
159
+ },
160
+ invalidate(): void {
161
+ diffView.invalidate();
162
+ },
163
+ },
164
+ {
165
+ dividerLabel: diffDividerLabel(theme, stats),
166
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
167
+ footerLines: [editDiffFooter(theme, result, context, stats)],
168
+ },
169
+ );
150
170
  },
151
171
  );
152
172
  },
@@ -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,
@@ -16,6 +16,8 @@ import {
16
16
  type BoxedToolContext,
17
17
  type BoxedToolDefinition,
18
18
  displayPath,
19
+ getRenderCacheKey,
20
+ memoizedStateComponent,
19
21
  noteBoxedCallState,
20
22
  noteBoxedResultPhase,
21
23
  noteExecutionStart,
@@ -172,32 +174,53 @@ function renderQuickEditResult(
172
174
  });
173
175
  }
174
176
 
175
- const rows = buildSplitRows(diff);
176
177
  const expanded = options.expanded;
177
178
  const argPath = String(context?.args?.path ?? "");
178
- const language = argPath ? getLanguageFromPath(argPath) : undefined;
179
- const shouldHighlight =
180
- 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).
181
181
  const stats = countDiffStats(diff);
182
182
 
183
- const maxRows = expanded ? 160 : 36;
184
- const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
185
- const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
186
-
187
- return renderBoxedToolResult(
188
- theme,
189
- {
190
- render(width: number): string[] {
191
- return diffView.render(width);
192
- },
193
- invalidate(): void {
194
- diffView.invalidate();
195
- },
196
- },
197
- {
198
- dividerLabel: quickEditDividerLabel(theme, stats),
199
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
200
- footerLines: [quickEditDiffFooter(theme, result, context, stats)],
183
+ return memoizedStateComponent(
184
+ context.state,
185
+ "__piStyleQuickEditDiffResult",
186
+ getRenderCacheKey(
187
+ "quick-edit-diff-result",
188
+ theme,
189
+ config.toolLabel,
190
+ Boolean(expanded),
191
+ diff,
192
+ argPath,
193
+ quickEditDiffFooter(theme, result, context, stats),
194
+ ),
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(
209
+ theme,
210
+ {
211
+ render(width: number): string[] {
212
+ return diffView.render(width);
213
+ },
214
+ invalidate(): void {
215
+ diffView.invalidate();
216
+ },
217
+ },
218
+ {
219
+ dividerLabel: quickEditDividerLabel(theme, stats),
220
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
221
+ footerLines: [quickEditDiffFooter(theme, result, context, stats)],
222
+ },
223
+ );
201
224
  },
202
225
  );
203
226
  }
@@ -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
  );
@@ -37,6 +37,19 @@ export function getToolsRenderConfig(): ToolsRenderConfig {
37
37
  return sessionToolsConfig;
38
38
  }
39
39
 
40
+ export function getToolsRenderCacheSignature(): string {
41
+ return [
42
+ sessionToolsConfig.maxCollapsedLines,
43
+ sessionToolsConfig.maxExpandedLines,
44
+ sessionToolsConfig.dimOutput ? 1 : 0,
45
+ sessionToolsConfig.showElapsed ? 1 : 0,
46
+ sessionToolsConfig.batchOpenGlyph,
47
+ sessionToolsConfig.nerdFonts ? 1 : 0,
48
+ sessionToolsConfig.collapseAfterTurn ? 1 : 0,
49
+ sessionToolsConfig.collapseMutatingTools ? 1 : 0,
50
+ ].join("|");
51
+ }
52
+
40
53
  // Wall-clock elapsed tracking through the renderer context state (no tool
41
54
  // re-registration, so result.details has no execution timing).
42
55
  //
@@ -83,8 +96,26 @@ export function markResultSeen(state: Record<string, unknown> | undefined): void
83
96
 
84
97
  type TickerHandle = ReturnType<typeof setInterval>;
85
98
 
86
- /** States that currently own a 1s elapsed-render interval, for session cleanup. */
87
- const tickerStates = new Set<Record<string, unknown>>();
99
+ type ElapsedTickerEntry = {
100
+ invalidate: () => void;
101
+ };
102
+
103
+ /** States currently subscribed to the shared elapsed-render ticker. */
104
+ const tickerEntries = new Map<Record<string, unknown>, ElapsedTickerEntry>();
105
+ let sharedTickerHandle: TickerHandle | undefined;
106
+
107
+ function ensureSharedTicker(): void {
108
+ if (sharedTickerHandle !== undefined || tickerEntries.size === 0) return;
109
+ sharedTickerHandle = setInterval(() => {
110
+ for (const { invalidate } of tickerEntries.values()) invalidate();
111
+ }, 1000) as unknown as TickerHandle;
112
+ }
113
+
114
+ function stopSharedTickerIfIdle(): void {
115
+ if (sharedTickerHandle === undefined || tickerEntries.size > 0) return;
116
+ clearInterval(sharedTickerHandle);
117
+ sharedTickerHandle = undefined;
118
+ }
88
119
 
89
120
  /**
90
121
  * While a tool is running, re-render once per second so live elapsed labels
@@ -92,26 +123,26 @@ const tickerStates = new Set<Record<string, unknown>>();
92
123
  */
93
124
  export function startElapsedTicker(state: Record<string, unknown> | undefined, invalidate: () => void): void {
94
125
  if (!state || typeof state !== "object") return;
95
- if (state[TICKER_KEY] !== undefined) return;
96
- state[TICKER_KEY] = setInterval(() => invalidate(), 1000) as unknown as TickerHandle;
97
- tickerStates.add(state);
126
+ state[TICKER_KEY] = true;
127
+ tickerEntries.set(state, { invalidate });
128
+ ensureSharedTicker();
98
129
  }
99
130
 
100
131
  /** Stop a running tool's elapsed ticker (terminal result, error, session end). */
101
132
  export function stopElapsedTicker(state: Record<string, unknown> | undefined): void {
102
133
  if (!state || typeof state !== "object") return;
103
- const handle = state[TICKER_KEY] as TickerHandle | undefined;
104
- if (handle !== undefined) clearInterval(handle);
105
134
  delete state[TICKER_KEY];
106
- tickerStates.delete(state);
135
+ tickerEntries.delete(state);
136
+ stopSharedTickerIfIdle();
107
137
  }
108
138
 
109
139
  /** Stop every elapsed ticker (session start/shutdown). */
110
140
  export function stopAllElapsedTickers(): void {
111
- for (const state of tickerStates) {
112
- const handle = state[TICKER_KEY] as TickerHandle | undefined;
113
- if (handle !== undefined) clearInterval(handle);
114
- delete state[TICKER_KEY];
115
- }
116
- tickerStates.clear();
141
+ for (const state of tickerEntries.keys()) delete state[TICKER_KEY];
142
+ tickerEntries.clear();
143
+ stopSharedTickerIfIdle();
144
+ }
145
+
146
+ export function __getElapsedTickerDebugState(): { trackedStates: number; hasSharedTicker: boolean } {
147
+ return { trackedStates: tickerEntries.size, hasSharedTicker: sharedTickerHandle !== undefined };
117
148
  }
@@ -12,9 +12,11 @@ import {
12
12
  renderCompactBoxedToolCall,
13
13
  resolveRelativePath,
14
14
  shortenPath,
15
+ themeCacheKey,
15
16
  } from "../../../shared/box.js";
16
17
  import {
17
18
  getStateElapsedMs,
19
+ getToolsRenderCacheSignature,
18
20
  isResultSeen,
19
21
  markResultSeen,
20
22
  recordExecutionEnded,
@@ -174,6 +176,55 @@ export function resultFooterLines(
174
176
  return [formatBoxedFooter(theme, result, extraParts, stateElapsedMs(context))];
175
177
  }
176
178
 
179
+ type StateComponentCacheEntry = {
180
+ key: string;
181
+ component: Component;
182
+ };
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
+
208
+ export function getRenderCacheKey(prefix: string, theme: BoxTheme, ...parts: Array<string | number | boolean>): string {
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("|");
212
+ }
213
+
214
+ export function memoizedStateComponent(
215
+ state: Record<string, unknown> | undefined,
216
+ slot: string,
217
+ key: string,
218
+ build: () => Component,
219
+ ): Component {
220
+ if (!state || typeof state !== "object") return build();
221
+ const cached = state[slot] as StateComponentCacheEntry | undefined;
222
+ if (cached && cached.key === key) return cached.component;
223
+ const component = build();
224
+ state[slot] = { key, component } satisfies StateComponentCacheEntry;
225
+ return component;
226
+ }
227
+
177
228
  export function clearFooterState(context: BoxedToolContext): void {
178
229
  clearCompactBoxedFooter(context.state);
179
230
  }