@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.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/extensions/pi-style.js +632 -262
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/runtime.ts +29 -4
- package/extension-src/pi-style/domain/status-renderer.ts +40 -8
- package/extension-src/pi-style/domain/status.ts +15 -5
- package/extension-src/pi-style/domain/theme.ts +32 -1
- package/extension-src/pi-style/features/editor/index.ts +17 -5
- package/extension-src/pi-style/features/messages/index.ts +302 -61
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
- package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
- package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
- package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
- package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
- package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/index.ts +2 -0
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +70 -4
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- 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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
309
|
+
visibleWidth: certifiedVisibleWidth(line),
|
|
130
310
|
hasContent: hasContent(line),
|
|
131
|
-
oscEnvelope: extractOscEnvelope(line),
|
|
132
|
-
hasOscStart
|
|
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 :
|
|
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
|
-
|
|
142
|
-
if (
|
|
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 ?
|
|
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 =
|
|
443
|
+
const prefixWidth = prefixWidthOf(prefix);
|
|
255
444
|
if (width <= prefixWidth) return undefined;
|
|
256
445
|
const bodyWidth = width - prefixWidth;
|
|
257
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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(
|
|
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(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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) =>
|
|
286
|
-
if (!
|
|
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 =
|
|
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
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 =
|
|
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
|
|
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;
|