@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
@@ -20,6 +20,7 @@ type DecoratedRenderCacheEntry = {
20
20
  nativeLines: readonly string[];
21
21
  result: readonly string[];
22
22
  };
23
+ type ChildrenScanState = { childrenRef: readonly unknown[]; length: number };
23
24
  type MessageDecorationTestState = {
24
25
  decoratePasses: number;
25
26
  cacheHits: number;
@@ -34,6 +35,15 @@ const MAX_LINE_ANALYSIS_ENTRIES = 4096;
34
35
 
35
36
  let renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
36
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>();
37
47
  const messageDecorationTestState: MessageDecorationTestState = {
38
48
  decoratePasses: 0,
39
49
  cacheHits: 0,
@@ -59,7 +69,9 @@ function splitLeadingMarkers(line: string): { head: string; rest: string } {
59
69
  if (end === -1) break;
60
70
  index = end + 1;
61
71
  }
62
- 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) };
63
75
  }
64
76
 
65
77
  /** Leading SGR escape sequence of a line ("" when none). */
@@ -74,47 +86,214 @@ function leadingSgr(line: string): string {
74
86
  return "";
75
87
  }
76
88
 
77
- /** 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). */
78
90
  function isBackgroundSgr(sequence: string): boolean {
79
91
  if (!sequence.startsWith("\x1b[") || !sequence.endsWith("m")) return false;
80
- for (const code of sequence.slice(2, -1).split(";")) {
81
- const value = Number(code);
82
- if (value === 48 || value === 49) return true;
83
- if (value >= 40 && value <= 47) return true;
84
- 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;
85
112
  }
86
- 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);
118
+ }
119
+
120
+ /**
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.
198
+ */
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;
87
205
  }
88
206
 
89
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.
90
212
  let output = "";
213
+ let sliceStart = 0;
91
214
  for (let index = 0; index < line.length; index++) {
92
- if (line.charCodeAt(index) !== 27) {
93
- output += line[index];
94
- continue;
95
- }
215
+ if (line.charCodeAt(index) !== 27) continue;
216
+ output += line.slice(sliceStart, index);
96
217
  const next = line[index + 1];
97
218
  if (next === "]") {
98
219
  index += 2;
99
220
  while (index < line.length && line.charCodeAt(index) !== 7) index++;
100
- continue;
101
- }
102
- if (next === "[") {
221
+ } else if (next === "[") {
103
222
  index += 2;
104
223
  while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
105
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, "");
106
233
  }
107
- return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
234
+ return output;
108
235
  }
109
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). */
110
257
  function hasContent(line: string): boolean {
111
- return [...contentText(line)].some((character) => !/\s/u.test(character));
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;
112
287
  }
113
288
 
114
289
  function getLineAnalysis(line: string): LineAnalysis {
115
290
  const cached = lineAnalysisCache.get(line);
116
291
  if (cached) {
117
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);
118
297
  return cached;
119
298
  }
120
299
  messageDecorationTestState.lineCacheMisses++;
@@ -125,21 +304,30 @@ function getLineAnalysis(line: string): LineAnalysis {
125
304
  const backgroundBody = isBackgroundWrapped
126
305
  ? leadingMarkers.rest.slice(backgroundAnsi.length, leadingMarkers.rest.length - BG_RESET.length)
127
306
  : undefined;
307
+ const hasOscStart = line.startsWith(OSC133_ZONE_START);
128
308
  const analysis: LineAnalysis = {
129
- visibleWidth: visibleWidth(line),
309
+ visibleWidth: certifiedVisibleWidth(line),
130
310
  hasContent: hasContent(line),
131
- oscEnvelope: extractOscEnvelope(line),
132
- hasOscStart: line.startsWith(OSC133_ZONE_START),
311
+ oscEnvelope: hasOscStart ? extractOscEnvelope(line) : undefined,
312
+ hasOscStart,
133
313
  leadingMarkers,
134
314
  isBackgroundWrapped,
135
315
  backgroundAnsi,
136
316
  backgroundBody,
137
- backgroundBodyWidth: backgroundBody === undefined ? undefined : visibleWidth(backgroundBody),
317
+ backgroundBodyWidth: backgroundBody === undefined ? undefined : certifiedVisibleWidth(backgroundBody),
138
318
  };
139
319
  lineAnalysisCache.set(line, analysis);
140
320
  if (lineAnalysisCache.size > MAX_LINE_ANALYSIS_ENTRIES) {
141
- const oldestKey = lineAnalysisCache.keys().next().value;
142
- if (oldestKey !== undefined) lineAnalysisCache.delete(oldestKey);
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);
143
331
  }
144
332
  return analysis;
145
333
  }
@@ -175,11 +363,12 @@ function decorateMessageLine(
175
363
  multilineEnvelope: boolean;
176
364
  prefix: string;
177
365
  prefixWidth: number;
366
+ continuationLead: string;
178
367
  },
179
368
  analysis = getLineAnalysis(line),
180
369
  ): string {
181
- const { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth } = options;
182
- 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 : "";
183
372
  const leadWidth = index < contentIndex ? 0 : prefixWidth;
184
373
  if (index === contentIndex && firstEnvelope)
185
374
  return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix, prefixWidth)}${firstEnvelope.end}`;
@@ -251,39 +440,65 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
251
440
  if (!Array.isArray(lines) || lines.length === 0 || !lines.every((line) => typeof line === "string")) return undefined;
252
441
  messageDecorationTestState.decoratePasses++;
253
442
  const nativeLines = lines as string[];
254
- const prefixWidth = visibleWidth(prefix);
443
+ const prefixWidth = prefixWidthOf(prefix);
255
444
  if (width <= prefixWidth) return undefined;
256
445
  const bodyWidth = width - prefixWidth;
257
- 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));
258
449
  const last = nativeLines.at(-1) ?? "";
259
- const lastAnalysis = getLineAnalysis(last);
450
+ const lastAnalysis = analyses[analyses.length - 1] ?? getLineAnalysis(last);
260
451
  const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL);
261
452
  // The last line is a content-start candidate only when the envelope is
262
453
  // single-line, or when no earlier line carries content. Assistant messages
263
454
  // with a single content line render as a multiline envelope whose only body
264
455
  // sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it
265
456
  // would drop the prefix for every short assistant reply.
266
- const firstContentIndex = nativeLines.findIndex((line, index) => {
267
- if (index !== nativeLines.length - 1 || !multilineEnvelope) return getLineAnalysis(line).hasContent;
268
- return (
269
- !nativeLines.slice(0, index).some((earlier) => getLineAnalysis(earlier).hasContent) && lastAnalysis.hasContent
270
- );
271
- });
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
+ }
272
477
  if (firstContentIndex < 0) return nativeLines;
273
- const firstAnalysis = getLineAnalysis(first);
478
+ const firstAnalysis = analyses[0] ?? getLineAnalysis(nativeLines[0] ?? "");
274
479
  const firstEnvelope = firstContentIndex === 0 ? firstAnalysis.oscEnvelope : undefined;
275
480
  const firstHasStart = firstContentIndex === 0 && firstAnalysis.hasOscStart;
481
+ const continuationLead = " ".repeat(prefixWidth);
276
482
  const decorated = nativeLines.map((line, index) =>
277
- decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, width, {
278
- firstEnvelope,
279
- firstHasStart,
280
- multilineEnvelope,
281
- prefix,
282
- prefixWidth,
283
- }),
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
+ ),
284
499
  );
285
- if (!decorated.every((line) => visibleWidth(line) <= width)) return undefined;
286
- if (!nativeLines.every((line) => getLineAnalysis(line).visibleWidth <= bodyWidth)) return undefined;
500
+ if (!decorated.every((line) => certifiedVisibleWidth(line) <= width)) return undefined;
501
+ if (!analyses.every((analysis) => analysis.visibleWidth <= bodyWidth)) return undefined;
287
502
  return decorated;
288
503
  }
289
504
 
@@ -308,6 +523,8 @@ export function __resetMessageDecorationTestState(): void {
308
523
  messageDecorationTestState.lineCacheMisses = 0;
309
524
  renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
310
525
  lineAnalysisCache = new Map<string, LineAnalysis>();
526
+ lineCacheEvictionCursor = undefined;
527
+ childrenScanByInstance = new WeakMap<object, ChildrenScanState>();
311
528
  }
312
529
 
313
530
  export function decorateMessageRender(
@@ -325,7 +542,7 @@ export function decorateMessageRender(
325
542
  const width = typeof args[0] === "number" ? args[0] : 0;
326
543
  const prefix = snapshot.assistantPrefix;
327
544
  if (!snapshot.assistantEnabled) return Reflect.apply(original, instance, args);
328
- const prefixWidth = visibleWidth(prefix);
545
+ const prefixWidth = prefixWidthOf(prefix);
329
546
  if (width <= prefixWidth) return Reflect.apply(original, instance, args);
330
547
  // Exactly one native invocation. If the reduced render cannot be certified, the
331
548
  // already-obtained result is the only safe fallback; retrying can mutate state.
@@ -383,11 +600,37 @@ function hasToolCallItems(message: unknown): boolean {
383
600
  * across that module boundary is unreliable.
384
601
  */
385
602
  function isBlankTextChild(child: unknown): boolean {
386
- 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;
387
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() === "";
388
613
  return contentText(candidate.render(0).join("\n")).trim() === "";
389
614
  }
390
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
+
391
634
  /**
392
635
  * Collapse Pi's hidden-thinking placeholder row to zero trace.
393
636
  *
@@ -419,11 +662,11 @@ export function decorateMessageUpdate(
419
662
  hiddenThinkingLabel?: string;
420
663
  contentContainer?: { children?: unknown[] };
421
664
  };
422
- // Only meaningful when Pi renders the hidden-block label (hideThinkingBlock)
423
- // and the extension has blanked that label out ("" the zero-trace mode).
424
- if (snapshot.collapseHiddenThinking && target.hideThinkingBlock === true && target.hiddenThinkingLabel === "") {
425
- const children = target.contentContainer?.children;
426
- 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 === "") {
427
670
  for (let index = children.length - 1; index >= 0; index--) {
428
671
  if (!isBlankTextChild(children[index])) continue;
429
672
  children.splice(index, 1);
@@ -432,21 +675,19 @@ export function decorateMessageUpdate(
432
675
  if (isSpacerChild(children[index])) children.splice(index, 1);
433
676
  }
434
677
  }
435
- }
436
- // Interim narration: assistant messages that carry tool calls use their text
437
- // only to narrate while working; the tool blocks tell the story. Hide the text
438
- // so the feed shows the run's summary and the final answer. Deterministic per
439
- // content streaming, scroll-back, and resume behave identically. Errors and
440
- // truncation notices are Text children and stay; if only spacers remain the
441
- // message becomes zero-trace.
442
- if (snapshot.hideInterimText && hasToolCallItems(args[0])) {
443
- const children = target.contentContainer?.children;
444
- 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])) {
445
685
  for (let index = children.length - 1; index >= 0; index--) {
446
686
  if (isInterimTextChild(children[index])) children.splice(index, 1);
447
687
  }
448
688
  if (children.every((child) => isSpacerChild(child))) children.length = 0;
449
689
  }
690
+ markChildrenScanned(instance, children);
450
691
  }
451
692
  return result;
452
693
  }
@@ -110,6 +110,37 @@ function separatorsFor(config: NormalizedPiStyleConfig, theme: ResolvedTheme): s
110
110
  return theme.apply("separator", style);
111
111
  }
112
112
 
113
+ /** Per-(theme, config) derived values shared across status-line renders. */
114
+ interface ThemeAssets {
115
+ readonly resolved: ResolvedTheme;
116
+ readonly separator: string;
117
+ }
118
+ /** Resolved theme + separator keyed by Pi theme identity then config identity; a fresh configure() object misses naturally. */
119
+ const themeAssetsCache = new WeakMap<ActivePiTheme, WeakMap<NormalizedPiStyleConfig, ThemeAssets>>();
120
+ function themeAssetsFor(activeTheme: ActivePiTheme, config: NormalizedPiStyleConfig): ThemeAssets {
121
+ let byConfig = themeAssetsCache.get(activeTheme);
122
+ if (!byConfig) {
123
+ byConfig = new WeakMap();
124
+ themeAssetsCache.set(activeTheme, byConfig);
125
+ }
126
+ let assets = byConfig.get(config);
127
+ if (!assets) {
128
+ const resolved = resolveTheme(
129
+ activeTheme.colors || activeTheme.fg
130
+ ? {
131
+ ...(activeTheme.colors ? { colors: activeTheme.colors } : {}),
132
+ // Call through the theme instance so `this` binds correctly inside Pi's fg().
133
+ ...(activeTheme.fg ? { fg: (color: string, text: string) => activeTheme.fg?.(color, text) ?? text } : {}),
134
+ }
135
+ : undefined,
136
+ config,
137
+ );
138
+ assets = { resolved, separator: separatorsFor(config, resolved) };
139
+ byConfig.set(config, assets);
140
+ }
141
+ return assets;
142
+ }
143
+
113
144
  export function installStatusLine(options: StatusLineInstallOptions): StatusLineInstallation {
114
145
  const existing = installationMap(options.host).get(options.generation);
115
146
  if (existing) return existing;
@@ -143,18 +174,9 @@ export function installStatusLine(options: StatusLineInstallOptions): StatusLine
143
174
 
144
175
  const render = (activeTheme: ActivePiTheme, width: number, secondary: boolean): string[] => {
145
176
  if (width <= 0 || !config.enabled || !config.statusLine.enabled) return [];
146
- const resolved = resolveTheme(
147
- activeTheme.colors || activeTheme.fg
148
- ? {
149
- ...(activeTheme.colors ? { colors: activeTheme.colors } : {}),
150
- // Call through the theme instance so `this` binds correctly inside Pi's fg().
151
- ...(activeTheme.fg ? { fg: (color: string, text: string) => activeTheme.fg?.(color, text) ?? text } : {}),
152
- }
153
- : undefined,
154
- config,
155
- );
177
+ const { resolved, separator } = themeAssetsFor(activeTheme, config);
156
178
  const result = renderStatus(config.statusLine.layout, effectiveSnapshot(snapshot), width, {
157
- separator: separatorsFor(config, resolved),
179
+ separator,
158
180
  segments,
159
181
  theme: resolved,
160
182
  options: {
@@ -241,12 +263,20 @@ export function installStatusLine(options: StatusLineInstallOptions): StatusLine
241
263
  (secondary: boolean): WidgetFactory =>
242
264
  (tui, theme) => {
243
265
  const currentTheme = theme;
266
+ // Per-component render cache: Pi repaints widgets on every frame
267
+ // (keystrokes, streaming chunks, tickers). Same width without an
268
+ // invalidate() (snapshot update / configure / footer-branch change)
269
+ // means the previous lines are still current; return them as-is.
270
+ let renderCache: { width: number; lines: string[] } | undefined;
244
271
  const component: RenderComponent = {
245
272
  render(width) {
273
+ if (renderCache?.width === width) return renderCache.lines;
246
274
  const lines = render(currentTheme, width, secondary);
275
+ renderCache = { width, lines };
247
276
  return lines;
248
277
  },
249
278
  invalidate() {
279
+ renderCache = undefined;
250
280
  // Pi supplies a fresh theme to the factory on theme replacement. Do not retain
251
281
  // pre-rendered ANSI strings; the next render reads the current component theme.
252
282
  primaryComponent = secondary ? primaryComponent : component;