@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
|
@@ -188,6 +188,38 @@ function countNewlines(text: string, from: number, to: number): number {
|
|
|
188
188
|
return count;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/** Index just past the `need`-th newline counted backwards from `end` (0 when
|
|
192
|
+
* the window holds fewer), so only `text.slice(index, end)` needs further
|
|
193
|
+
* processing. Plain char scan, no allocation. */
|
|
194
|
+
function findBackwardLineStart(text: string, need: number, end: number = text.length): number {
|
|
195
|
+
let found = 0;
|
|
196
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
197
|
+
if (text.charCodeAt(i) === 10 && ++found >= need) return i + 1;
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Whitespace per `String.prototype.trim` (superset of ASCII blank/line
|
|
203
|
+
* terminators); anything else counts as visible output. */
|
|
204
|
+
function isOutputWhitespaceCode(code: number): boolean {
|
|
205
|
+
if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) return true;
|
|
206
|
+
if (code === 0x85 || code === 0xa0 || code === 0x1680) return true;
|
|
207
|
+
if (code >= 0x2000 && code <= 0x200a) return true;
|
|
208
|
+
return code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** End index (exclusive) of the last non-whitespace character in `text` — the
|
|
212
|
+
* streaming equivalent of `stripAnsi(text).trimEnd()`: trailing blank lines and
|
|
213
|
+
* padding never push the visible tail out of the processing window. ANSI
|
|
214
|
+
* escape bytes count as non-whitespace; a slice ending inside one still
|
|
215
|
+
* strips correctly downstream. */
|
|
216
|
+
function lastVisibleEnd(text: string): number {
|
|
217
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
218
|
+
if (!isOutputWhitespaceCode(text.charCodeAt(i))) return i + 1;
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
191
223
|
function stripBashToolNoticeLines(text: string): string {
|
|
192
224
|
const filteredLines = text
|
|
193
225
|
.replace(/\r/g, "")
|
|
@@ -388,8 +420,19 @@ function renderBashStreamingResult(
|
|
|
388
420
|
options: { expanded: boolean },
|
|
389
421
|
context: BoxedToolContext,
|
|
390
422
|
): Component {
|
|
391
|
-
|
|
392
|
-
|
|
423
|
+
// Tail-only processing: the preview collapses to maxCollapsedLines lines
|
|
424
|
+
// anyway, so only the last maxCollapsedLines + 10 raw lines (the same headroom
|
|
425
|
+
// the final collapsed scan uses, covering notice lines stripped from the
|
|
426
|
+
// tail) get ANSI stripping/truncation work, and trailing blank lines never
|
|
427
|
+
// push real content out of the window (the raw-string equivalent of the old
|
|
428
|
+
// whole-buffer stripAnsi + trimEnd). Streaming passes stay O(tail) as the
|
|
429
|
+
// output grows instead of re-stripping the whole buffer each pass.
|
|
430
|
+
const contentEnd = lastVisibleEnd(raw);
|
|
431
|
+
const hasOutput = contentEnd > 0;
|
|
432
|
+
const tailStart = hasOutput
|
|
433
|
+
? findBackwardLineStart(raw, getToolsRenderConfig().maxCollapsedLines + 10, contentEnd)
|
|
434
|
+
: 0;
|
|
435
|
+
const body = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart, contentEnd)));
|
|
393
436
|
const elapsed = getStateElapsedMs(context.state);
|
|
394
437
|
const emptyLines: string[] = [theme.fg("dim", "No output received yet")];
|
|
395
438
|
if (!hasOutput && isInteractiveCommand(context?.args?.command) && (elapsed ?? 0) >= 1000) {
|
|
@@ -429,17 +472,7 @@ function renderBashFinalResult(
|
|
|
429
472
|
if (!options.expanded) {
|
|
430
473
|
// Collapsed: only process the tail of the output (notices stripped per line).
|
|
431
474
|
const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
|
|
432
|
-
|
|
433
|
-
let tailStart = 0;
|
|
434
|
-
for (let i = statusStripped.length - 1; i >= 0; i--) {
|
|
435
|
-
if (statusStripped.charCodeAt(i) === 10) {
|
|
436
|
-
nlCount++;
|
|
437
|
-
if (nlCount >= scanLines) {
|
|
438
|
-
tailStart = i + 1;
|
|
439
|
-
break;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
475
|
+
const tailStart = findBackwardLineStart(statusStripped, scanLines);
|
|
443
476
|
const tail = stripBashToolNoticeLines(stripAnsi(statusStripped.slice(tailStart)));
|
|
444
477
|
const totalLinesBefore = tailStart > 0 ? countNewlines(statusStripped, 0, tailStart) : 0;
|
|
445
478
|
const preview = createBashResultPreview(theme, tail, options, outputColor);
|
|
@@ -508,17 +541,7 @@ function createBashResultPreview(
|
|
|
508
541
|
if (!expanded) {
|
|
509
542
|
// Collapsed: only process the tail of the output
|
|
510
543
|
const needed = cfg.maxCollapsedLines;
|
|
511
|
-
|
|
512
|
-
let scanFrom = 0; // default: take full text if fewer than needed newlines
|
|
513
|
-
for (let i = text.length - 1; i >= 0; i--) {
|
|
514
|
-
if (text.charCodeAt(i) === 10) {
|
|
515
|
-
totalNewlines++;
|
|
516
|
-
if (totalNewlines === needed) {
|
|
517
|
-
scanFrom = i + 1;
|
|
518
|
-
break;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
544
|
+
const scanFrom = findBackwardLineStart(text, needed); // full text when fewer newlines
|
|
522
545
|
|
|
523
546
|
if (text.length === 0) {
|
|
524
547
|
cacheKey = cacheId;
|
|
@@ -548,10 +571,14 @@ function createBashResultPreview(
|
|
|
548
571
|
return cacheLines;
|
|
549
572
|
}
|
|
550
573
|
|
|
551
|
-
// Expanded:
|
|
574
|
+
// Expanded: only the tail lines the expanded budget can show receive
|
|
575
|
+
// clamp/truncate/color work; earlier lines collapse into one `… N earlier
|
|
576
|
+
// lines` head row, so per-line cost scales with maxExpandedLines instead
|
|
577
|
+
// of the full output.
|
|
552
578
|
const normalized = replaceTabs(text);
|
|
553
|
-
const
|
|
554
|
-
const
|
|
579
|
+
const rawLines = normalized.split("\n");
|
|
580
|
+
const totalLines = rawLines.length;
|
|
581
|
+
const hasOutput = !(totalLines === 1 && rawLines[0] === "");
|
|
555
582
|
|
|
556
583
|
if (!hasOutput) {
|
|
557
584
|
cacheKey = cacheId;
|
|
@@ -559,17 +586,17 @@ function createBashResultPreview(
|
|
|
559
586
|
return cacheLines;
|
|
560
587
|
}
|
|
561
588
|
|
|
562
|
-
const truncatedLines = logicalLines.map((line) => safeTruncateToWidth(line, bodyWidth, "…"));
|
|
563
|
-
const expandedLines = truncatedLines.length === 1 && truncatedLines[0] === "" ? [] : truncatedLines;
|
|
564
589
|
const applyColor = (l: string) =>
|
|
565
590
|
color === "error"
|
|
566
591
|
? formatToolOutputLine(theme, l, "error")
|
|
567
592
|
: cfg.dimOutput
|
|
568
593
|
? formatToolOutputLine(theme, l)
|
|
569
594
|
: formatToolOutputLine(theme, l, "text");
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
595
|
+
const renderRawLine = (line: string) => safeTruncateToWidth(clampLineLength(line), bodyWidth, "…");
|
|
596
|
+
|
|
597
|
+
if (cfg.maxExpandedLines > 0 && totalLines > cfg.maxExpandedLines) {
|
|
598
|
+
const truncated = rawLines.slice(-cfg.maxExpandedLines).map((line) => applyColor(renderRawLine(line)));
|
|
599
|
+
const remaining = totalLines - cfg.maxExpandedLines;
|
|
573
600
|
truncated.unshift(theme.fg("dim", `… ${remaining} earlier lines`));
|
|
574
601
|
cacheKey = cacheId;
|
|
575
602
|
cacheLines = truncated;
|
|
@@ -577,7 +604,7 @@ function createBashResultPreview(
|
|
|
577
604
|
}
|
|
578
605
|
|
|
579
606
|
cacheKey = cacheId;
|
|
580
|
-
cacheLines =
|
|
607
|
+
cacheLines = rawLines.map((line) => applyColor(renderRawLine(line)));
|
|
581
608
|
return cacheLines;
|
|
582
609
|
},
|
|
583
610
|
};
|
|
@@ -778,6 +805,10 @@ interface BashTreeState {
|
|
|
778
805
|
finished: boolean;
|
|
779
806
|
revision: number;
|
|
780
807
|
renderCache?: FinalSemanticRenderCache;
|
|
808
|
+
/** Raw output length at the last streaming parse attempt: partial passes
|
|
809
|
+
* with smaller growth than PARTIAL_REPARSE_THRESHOLD skip the re-parse (the
|
|
810
|
+
* final pass always parses the settled output in full). */
|
|
811
|
+
lastParsedLength?: number;
|
|
781
812
|
}
|
|
782
813
|
|
|
783
814
|
/** Classified semantic command: a bash tree (ls/find/grep), a git card, or a
|
|
@@ -834,6 +865,11 @@ function isGitActionClass(cls: BashSemanticClass): boolean {
|
|
|
834
865
|
return !isBashTreeClass(cls) && (cls as GitSemanticClass).kind === "action";
|
|
835
866
|
}
|
|
836
867
|
|
|
868
|
+
/** Minimum raw-output growth (chars) before a streaming partial pass re-parses
|
|
869
|
+
* a live tree command's output; smaller deltas keep the current tree until the
|
|
870
|
+
* final pass re-parses everything. */
|
|
871
|
+
const PARTIAL_REPARSE_THRESHOLD = 4096;
|
|
872
|
+
|
|
837
873
|
function parseSemanticOutput(cls: BashSemanticClass, output: string): ParsedSemantic | null {
|
|
838
874
|
if (isBashTreeClass(cls)) return parseBashTreeOutput(cls, output);
|
|
839
875
|
if (isGhClass(cls)) return parseGhOutput(cls, output);
|
|
@@ -945,6 +981,7 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
945
981
|
existing.finished = false;
|
|
946
982
|
existing.revision++;
|
|
947
983
|
delete existing.renderCache;
|
|
984
|
+
delete existing.lastParsedLength;
|
|
948
985
|
}
|
|
949
986
|
existing.command = command;
|
|
950
987
|
existing.cls = cls;
|
|
@@ -980,14 +1017,26 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
980
1017
|
}
|
|
981
1018
|
if (!options.isPartial || isBashTreeClass(cls)) {
|
|
982
1019
|
const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
|
|
983
|
-
const parsed = parseSemanticOutput(cls, output);
|
|
984
1020
|
const state = semanticStates.get(context.toolCallId);
|
|
1021
|
+
// Live tree classes re-parse on streaming passes, but only once the raw
|
|
1022
|
+
// output grew ≥ PARTIAL_REPARSE_THRESHOLD chars since the last parse
|
|
1023
|
+
// attempt: re-parsing the full buffer on every partial pass made
|
|
1024
|
+
// streaming O(n²). Small deltas keep the current tree; the final pass
|
|
1025
|
+
// always re-parses, so the settled registry state matches the ungated
|
|
1026
|
+
// path byte for byte.
|
|
1027
|
+
const shouldParse =
|
|
1028
|
+
!options.isPartial ||
|
|
1029
|
+
state === undefined ||
|
|
1030
|
+
state.lastParsedLength === undefined ||
|
|
1031
|
+
output.length - state.lastParsedLength >= PARTIAL_REPARSE_THRESHOLD;
|
|
1032
|
+
const parsed = shouldParse ? parseSemanticOutput(cls, output) : undefined;
|
|
985
1033
|
if (parsed) {
|
|
986
1034
|
if (state) {
|
|
987
1035
|
state.parsed = parsed;
|
|
988
1036
|
state.finished = !options.isPartial;
|
|
989
1037
|
state.revision++;
|
|
990
1038
|
delete state.renderCache;
|
|
1039
|
+
if (options.isPartial) state.lastParsedLength = output.length;
|
|
991
1040
|
} else
|
|
992
1041
|
semanticStates.set(context.toolCallId, {
|
|
993
1042
|
cls,
|
|
@@ -995,6 +1044,7 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
995
1044
|
parsed,
|
|
996
1045
|
finished: !options.isPartial,
|
|
997
1046
|
revision: 0,
|
|
1047
|
+
...(options.isPartial ? { lastParsedLength: output.length } : {}),
|
|
998
1048
|
});
|
|
999
1049
|
// `git diff` / `git show` render a boxed adaptive-diff result (one frame
|
|
1000
1050
|
// per file); `gh run view --job=<id>` renders a boxed log result. Every
|
|
@@ -1008,14 +1058,25 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
1008
1058
|
}
|
|
1009
1059
|
return EMPTY_BASH_TREE_RESULT;
|
|
1010
1060
|
}
|
|
1061
|
+
if (!shouldParse) {
|
|
1062
|
+
// Skipped re-parse (sub-threshold growth): keep the current panel. A
|
|
1063
|
+
// live parsed tree still owns the display (the call panel renders it, the
|
|
1064
|
+
// result adds nothing); a fallback keeps streaming raw output into the
|
|
1065
|
+
// open box below.
|
|
1066
|
+
if (state?.parsed !== undefined) return EMPTY_BASH_TREE_RESULT;
|
|
1067
|
+
}
|
|
1011
1068
|
// Unparseable output (ls -l, raw rg summary, non-git output): the boxed
|
|
1012
1069
|
// shell owns the result; flag the call panel to render nothing so the
|
|
1013
|
-
// two don't duplicate.
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
state
|
|
1017
|
-
|
|
1018
|
-
|
|
1070
|
+
// two don't duplicate. Skipped passes (sub-threshold growth) leave the
|
|
1071
|
+
// current panel untouched.
|
|
1072
|
+
if (shouldParse) {
|
|
1073
|
+
if (state) {
|
|
1074
|
+
state.fallback = true;
|
|
1075
|
+
state.finished = !options.isPartial;
|
|
1076
|
+
state.revision++;
|
|
1077
|
+
delete state.renderCache;
|
|
1078
|
+
if (options.isPartial) state.lastParsedLength = output.length;
|
|
1079
|
+
}
|
|
1019
1080
|
}
|
|
1020
1081
|
}
|
|
1021
1082
|
} else if (options.isPartial) {
|
|
@@ -215,6 +215,12 @@ export interface BatchResultData {
|
|
|
215
215
|
* and records batch completion once every member has settled. The member's
|
|
216
216
|
* display detail stays as registered by the call renderer (the result context's
|
|
217
217
|
* args may be normalized differently).
|
|
218
|
+
*
|
|
219
|
+
* `data.entries === undefined` means "keep the registered entries": callers
|
|
220
|
+
* that already registered final output (see hasFinalBatchOutput) omit the
|
|
221
|
+
* field on warm re-render passes, and that must never count as a change —
|
|
222
|
+
* comparing against the stored array would otherwise bump the revision on
|
|
223
|
+
* every pass.
|
|
218
224
|
*/
|
|
219
225
|
export function registerBatchResult(
|
|
220
226
|
meta: BatchToolMeta,
|
|
@@ -231,7 +237,7 @@ export function registerBatchResult(
|
|
|
231
237
|
member.status !== nextStatus ||
|
|
232
238
|
member.isError !== nextIsError ||
|
|
233
239
|
member.errorText !== (nextIsError ? data.errorText : undefined) ||
|
|
234
|
-
member.outputEntries !== data.entries;
|
|
240
|
+
(data.entries !== undefined && member.outputEntries !== data.entries);
|
|
235
241
|
member.status = nextStatus;
|
|
236
242
|
member.isError = nextIsError;
|
|
237
243
|
if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
|
|
@@ -246,6 +252,16 @@ export function registerBatchResult(
|
|
|
246
252
|
return { batch, isLeader: batch.leaderId === context.toolCallId };
|
|
247
253
|
}
|
|
248
254
|
|
|
255
|
+
/** True when the member for `toolCallId` has settled with final parsed output
|
|
256
|
+
* (done, non-error, entries registered). Result renderers use this to skip
|
|
257
|
+
* re-parsing an unchanged final output on warm re-render passes. */
|
|
258
|
+
export function hasFinalBatchOutput(toolCallId: string): boolean {
|
|
259
|
+
const batch = batchByCallId.get(toolCallId);
|
|
260
|
+
if (!batch) return false;
|
|
261
|
+
const member = batch.members.find((entry) => entry.toolCallId === toolCallId);
|
|
262
|
+
return member !== undefined && member.outputEntries !== undefined && member.status === "done" && !member.isError;
|
|
263
|
+
}
|
|
264
|
+
|
|
249
265
|
interface BatchStatus {
|
|
250
266
|
readonly total: number;
|
|
251
267
|
readonly done: number;
|
|
@@ -117,24 +117,15 @@ export const editTool: BoxedToolDefinition = {
|
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
// Resolve language
|
|
120
|
+
// Resolve the edited path (cache-key input + language hint source).
|
|
121
121
|
const message = firstText(result.content as Array<{ type: string; text?: string }>);
|
|
122
122
|
const argPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
|
|
123
123
|
const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
|
|
124
|
-
const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
|
|
125
|
-
|
|
126
|
-
// Build diff rows + adaptive layout
|
|
127
|
-
const rows = buildSplitRows(diff);
|
|
128
124
|
const expanded = options.expanded;
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
// Stats feed the footer, which is part of the cache key (cheap line scan —
|
|
126
|
+
// unlike the row/component construction below, which must not run on hits).
|
|
131
127
|
const stats = countDiffStats(diff);
|
|
132
128
|
|
|
133
|
-
// Render adaptive diff (unified/split per width) with syntax colors for small outputs.
|
|
134
|
-
const maxRows = expanded ? 160 : 36;
|
|
135
|
-
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
136
|
-
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
137
|
-
|
|
138
129
|
return memoizedStateComponent(
|
|
139
130
|
context.state,
|
|
140
131
|
"__piStyleEditDiffResult",
|
|
@@ -146,8 +137,21 @@ export const editTool: BoxedToolDefinition = {
|
|
|
146
137
|
sourcePath ?? "",
|
|
147
138
|
editDiffFooter(theme, result, context, stats),
|
|
148
139
|
),
|
|
149
|
-
() =>
|
|
150
|
-
|
|
140
|
+
() => {
|
|
141
|
+
// Expensive construction (buildSplitRows + AdaptiveDiffComponent,
|
|
142
|
+
// ~0.4ms for a 160-row diff) runs only on cache misses, never per
|
|
143
|
+
// render pass. Everything below is a pure function of the key inputs.
|
|
144
|
+
const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
|
|
145
|
+
const rows = buildSplitRows(diff);
|
|
146
|
+
const shouldHighlight =
|
|
147
|
+
Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
|
|
148
|
+
|
|
149
|
+
// Render adaptive diff (unified/split per width) with syntax colors for small outputs.
|
|
150
|
+
const maxRows = expanded ? 160 : 36;
|
|
151
|
+
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
152
|
+
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
153
|
+
|
|
154
|
+
return renderBoxedToolResult(
|
|
151
155
|
theme,
|
|
152
156
|
{
|
|
153
157
|
render(width: number): string[] {
|
|
@@ -162,7 +166,8 @@ export const editTool: BoxedToolDefinition = {
|
|
|
162
166
|
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
163
167
|
footerLines: [editDiffFooter(theme, result, context, stats)],
|
|
164
168
|
},
|
|
165
|
-
)
|
|
169
|
+
);
|
|
170
|
+
},
|
|
166
171
|
);
|
|
167
172
|
},
|
|
168
173
|
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type BatchToolMeta,
|
|
12
12
|
EMPTY_BATCH_COMPONENT,
|
|
13
13
|
emptyBatchResult,
|
|
14
|
+
hasFinalBatchOutput,
|
|
14
15
|
registerBatchCall,
|
|
15
16
|
registerBatchResult,
|
|
16
17
|
renderBatchAwareCall,
|
|
@@ -48,14 +49,18 @@ export const findTool: BoxedToolDefinition = {
|
|
|
48
49
|
return renderBatchAwareCall(theme, batch);
|
|
49
50
|
},
|
|
50
51
|
result(result, options, _theme, context) {
|
|
51
|
-
const
|
|
52
|
-
|
|
52
|
+
const isError = Boolean(context.isError);
|
|
53
|
+
// Result renderers re-fire on every repaint/scroll; once the final output
|
|
54
|
+
// is parsed and registered, skip stripping/parsing the same text again.
|
|
55
|
+
const settled = !options.isPartial && !isError && hasFinalBatchOutput(context.toolCallId);
|
|
56
|
+
const output = settled ? "" : stripAnsi(getTextOutput(result)).trimEnd();
|
|
57
|
+
const entries = settled || isError ? undefined : parseFindOutput(output);
|
|
53
58
|
registerBatchResult(
|
|
54
59
|
FIND_META,
|
|
55
60
|
{
|
|
56
61
|
isPartial: Boolean(options.isPartial),
|
|
57
|
-
isError
|
|
58
|
-
errorText:
|
|
62
|
+
isError,
|
|
63
|
+
errorText: isError ? output || undefined : undefined,
|
|
59
64
|
...(entries !== undefined ? { entries } : {}),
|
|
60
65
|
},
|
|
61
66
|
context,
|
|
@@ -26,7 +26,7 @@ import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../
|
|
|
26
26
|
import { parseSimpleBashCommand } from "./command-shape.js";
|
|
27
27
|
import { pluralForm, TREE_INDENT } from "./output-tree.js";
|
|
28
28
|
import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
|
|
29
|
-
import type
|
|
29
|
+
import { type BoxedToolContext, getRenderCacheKey, memoizedStateComponent } from "./shared.js";
|
|
30
30
|
|
|
31
31
|
// ── Classification ──────────────────────────────────────────────────────────
|
|
32
32
|
|
|
@@ -1871,7 +1871,10 @@ interface DiffFileBox {
|
|
|
1871
1871
|
|
|
1872
1872
|
/** Build a complete boxed-diff result component for a parsed `git diff`/`show`.
|
|
1873
1873
|
* The call panel renders the boxless Git header; this component renders one
|
|
1874
|
-
* `╭…╰` frame per file (or a single `No changes` frame for an empty diff).
|
|
1874
|
+
* `╭…╰` frame per file (or a single `No changes` frame for an empty diff).
|
|
1875
|
+
* Construction is memoized on the call state: repeated result passes reuse
|
|
1876
|
+
* the cached component (identity-stable, invalidate propagates inward) so
|
|
1877
|
+
* the per-file `AdaptiveDiffComponent` build never re-runs per render pass. */
|
|
1875
1878
|
export function renderGitDiffResult(
|
|
1876
1879
|
theme: BoxTheme,
|
|
1877
1880
|
parsed: GitDiffParsed,
|
|
@@ -1879,6 +1882,47 @@ export function renderGitDiffResult(
|
|
|
1879
1882
|
context: BoxedToolContext,
|
|
1880
1883
|
): Component {
|
|
1881
1884
|
const expanded = Boolean(options.expanded);
|
|
1885
|
+
|
|
1886
|
+
// Cheap cache key capturing everything that affects output — theme, show vs
|
|
1887
|
+
// diff, expansion, file count/totals, and per-file identity (path, body
|
|
1888
|
+
// length, counts, binary/status) — computed WITHOUT building rows or
|
|
1889
|
+
// components; the expensive build runs only on cache misses.
|
|
1890
|
+
let totalAdditions = 0;
|
|
1891
|
+
let totalRemovals = 0;
|
|
1892
|
+
const sigParts: string[] = [];
|
|
1893
|
+
for (const file of parsed.files) {
|
|
1894
|
+
totalAdditions += file.additions;
|
|
1895
|
+
totalRemovals += file.removals;
|
|
1896
|
+
sigParts.push(
|
|
1897
|
+
`${file.path}:${file.body.length}:${file.additions}:${file.removals}:${file.binary ? 1 : 0}:${file.status ?? ""}`,
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
const sig = sigParts.join(";").slice(0, 2048);
|
|
1901
|
+
|
|
1902
|
+
return memoizedStateComponent(
|
|
1903
|
+
context.state,
|
|
1904
|
+
"__piStyleGitDiffResult",
|
|
1905
|
+
getRenderCacheKey(
|
|
1906
|
+
"git-diff-result",
|
|
1907
|
+
theme,
|
|
1908
|
+
String(parsed.show),
|
|
1909
|
+
String(expanded),
|
|
1910
|
+
parsed.files.length,
|
|
1911
|
+
totalAdditions,
|
|
1912
|
+
totalRemovals,
|
|
1913
|
+
sig,
|
|
1914
|
+
),
|
|
1915
|
+
() => buildGitDiffResultComponent(theme, parsed, expanded, context),
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/** Uncached boxed git-diff construction: one `╭…╰` frame per file. */
|
|
1920
|
+
function buildGitDiffResultComponent(
|
|
1921
|
+
theme: BoxTheme,
|
|
1922
|
+
parsed: GitDiffParsed,
|
|
1923
|
+
expanded: boolean,
|
|
1924
|
+
context: BoxedToolContext,
|
|
1925
|
+
): Component {
|
|
1882
1926
|
const elapsedMs = getStateElapsedMs(context.state);
|
|
1883
1927
|
const fileCount = parsed.files.length;
|
|
1884
1928
|
|
|
@@ -166,14 +166,21 @@ export const grepTool: BoxedToolDefinition = {
|
|
|
166
166
|
return renderGrepPanel(theme, context.toolCallId);
|
|
167
167
|
},
|
|
168
168
|
result(result, options, _theme, context) {
|
|
169
|
-
const output = stripAnsi(getTextOutput(result)).trimEnd();
|
|
170
169
|
const isError = Boolean(context.isError);
|
|
170
|
+
const isPartial = Boolean(options.isPartial);
|
|
171
|
+
// Result renderers re-fire on every repaint/scroll; once final matches
|
|
172
|
+
// are registered the registry is already final — skip stripping/parsing.
|
|
173
|
+
const state = grepPanels.get(context.toolCallId);
|
|
174
|
+
if (!isPartial && !isError && state !== undefined && state.matches !== undefined && !state.isPartial) {
|
|
175
|
+
return EMPTY_GREP_RESULT;
|
|
176
|
+
}
|
|
177
|
+
const output = stripAnsi(getTextOutput(result)).trimEnd();
|
|
171
178
|
const matches = isError ? [] : parseGrepOutput(output);
|
|
172
179
|
registerGrepResult(context.toolCallId, {
|
|
173
180
|
matches,
|
|
174
181
|
isError,
|
|
175
182
|
errorText: isError ? output || undefined : undefined,
|
|
176
|
-
isPartial
|
|
183
|
+
isPartial,
|
|
177
184
|
});
|
|
178
185
|
return EMPTY_GREP_RESULT;
|
|
179
186
|
},
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type BatchToolMeta,
|
|
12
12
|
EMPTY_BATCH_COMPONENT,
|
|
13
13
|
emptyBatchResult,
|
|
14
|
+
hasFinalBatchOutput,
|
|
14
15
|
registerBatchCall,
|
|
15
16
|
registerBatchResult,
|
|
16
17
|
renderBatchAwareCall,
|
|
@@ -40,14 +41,18 @@ export const lsTool: BoxedToolDefinition = {
|
|
|
40
41
|
return renderBatchAwareCall(theme, batch);
|
|
41
42
|
},
|
|
42
43
|
result(result, options, _theme, context) {
|
|
43
|
-
const
|
|
44
|
-
|
|
44
|
+
const isError = Boolean(context.isError);
|
|
45
|
+
// Result renderers re-fire on every repaint/scroll; once the final output
|
|
46
|
+
// is parsed and registered, skip stripping/parsing the same text again.
|
|
47
|
+
const settled = !options.isPartial && !isError && hasFinalBatchOutput(context.toolCallId);
|
|
48
|
+
const output = settled ? "" : stripAnsi(getTextOutput(result)).trimEnd();
|
|
49
|
+
const entries = settled || isError ? undefined : parseLsOutput(output);
|
|
45
50
|
registerBatchResult(
|
|
46
51
|
LIST_META,
|
|
47
52
|
{
|
|
48
53
|
isPartial: Boolean(options.isPartial),
|
|
49
|
-
isError
|
|
50
|
-
errorText:
|
|
54
|
+
isError,
|
|
55
|
+
errorText: isError ? output || undefined : undefined,
|
|
51
56
|
...(entries !== undefined ? { entries } : {}),
|
|
52
57
|
},
|
|
53
58
|
context,
|
|
@@ -174,18 +174,12 @@ function renderQuickEditResult(
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
const rows = buildSplitRows(diff);
|
|
178
177
|
const expanded = options.expanded;
|
|
179
178
|
const argPath = String(context?.args?.path ?? "");
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
|
|
179
|
+
// Stats feed the footer, which is part of the cache key (cheap line scan —
|
|
180
|
+
// unlike the row/component construction below, which must not run on hits).
|
|
183
181
|
const stats = countDiffStats(diff);
|
|
184
182
|
|
|
185
|
-
const maxRows = expanded ? 160 : 36;
|
|
186
|
-
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
187
|
-
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
188
|
-
|
|
189
183
|
return memoizedStateComponent(
|
|
190
184
|
context.state,
|
|
191
185
|
"__piStyleQuickEditDiffResult",
|
|
@@ -198,8 +192,20 @@ function renderQuickEditResult(
|
|
|
198
192
|
argPath,
|
|
199
193
|
quickEditDiffFooter(theme, result, context, stats),
|
|
200
194
|
),
|
|
201
|
-
() =>
|
|
202
|
-
|
|
195
|
+
() => {
|
|
196
|
+
// Expensive construction (buildSplitRows + AdaptiveDiffComponent) runs
|
|
197
|
+
// only on cache misses, never per render pass. Everything below is a
|
|
198
|
+
// pure function of the key inputs.
|
|
199
|
+
const rows = buildSplitRows(diff);
|
|
200
|
+
const language = argPath ? getLanguageFromPath(argPath) : undefined;
|
|
201
|
+
const shouldHighlight =
|
|
202
|
+
Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
|
|
203
|
+
|
|
204
|
+
const maxRows = expanded ? 160 : 36;
|
|
205
|
+
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
206
|
+
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
207
|
+
|
|
208
|
+
return renderBoxedToolResult(
|
|
203
209
|
theme,
|
|
204
210
|
{
|
|
205
211
|
render(width: number): string[] {
|
|
@@ -214,7 +220,8 @@ function renderQuickEditResult(
|
|
|
214
220
|
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
215
221
|
footerLines: [quickEditDiffFooter(theme, result, context, stats)],
|
|
216
222
|
},
|
|
217
|
-
)
|
|
223
|
+
);
|
|
224
|
+
},
|
|
218
225
|
);
|
|
219
226
|
}
|
|
220
227
|
|
|
@@ -32,13 +32,15 @@ export const readTool: BoxedToolDefinition = {
|
|
|
32
32
|
return renderBatchAwareCall(theme, batch);
|
|
33
33
|
},
|
|
34
34
|
result(result, options, _theme, context) {
|
|
35
|
-
|
|
35
|
+
// The strip is only needed for error text — keep it off the success path
|
|
36
|
+
// (result renderers re-fire on every repaint/scroll).
|
|
37
|
+
const errorText = context.isError ? stripAnsi(getTextOutput(result)).trimEnd() || undefined : undefined;
|
|
36
38
|
registerBatchResult(
|
|
37
39
|
READ_META,
|
|
38
40
|
{
|
|
39
41
|
isPartial: Boolean(options.isPartial),
|
|
40
42
|
isError: Boolean(context.isError),
|
|
41
|
-
errorText
|
|
43
|
+
errorText,
|
|
42
44
|
},
|
|
43
45
|
context,
|
|
44
46
|
);
|
|
@@ -181,8 +181,34 @@ type StateComponentCacheEntry = {
|
|
|
181
181
|
component: Component;
|
|
182
182
|
};
|
|
183
183
|
|
|
184
|
+
/** Cache-key string parts at or below this length join verbatim; longer ones
|
|
185
|
+
* collapse to `length:hash` so the joined key length stays bounded regardless
|
|
186
|
+
* of raw output size. */
|
|
187
|
+
const CACHE_KEY_LONG_PART_THRESHOLD = 64;
|
|
188
|
+
|
|
189
|
+
/** 32-bit FNV-1a hash of a string as 8 lowercase hex digits. Local mirror of the
|
|
190
|
+
* compatibility probe's fingerprint hashing (the pi/ layer stays unreachable
|
|
191
|
+
* from features): deterministic, collision-safe for cache identity. */
|
|
192
|
+
function fnv1aHex(text: string): string {
|
|
193
|
+
let hash = 2166136261;
|
|
194
|
+
for (let i = 0; i < text.length; i++) {
|
|
195
|
+
hash ^= text.charCodeAt(i);
|
|
196
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
197
|
+
}
|
|
198
|
+
return hash.toString(16).padStart(8, "0");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Fold one join part: strings longer than CACHE_KEY_LONG_PART_THRESHOLD
|
|
202
|
+
* collapse to `length:hash`; numbers/booleans pass through unchanged. */
|
|
203
|
+
function boundedCacheKeyPart(part: string | number | boolean): string | number | boolean {
|
|
204
|
+
if (typeof part !== "string" || part.length <= CACHE_KEY_LONG_PART_THRESHOLD) return part;
|
|
205
|
+
return `${part.length}:${fnv1aHex(part)}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
184
208
|
export function getRenderCacheKey(prefix: string, theme: BoxTheme, ...parts: Array<string | number | boolean>): string {
|
|
185
|
-
|
|
209
|
+
const pieces: Array<string | number | boolean> = [prefix, themeCacheKey(theme), getToolsRenderCacheSignature()];
|
|
210
|
+
for (const part of parts) pieces.push(boundedCacheKeyPart(part));
|
|
211
|
+
return pieces.join("|");
|
|
186
212
|
}
|
|
187
213
|
|
|
188
214
|
export function memoizedStateComponent(
|
|
@@ -306,6 +306,18 @@ export function invalidateTurnMembers(turn: TurnState): void {
|
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Drop the turn's captured invalidate callbacks (`agent_end`, after the
|
|
311
|
+
* collapse re-render). The closures pin component state for the rest of the
|
|
312
|
+
* session otherwise; `memberByCallId` entries stay so scrollback keeps
|
|
313
|
+
* resolving the turn. Later expand toggles re-render via Pi's updateDisplay
|
|
314
|
+
* selectors and re-capture fresh callbacks; a missing callback is already
|
|
315
|
+
* skipped gracefully by invalidateTurnMembers.
|
|
316
|
+
*/
|
|
317
|
+
export function releaseTurnInvalidators(turn: TurnState): void {
|
|
318
|
+
for (const member of turn.members) invalidateByCallId.delete(member.toolCallId);
|
|
319
|
+
}
|
|
320
|
+
|
|
309
321
|
/**
|
|
310
322
|
* Freeze a member's wall-clock elapsed into the registry (idempotent; the
|
|
311
323
|
* value is frozen by the renderer state once the terminal result rendered).
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
invalidateTurnMembers,
|
|
8
8
|
rebuildTurnRegistryFromEntries,
|
|
9
9
|
registerTurnFromMessage,
|
|
10
|
+
releaseTurnInvalidators,
|
|
10
11
|
} from "../features/tools/boxed/turn-summary.js";
|
|
11
12
|
import { requestToolPresentationRender } from "../features/tools/index.js";
|
|
12
13
|
import { registerPiStyleCommand } from "./commands.js";
|
|
@@ -142,6 +143,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
142
143
|
const run = finishAgentRun();
|
|
143
144
|
if (run) {
|
|
144
145
|
invalidateTurnMembers(run);
|
|
146
|
+
releaseTurnInvalidators(run);
|
|
145
147
|
requestToolPresentationRender();
|
|
146
148
|
}
|
|
147
149
|
});
|