@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
@@ -1,9 +1,56 @@
1
- import { visibleWidth } from "@earendil-works/pi-tui";
1
+ import { visibleWidth } from "../../shared/ansi.js";
2
2
 
3
3
  const OSC133_ZONE_START = "\x1b]133;A\x07";
4
4
  const OSC133_ZONE_END = "\x1b]133;B\x07";
5
5
  const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
6
6
  type OscParts = { start: string; body: string; end: string };
7
+ type LineAnalysis = {
8
+ visibleWidth: number;
9
+ hasContent: boolean;
10
+ oscEnvelope: OscParts | undefined;
11
+ hasOscStart: boolean;
12
+ leadingMarkers: { head: string; rest: string };
13
+ isBackgroundWrapped: boolean;
14
+ backgroundAnsi: string;
15
+ backgroundBody: string | undefined;
16
+ backgroundBodyWidth: number | undefined;
17
+ };
18
+ type DecoratedRenderCacheEntry = {
19
+ nativeRef: readonly string[];
20
+ nativeLines: readonly string[];
21
+ result: readonly string[];
22
+ };
23
+ type ChildrenScanState = { childrenRef: readonly unknown[]; length: number };
24
+ type MessageDecorationTestState = {
25
+ decoratePasses: number;
26
+ cacheHits: number;
27
+ cacheMisses: number;
28
+ lineCacheHits: number;
29
+ lineCacheMisses: number;
30
+ };
31
+
32
+ const BG_RESET = "\x1b[49m";
33
+ const MAX_RENDER_CACHE_KEYS_PER_INSTANCE = 8;
34
+ const MAX_LINE_ANALYSIS_ENTRIES = 4096;
35
+
36
+ let renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
37
+ let lineAnalysisCache = new Map<string, LineAnalysis>();
38
+ // Live iterator over the analysis cache's insertion (= recency) order, reused
39
+ // across evictions: `keys().next()` allocates a fresh iterator (~1.4µs) each
40
+ // call, which dominates the per-insert eviction cost during streaming. A live
41
+ // Map iterator skips deleted entries and always hands back the current
42
+ // least-recently-used key, so eviction choice is identical to a fresh iterator.
43
+ let lineCacheEvictionCursor: Iterator<string, undefined, undefined> | undefined;
44
+ // Per-assistant-message guard for the updateContent children scan: skips the
45
+ // blank/interim scans when the contentContainer children array is unchanged.
46
+ let childrenScanByInstance = new WeakMap<object, ChildrenScanState>();
47
+ const messageDecorationTestState: MessageDecorationTestState = {
48
+ decoratePasses: 0,
49
+ cacheHits: 0,
50
+ cacheMisses: 0,
51
+ lineCacheHits: 0,
52
+ lineCacheMisses: 0,
53
+ };
7
54
 
8
55
  function extractOscEnvelope(line: string): OscParts | undefined {
9
56
  if (!line.startsWith(OSC133_ZONE_START)) return undefined;
@@ -12,8 +59,6 @@ function extractOscEnvelope(line: string): OscParts | undefined {
12
59
  return { start: OSC133_ZONE_START, body: line.slice(OSC133_ZONE_START.length, bodyEnd), end: line.slice(bodyEnd) };
13
60
  }
14
61
 
15
- const BG_RESET = "\x1b[49m";
16
-
17
62
  /** Leading zero-width OSC sequences (e.g. OSC133 markers) of a line. */
18
63
  function splitLeadingMarkers(line: string): { head: string; rest: string } {
19
64
  let index = 0;
@@ -24,7 +69,9 @@ function splitLeadingMarkers(line: string): { head: string; rest: string } {
24
69
  if (end === -1) break;
25
70
  index = end + 1;
26
71
  }
27
- return { head: line.slice(0, index), rest: line.slice(index) };
72
+ // Strings are immutable: with no markers, `rest` can alias the line itself
73
+ // instead of allocating a full copy on every analyzed line.
74
+ return index === 0 ? { head: "", rest: line } : { head: line.slice(0, index), rest: line.slice(index) };
28
75
  }
29
76
 
30
77
  /** Leading SGR escape sequence of a line ("" when none). */
@@ -39,39 +86,269 @@ function leadingSgr(line: string): string {
39
86
  return "";
40
87
  }
41
88
 
42
- /** Whether an SGR sequence sets/resets the terminal background color. */
89
+ /** Whether an SGR sequence sets/resets the terminal background color (allocation-free `Number`-equivalent parse). */
43
90
  function isBackgroundSgr(sequence: string): boolean {
44
91
  if (!sequence.startsWith("\x1b[") || !sequence.endsWith("m")) return false;
45
- for (const code of sequence.slice(2, -1).split(";")) {
46
- const value = Number(code);
47
- if (value === 48 || value === 49) return true;
48
- if (value >= 40 && value <= 47) return true;
49
- if (value >= 100 && value <= 107) return true;
92
+ // Splits on ";" and applies Number() per token: Number("") === 0, all-digit
93
+ // tokens parse as integers, anything else is NaN (matches no range).
94
+ let value = 0;
95
+ let empty = true;
96
+ let valid = true;
97
+ for (let index = 2; index < sequence.length - 1; index++) {
98
+ const code = sequence.charCodeAt(index);
99
+ if (code === 0x3b) {
100
+ if (valid && matchesBackgroundCode(empty ? 0 : value)) return true;
101
+ value = 0;
102
+ empty = true;
103
+ valid = true;
104
+ continue;
105
+ }
106
+ if (code < 0x30 || code > 0x39) {
107
+ valid = false;
108
+ continue;
109
+ }
110
+ value = value * 10 + (code - 0x30);
111
+ empty = false;
50
112
  }
51
- return false;
113
+ return valid && matchesBackgroundCode(empty ? 0 : value);
114
+ }
115
+
116
+ function matchesBackgroundCode(value: number): boolean {
117
+ return value === 48 || value === 49 || (value >= 40 && value <= 47) || (value >= 100 && value <= 107);
52
118
  }
53
119
 
54
120
  /**
55
- * Rebuild a native line so `lead` (prompt prefix / continuation indent) and the
56
- * full target width are covered by the line's background.
57
- *
58
- * Native Box lines (user messages) are `bgAnsi + body + \x1b[49m`; prepending the
59
- * prefix outside that wrap shifted the input row's background right by the
60
- * prefix width while keeping the full container width, producing a staircase
61
- * box (indented left, overflowing right). Rebuilding inside the wrap keeps the
62
- * background flush across every row; plain (unwrapped) lines are padded to the
63
- * target width instead so left/right edges stay aligned.
121
+ * Width of a string whose visible content is printable ASCII carrying only
122
+ * escapes pi-tui recognizes (CSI ending in m/G/K/H/J, OSC/APC ending in BEL or
123
+ * ST) — computed in one scan, no Intl.Segmenter pass. Returns undefined whenever
124
+ * the string can leave that domain (tabs, controls, non-ASCII, or
125
+ * unrecognized/unterminated escapes); callers then delegate to `visibleWidth`,
126
+ * which makes the result provably identical to pi-tui's while skipping grapheme
127
+ * segmentation for the streaming-hot line shapes. The escape scan mirrors
128
+ * pi-tui's `extractAnsiCode` exactly (including tab-in-sequence handling, since
129
+ * pi-tui replaces tabs before scanning but still consumes the same sequence).
130
+ */
131
+ function certifiedAsciiWidth(value: string): number | undefined {
132
+ let width = 0;
133
+ let index = 0;
134
+ const length = value.length;
135
+ while (index < length) {
136
+ const code = value.charCodeAt(index);
137
+ if (code === 0x1b) {
138
+ const next = index + 1 < length ? value.charCodeAt(index + 1) : -1;
139
+ if (next === 0x5b) {
140
+ // CSI: pi-tui consumes through the first m/G/K/H/J byte.
141
+ let scan = index + 2;
142
+ while (scan < length) {
143
+ const terminator = value.charCodeAt(scan);
144
+ if (
145
+ terminator === 0x6d || // m
146
+ terminator === 0x47 || // G
147
+ terminator === 0x4b || // K
148
+ terminator === 0x48 || // H
149
+ terminator === 0x4a // J
150
+ ) {
151
+ index = scan + 1;
152
+ break;
153
+ }
154
+ scan++;
155
+ }
156
+ if (scan >= length) return undefined; // unterminated: pi-tui emits the ESC visibly
157
+ continue;
158
+ }
159
+ if (next === 0x5d || next === 0x5f) {
160
+ // OSC/APC: consumed through BEL or ST (ESC \).
161
+ let scan = index + 2;
162
+ let end = -1;
163
+ while (scan < length) {
164
+ const terminator = value.charCodeAt(scan);
165
+ if (terminator === 0x07) {
166
+ end = scan + 1;
167
+ break;
168
+ }
169
+ if (terminator === 0x1b && scan + 1 < length && value.charCodeAt(scan + 1) === 0x5c) {
170
+ end = scan + 2;
171
+ break;
172
+ }
173
+ scan++;
174
+ }
175
+ if (end < 0) return undefined; // unterminated: delegate
176
+ index = end;
177
+ continue;
178
+ }
179
+ return undefined; // any other escape form: delegate
180
+ }
181
+ if (code < 0x20 || code > 0x7e) return undefined; // tab/control/non-ASCII: delegate
182
+ width++;
183
+ index++;
184
+ }
185
+ return width;
186
+ }
187
+
188
+ /** visibleWidth with a single-scan fast path; identical results, cheaper for streaming-hot lines. */
189
+ function certifiedVisibleWidth(value: string): number {
190
+ return certifiedAsciiWidth(value) ?? visibleWidth(value);
191
+ }
192
+
193
+ /**
194
+ * Memoized prefix width: the prefix (typically non-ASCII, e.g. "│ ") always
195
+ * delegates to pi-tui's visibleWidth, and the streaming-hot unique lines evict
196
+ * it from pi-tui's internal FIFO width cache, re-segmenting it every pass.
197
+ * visibleWidth is pure, so a one-entry memo is exactly equivalent.
64
198
  */
65
- function rebuildAtWidth(line: string, width: number, lead: string): string {
66
- const { head, rest } = splitLeadingMarkers(line);
67
- const bgAnsi = leadingSgr(rest);
68
- if (bgAnsi && isBackgroundSgr(bgAnsi) && rest.endsWith(BG_RESET)) {
69
- const body = rest.slice(bgAnsi.length, rest.length - BG_RESET.length);
70
- const pad = " ".repeat(Math.max(0, width - visibleWidth(lead) - visibleWidth(body)));
71
- return `${head}${bgAnsi}${lead}${body}${pad}${BG_RESET}`;
199
+ let prefixWidthMemo: { prefix: string; width: number } | undefined;
200
+ function prefixWidthOf(prefix: string): number {
201
+ if (prefixWidthMemo?.prefix === prefix) return prefixWidthMemo.width;
202
+ const width = certifiedVisibleWidth(prefix);
203
+ prefixWidthMemo = { prefix, width };
204
+ return width;
205
+ }
206
+
207
+ function contentText(line: string): string {
208
+ // Fast path: every OSC133 marker contains ESC and the strip loop below copies
209
+ // every non-ESC char verbatim — an ESC-free line is its own content text.
210
+ if (!line.includes("\x1b")) return line;
211
+ // Slice-based build: one concatenation per escape span instead of per char.
212
+ let output = "";
213
+ let sliceStart = 0;
214
+ for (let index = 0; index < line.length; index++) {
215
+ if (line.charCodeAt(index) !== 27) continue;
216
+ output += line.slice(sliceStart, index);
217
+ const next = line[index + 1];
218
+ if (next === "]") {
219
+ index += 2;
220
+ while (index < line.length && line.charCodeAt(index) !== 7) index++;
221
+ } else if (next === "[") {
222
+ index += 2;
223
+ while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
224
+ }
225
+ sliceStart = index + 1;
226
+ }
227
+ output += line.slice(sliceStart);
228
+ // The strip above can never leave an ESC in the output (every ESC consumes at
229
+ // least itself, and both escape branches run to their terminator or end of
230
+ // line), so these marker removals are a provably-untaken safety net.
231
+ if (output.includes("\x1b]133;")) {
232
+ return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
233
+ }
234
+ return output;
235
+ }
236
+
237
+ /** Whether a BMP code unit is whitespace with exact `\s` regex semantics (ECMAScript WhiteSpace + LineTerminator). */
238
+ function isWhitespaceCode(code: number): boolean {
239
+ // Fast path: ASCII whitespace — space, \t, \n, \v, \f, \r.
240
+ if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) return true;
241
+ if (code < 0x80) return false;
242
+ // The non-ASCII members of \s (no surrogates or astral code points are whitespace).
243
+ return (
244
+ code === 0x00a0 ||
245
+ code === 0x1680 ||
246
+ (code >= 0x2000 && code <= 0x200a) ||
247
+ code === 0x2028 ||
248
+ code === 0x2029 ||
249
+ code === 0x202f ||
250
+ code === 0x205f ||
251
+ code === 0x3000 ||
252
+ code === 0xfeff
253
+ );
254
+ }
255
+
256
+ /** Whether the line carries any non-whitespace content, in one ANSI-skipping scan (no content string built, no code-point array, no per-char regex). */
257
+ function hasContent(line: string): boolean {
258
+ const length = line.length;
259
+ let index = 0;
260
+ while (index < length) {
261
+ const code = line.charCodeAt(index);
262
+ if (code === 0x1b) {
263
+ // Skip exactly the spans contentText strips: OSC through BEL, CSI through
264
+ // its final byte; a lone ESC drops just itself.
265
+ const next = index + 1 < length ? line.charCodeAt(index + 1) : -1;
266
+ if (next === 0x5d) {
267
+ index += 2;
268
+ while (index < length && line.charCodeAt(index) !== 0x07) index++;
269
+ } else if (next === 0x5b) {
270
+ index += 2;
271
+ while (index < length) {
272
+ const inner = line.charCodeAt(index);
273
+ if (inner >= 64 && inner <= 126) break;
274
+ index++;
275
+ }
276
+ }
277
+ index++;
278
+ continue;
279
+ }
280
+ // Surrogate halves never match \s: an astral code point (or a lone surrogate)
281
+ // always counts as content, equivalent to the previous per-code-point regex.
282
+ if (code >= 0xd800 && code <= 0xdfff) return true;
283
+ if (!isWhitespaceCode(code)) return true;
284
+ index++;
285
+ }
286
+ return false;
287
+ }
288
+
289
+ function getLineAnalysis(line: string): LineAnalysis {
290
+ const cached = lineAnalysisCache.get(line);
291
+ if (cached) {
292
+ messageDecorationTestState.lineCacheHits++;
293
+ // True LRU: re-insert on hit so recency is refreshed; the eviction below then
294
+ // drops the least recently used entry instead of the oldest inserted one.
295
+ lineAnalysisCache.delete(line);
296
+ lineAnalysisCache.set(line, cached);
297
+ return cached;
298
+ }
299
+ messageDecorationTestState.lineCacheMisses++;
300
+ const leadingMarkers = splitLeadingMarkers(line);
301
+ const backgroundAnsi = leadingSgr(leadingMarkers.rest);
302
+ const isBackgroundWrapped =
303
+ backgroundAnsi !== "" && isBackgroundSgr(backgroundAnsi) && leadingMarkers.rest.endsWith(BG_RESET);
304
+ const backgroundBody = isBackgroundWrapped
305
+ ? leadingMarkers.rest.slice(backgroundAnsi.length, leadingMarkers.rest.length - BG_RESET.length)
306
+ : undefined;
307
+ const hasOscStart = line.startsWith(OSC133_ZONE_START);
308
+ const analysis: LineAnalysis = {
309
+ visibleWidth: certifiedVisibleWidth(line),
310
+ hasContent: hasContent(line),
311
+ oscEnvelope: hasOscStart ? extractOscEnvelope(line) : undefined,
312
+ hasOscStart,
313
+ leadingMarkers,
314
+ isBackgroundWrapped,
315
+ backgroundAnsi,
316
+ backgroundBody,
317
+ backgroundBodyWidth: backgroundBody === undefined ? undefined : certifiedVisibleWidth(backgroundBody),
318
+ };
319
+ lineAnalysisCache.set(line, analysis);
320
+ if (lineAnalysisCache.size > MAX_LINE_ANALYSIS_ENTRIES) {
321
+ let cursor = lineCacheEvictionCursor;
322
+ if (cursor === undefined) cursor = lineAnalysisCache.keys();
323
+ let oldest = cursor.next();
324
+ if (oldest.done) {
325
+ // Every not-yet-visited entry was refreshed past the cursor; restart from the true LRU head.
326
+ cursor = lineAnalysisCache.keys();
327
+ oldest = cursor.next();
328
+ }
329
+ lineCacheEvictionCursor = cursor;
330
+ if (!oldest.done && oldest.value !== undefined) lineAnalysisCache.delete(oldest.value);
331
+ }
332
+ return analysis;
333
+ }
334
+
335
+ function rebuildAtWidth(
336
+ line: string,
337
+ width: number,
338
+ lead: string,
339
+ leadWidth: number,
340
+ analysis = getLineAnalysis(line),
341
+ ): string {
342
+ if (
343
+ analysis.isBackgroundWrapped &&
344
+ analysis.backgroundBody !== undefined &&
345
+ analysis.backgroundBodyWidth !== undefined
346
+ ) {
347
+ const pad = " ".repeat(Math.max(0, width - leadWidth - analysis.backgroundBodyWidth));
348
+ return `${analysis.leadingMarkers.head}${analysis.backgroundAnsi}${lead}${analysis.backgroundBody}${pad}${BG_RESET}`;
72
349
  }
73
- const padded = `${lead}${line}`;
74
- return `${padded}${" ".repeat(Math.max(0, width - visibleWidth(padded)))}`;
350
+ const pad = " ".repeat(Math.max(0, width - leadWidth - analysis.visibleWidth));
351
+ return `${lead}${line}${pad}`;
75
352
  }
76
353
 
77
354
  function decorateMessageLine(
@@ -85,20 +362,24 @@ function decorateMessageLine(
85
362
  firstHasStart: boolean;
86
363
  multilineEnvelope: boolean;
87
364
  prefix: string;
365
+ prefixWidth: number;
366
+ continuationLead: string;
88
367
  },
368
+ analysis = getLineAnalysis(line),
89
369
  ): string {
90
- const { firstEnvelope, firstHasStart, multilineEnvelope, prefix } = options;
91
- const prefixWidth = visibleWidth(prefix);
92
- const lead = index === contentIndex ? prefix : index > contentIndex ? " ".repeat(prefixWidth) : "";
370
+ const { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth, continuationLead } = options;
371
+ const lead = index === contentIndex ? prefix : index > contentIndex ? continuationLead : "";
372
+ const leadWidth = index < contentIndex ? 0 : prefixWidth;
93
373
  if (index === contentIndex && firstEnvelope)
94
- return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix)}${firstEnvelope.end}`;
374
+ return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix, prefixWidth)}${firstEnvelope.end}`;
95
375
  if (index === contentIndex && firstHasStart)
96
- return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix)}`;
376
+ return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix, prefixWidth)}`;
97
377
  if (index === lastIndex && multilineEnvelope && index !== contentIndex)
98
378
  return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth(
99
379
  line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
100
380
  width,
101
381
  lead,
382
+ leadWidth,
102
383
  )}`;
103
384
  if (
104
385
  index === contentIndex &&
@@ -110,66 +391,114 @@ function decorateMessageLine(
110
391
  line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
111
392
  width,
112
393
  prefix,
394
+ prefixWidth,
113
395
  )}`;
114
- return rebuildAtWidth(line, width, lead);
396
+ return rebuildAtWidth(line, width, lead, leadWidth, analysis);
115
397
  }
116
398
 
117
- function contentText(line: string): string {
118
- let output = "";
119
- for (let index = 0; index < line.length; index++) {
120
- if (line.charCodeAt(index) !== 27) {
121
- output += line[index];
122
- continue;
123
- }
124
- const next = line[index + 1];
125
- if (next === "]") {
126
- index += 2;
127
- while (index < line.length && line.charCodeAt(index) !== 7) index++;
128
- continue;
129
- }
130
- if (next === "[") {
131
- index += 2;
132
- while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
133
- }
399
+ function sameLines(left: readonly string[], right: readonly string[]): boolean {
400
+ if (left === right) return true;
401
+ if (left.length !== right.length) return false;
402
+ for (let index = 0; index < left.length; index++) {
403
+ if (left[index] !== right[index]) return false;
134
404
  }
135
- return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
405
+ return true;
136
406
  }
137
407
 
138
- function hasContent(line: string): boolean {
139
- return [...contentText(line)].some((character) => !/\s/u.test(character));
408
+ function cacheKey(width: number, prefix: string): string {
409
+ return `${width}\u0000${prefix}`;
410
+ }
411
+
412
+ function getRenderCache(instance: object): Map<string, DecoratedRenderCacheEntry> {
413
+ let cache = renderCacheByInstance.get(instance);
414
+ if (!cache) {
415
+ cache = new Map();
416
+ renderCacheByInstance.set(instance, cache);
417
+ }
418
+ return cache;
419
+ }
420
+
421
+ function storeRenderCache(
422
+ instance: object,
423
+ width: number,
424
+ prefix: string,
425
+ native: readonly string[],
426
+ result: readonly string[],
427
+ ): void {
428
+ const cache = getRenderCache(instance);
429
+ const key = cacheKey(width, prefix);
430
+ if (cache.has(key)) cache.delete(key);
431
+ cache.set(key, { nativeRef: native, nativeLines: [...native], result: [...result] });
432
+ while (cache.size > MAX_RENDER_CACHE_KEYS_PER_INSTANCE) {
433
+ const oldestKey = cache.keys().next().value;
434
+ if (oldestKey === undefined) break;
435
+ cache.delete(oldestKey);
436
+ }
140
437
  }
141
438
 
142
439
  function prefixNative(lines: unknown, width: number, prefix: string): string[] | undefined {
143
440
  if (!Array.isArray(lines) || lines.length === 0 || !lines.every((line) => typeof line === "string")) return undefined;
441
+ messageDecorationTestState.decoratePasses++;
144
442
  const nativeLines = lines as string[];
145
- const prefixWidth = visibleWidth(prefix);
443
+ const prefixWidth = prefixWidthOf(prefix);
146
444
  if (width <= prefixWidth) return undefined;
147
445
  const bodyWidth = width - prefixWidth;
148
- const first = nativeLines[0] ?? "";
446
+ // One cache lookup per line per pass; every later consumer reuses this array
447
+ // instead of re-requesting analysis (and re-churning LRU recency) per line.
448
+ const analyses = nativeLines.map((line) => getLineAnalysis(line));
149
449
  const last = nativeLines.at(-1) ?? "";
450
+ const lastAnalysis = analyses[analyses.length - 1] ?? getLineAnalysis(last);
150
451
  const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL);
151
452
  // The last line is a content-start candidate only when the envelope is
152
453
  // single-line, or when no earlier line carries content. Assistant messages
153
454
  // with a single content line render as a multiline envelope whose only body
154
455
  // sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it
155
456
  // would drop the prefix for every short assistant reply.
156
- const firstContentIndex = nativeLines.findIndex((line, index) => {
157
- if (index !== nativeLines.length - 1 || !multilineEnvelope) return hasContent(line);
158
- return !nativeLines.slice(0, index).some((earlier) => hasContent(earlier)) && hasContent(line);
159
- });
457
+ let firstContentIndex = -1;
458
+ for (let index = 0; index < nativeLines.length; index++) {
459
+ const analysis = analyses[index] ?? getLineAnalysis(nativeLines[index] ?? "");
460
+ if (index !== nativeLines.length - 1 || !multilineEnvelope) {
461
+ if (analysis.hasContent) {
462
+ firstContentIndex = index;
463
+ break;
464
+ }
465
+ continue;
466
+ }
467
+ let earlierHasContent = false;
468
+ for (let earlier = 0; earlier < index; earlier++) {
469
+ if ((analyses[earlier] ?? getLineAnalysis(nativeLines[earlier] ?? "")).hasContent) {
470
+ earlierHasContent = true;
471
+ break;
472
+ }
473
+ }
474
+ if (!earlierHasContent && lastAnalysis.hasContent) firstContentIndex = index;
475
+ break;
476
+ }
160
477
  if (firstContentIndex < 0) return nativeLines;
161
- const firstEnvelope = firstContentIndex === 0 ? extractOscEnvelope(first) : undefined;
162
- const firstHasStart = firstContentIndex === 0 && first.startsWith(OSC133_ZONE_START);
478
+ const firstAnalysis = analyses[0] ?? getLineAnalysis(nativeLines[0] ?? "");
479
+ const firstEnvelope = firstContentIndex === 0 ? firstAnalysis.oscEnvelope : undefined;
480
+ const firstHasStart = firstContentIndex === 0 && firstAnalysis.hasOscStart;
481
+ const continuationLead = " ".repeat(prefixWidth);
163
482
  const decorated = nativeLines.map((line, index) =>
164
- decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, width, {
165
- firstEnvelope,
166
- firstHasStart,
167
- multilineEnvelope,
168
- prefix,
169
- }),
483
+ decorateMessageLine(
484
+ line,
485
+ index,
486
+ nativeLines.length - 1,
487
+ firstContentIndex,
488
+ width,
489
+ {
490
+ firstEnvelope,
491
+ firstHasStart,
492
+ multilineEnvelope,
493
+ prefix,
494
+ prefixWidth,
495
+ continuationLead,
496
+ },
497
+ analyses[index],
498
+ ),
170
499
  );
171
- if (!decorated.every((line) => visibleWidth(line) <= width)) return undefined;
172
- if (!nativeLines.every((line) => visibleWidth(line) <= bodyWidth)) return undefined;
500
+ if (!decorated.every((line) => certifiedVisibleWidth(line) <= width)) return undefined;
501
+ if (!analyses.every((analysis) => analysis.visibleWidth <= bodyWidth)) return undefined;
173
502
  return decorated;
174
503
  }
175
504
 
@@ -182,6 +511,22 @@ export type MessageDecorationSnapshot = Readonly<{
182
511
  hideInterimText: boolean;
183
512
  }>;
184
513
 
514
+ export function __getMessageDecorationTestState(): Readonly<MessageDecorationTestState> {
515
+ return { ...messageDecorationTestState };
516
+ }
517
+
518
+ export function __resetMessageDecorationTestState(): void {
519
+ messageDecorationTestState.decoratePasses = 0;
520
+ messageDecorationTestState.cacheHits = 0;
521
+ messageDecorationTestState.cacheMisses = 0;
522
+ messageDecorationTestState.lineCacheHits = 0;
523
+ messageDecorationTestState.lineCacheMisses = 0;
524
+ renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
525
+ lineAnalysisCache = new Map<string, LineAnalysis>();
526
+ lineCacheEvictionCursor = undefined;
527
+ childrenScanByInstance = new WeakMap<object, ChildrenScanState>();
528
+ }
529
+
185
530
  export function decorateMessageRender(
186
531
  original: unknown,
187
532
  instance: object,
@@ -197,12 +542,22 @@ export function decorateMessageRender(
197
542
  const width = typeof args[0] === "number" ? args[0] : 0;
198
543
  const prefix = snapshot.assistantPrefix;
199
544
  if (!snapshot.assistantEnabled) return Reflect.apply(original, instance, args);
200
- if (width <= visibleWidth(prefix)) return Reflect.apply(original, instance, args);
545
+ const prefixWidth = prefixWidthOf(prefix);
546
+ if (width <= prefixWidth) return Reflect.apply(original, instance, args);
201
547
  // Exactly one native invocation. If the reduced render cannot be certified, the
202
548
  // already-obtained result is the only safe fallback; retrying can mutate state.
203
- const reducedWidth = width - visibleWidth(prefix);
549
+ const reducedWidth = width - prefixWidth;
204
550
  const native = Reflect.apply(original, instance, [reducedWidth, ...args.slice(1)]);
205
- return prefixNative(native, width, prefix) ?? native;
551
+ if (!Array.isArray(native) || !native.every((line) => typeof line === "string")) return native;
552
+ const cached = getRenderCache(instance).get(cacheKey(width, prefix));
553
+ if (cached && (cached.nativeRef === native || sameLines(cached.nativeLines, native))) {
554
+ messageDecorationTestState.cacheHits++;
555
+ return [...cached.result];
556
+ }
557
+ messageDecorationTestState.cacheMisses++;
558
+ const decorated = prefixNative(native, width, prefix) ?? native;
559
+ storeRenderCache(instance, width, prefix, native, decorated);
560
+ return decorated;
206
561
  }
207
562
 
208
563
  /** Spacer-like: renders empty lines and exposes only setLines among these surfaces. */
@@ -245,11 +600,37 @@ function hasToolCallItems(message: unknown): boolean {
245
600
  * across that module boundary is unreliable.
246
601
  */
247
602
  function isBlankTextChild(child: unknown): boolean {
248
- const candidate = child as { setCustomBgFn?: unknown; render?: (width: number) => string[] } | undefined;
603
+ const candidate = child as
604
+ | { setCustomBgFn?: unknown; render?: (width: number) => string[]; text?: unknown }
605
+ | undefined;
249
606
  if (typeof candidate?.setCustomBgFn !== "function" || typeof candidate.render !== "function") return false;
607
+ // pi-tui's Text exposes its raw source text as a plain `.text` property (kept in
608
+ // sync by the constructor and setText). render(0) only wraps/pads that text with
609
+ // spaces and ANSI (both blank under contentText+trim), so the property check is
610
+ // equivalent — and avoids a full Text render per child on every updateContent pass
611
+ // (which would also pollute Text's own width-keyed render cache with width 0).
612
+ if (typeof candidate.text === "string") return contentText(candidate.text).trim() === "";
250
613
  return contentText(candidate.render(0).join("\n")).trim() === "";
251
614
  }
252
615
 
616
+ /**
617
+ * Skip the post-update children scans when nothing could have changed:
618
+ * `AssistantMessageComponent.updateContent` starts every pass with
619
+ * `contentContainer.clear()`, and pi-tui's `Container.clear()` assigns a fresh
620
+ * `children` array (children are REPLACED, never mutated in place across passes).
621
+ * An unchanged reference (and length) therefore means the native layout did not
622
+ * rebuild since this instance was last scanned, so the previous scan's collapse
623
+ * is still in effect. Kept per-instance via WeakMap so messages are GC-able.
624
+ */
625
+ function childrenUnchangedSinceScan(instance: object, children: readonly unknown[]): boolean {
626
+ const state = childrenScanByInstance.get(instance);
627
+ return state !== undefined && state.childrenRef === children && state.length === children.length;
628
+ }
629
+
630
+ function markChildrenScanned(instance: object, children: readonly unknown[]): void {
631
+ childrenScanByInstance.set(instance, { childrenRef: children, length: children.length });
632
+ }
633
+
253
634
  /**
254
635
  * Collapse Pi's hidden-thinking placeholder row to zero trace.
255
636
  *
@@ -281,11 +662,11 @@ export function decorateMessageUpdate(
281
662
  hiddenThinkingLabel?: string;
282
663
  contentContainer?: { children?: unknown[] };
283
664
  };
284
- // Only meaningful when Pi renders the hidden-block label (hideThinkingBlock)
285
- // and the extension has blanked that label out ("" the zero-trace mode).
286
- if (snapshot.collapseHiddenThinking && target.hideThinkingBlock === true && target.hiddenThinkingLabel === "") {
287
- const children = target.contentContainer?.children;
288
- if (children) {
665
+ const children = target.contentContainer?.children;
666
+ if (children && !childrenUnchangedSinceScan(instance, children)) {
667
+ // Only meaningful when Pi renders the hidden-block label (hideThinkingBlock)
668
+ // and the extension has blanked that label out ("" — the zero-trace mode).
669
+ if (snapshot.collapseHiddenThinking && target.hideThinkingBlock === true && target.hiddenThinkingLabel === "") {
289
670
  for (let index = children.length - 1; index >= 0; index--) {
290
671
  if (!isBlankTextChild(children[index])) continue;
291
672
  children.splice(index, 1);
@@ -294,21 +675,19 @@ export function decorateMessageUpdate(
294
675
  if (isSpacerChild(children[index])) children.splice(index, 1);
295
676
  }
296
677
  }
297
- }
298
- // Interim narration: assistant messages that carry tool calls use their text
299
- // only to narrate while working; the tool blocks tell the story. Hide the text
300
- // so the feed shows the run's summary and the final answer. Deterministic per
301
- // content streaming, scroll-back, and resume behave identically. Errors and
302
- // truncation notices are Text children and stay; if only spacers remain the
303
- // message becomes zero-trace.
304
- if (snapshot.hideInterimText && hasToolCallItems(args[0])) {
305
- const children = target.contentContainer?.children;
306
- if (children) {
678
+ // Interim narration: assistant messages that carry tool calls use their text
679
+ // only to narrate while working; the tool blocks tell the story. Hide the text
680
+ // so the feed shows the run's summary and the final answer. Deterministic per
681
+ // content streaming, scroll-back, and resume behave identically. Errors and
682
+ // truncation notices are Text children and stay; if only spacers remain the
683
+ // message becomes zero-trace.
684
+ if (snapshot.hideInterimText && hasToolCallItems(args[0])) {
307
685
  for (let index = children.length - 1; index >= 0; index--) {
308
686
  if (isInterimTextChild(children[index])) children.splice(index, 1);
309
687
  }
310
688
  if (children.every((child) => isSpacerChild(child))) children.length = 0;
311
689
  }
690
+ markChildrenScanned(instance, children);
312
691
  }
313
692
  return result;
314
693
  }