@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.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/dist/extensions/pi-style.js +1256 -491
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/index.ts +3 -2
- package/extension-src/pi-style/app/runtime.ts +99 -86
- package/extension-src/pi-style/app/snapshot.ts +41 -2
- 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 +97 -66
- package/extension-src/pi-style/features/messages/index.ts +469 -90
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
- package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
- package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
- 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 +45 -22
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
- package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
- package/extension-src/pi-style/pi/index.ts +28 -13
- package/extension-src/pi-style/pi/session-usage.ts +204 -21
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +83 -6
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
|
@@ -1,9 +1,56 @@
|
|
|
1
|
-
import { visibleWidth } from "
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
74
|
-
return `${
|
|
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
|
|
92
|
-
const
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
405
|
+
return true;
|
|
136
406
|
}
|
|
137
407
|
|
|
138
|
-
function
|
|
139
|
-
return
|
|
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 =
|
|
443
|
+
const prefixWidth = prefixWidthOf(prefix);
|
|
146
444
|
if (width <= prefixWidth) return undefined;
|
|
147
445
|
const bodyWidth = width - prefixWidth;
|
|
148
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
const
|
|
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(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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) =>
|
|
172
|
-
if (!
|
|
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
|
-
|
|
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 -
|
|
549
|
+
const reducedWidth = width - prefixWidth;
|
|
204
550
|
const native = Reflect.apply(original, instance, [reducedWidth, ...args.slice(1)]);
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
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 === "") {
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
}
|