@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
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// consume no vertical space.
|
|
9
9
|
//
|
|
10
10
|
// Design notes:
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// - Live batches render directly from the registry so member completions (which
|
|
12
|
+
// trigger ui.requestRender via Pi's tool_execution_end handler) are picked up
|
|
13
|
+
// without cross-component invalidation plumbing. Once a batch is finalized,
|
|
14
|
+
// width/config-stable renders reuse the cached line array.
|
|
15
15
|
// - Batch boundaries: a new batch starts when the active batch is closed. The
|
|
16
16
|
// active batch closes when a non-batchable tool call is dispatched
|
|
17
17
|
// (boxed/index.ts), when a new message starts (pi/index.ts), and on session
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import type { Component } from "@earendil-works/pi-tui";
|
|
33
33
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
34
|
-
import { type BoxTheme, dimLine, formatToolTitlePrefix } from "../../../shared/box.js";
|
|
34
|
+
import { type BoxTheme, dimLine, formatToolTitlePrefix, themeCacheKey } from "../../../shared/box.js";
|
|
35
35
|
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
36
36
|
import {
|
|
37
37
|
fileIcon,
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
TREE_CHILD_INDENT,
|
|
43
43
|
TREE_INDENT,
|
|
44
44
|
} from "./output-tree.js";
|
|
45
|
-
import { getToolsRenderConfig } from "./session-config.js";
|
|
45
|
+
import { getToolsRenderCacheSignature, getToolsRenderConfig } from "./session-config.js";
|
|
46
46
|
import type { BoxedToolContext } from "./shared.js";
|
|
47
47
|
|
|
48
48
|
/** Quiet tools whose calls group into a single batch panel. */
|
|
@@ -78,6 +78,11 @@ export interface BatchMember {
|
|
|
78
78
|
outputEntries?: string[];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
type BatchRenderCache = {
|
|
82
|
+
key: string;
|
|
83
|
+
lines: string[];
|
|
84
|
+
};
|
|
85
|
+
|
|
81
86
|
export interface BatchState {
|
|
82
87
|
readonly meta: BatchToolMeta;
|
|
83
88
|
readonly leaderId: string;
|
|
@@ -85,6 +90,8 @@ export interface BatchState {
|
|
|
85
90
|
completedAt?: number;
|
|
86
91
|
closed: boolean;
|
|
87
92
|
readonly members: BatchMember[];
|
|
93
|
+
revision: number;
|
|
94
|
+
renderCache?: BatchRenderCache;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/** Tree head limit: only the first few members are listed, the rest collapse. */
|
|
@@ -130,6 +137,7 @@ function createBatch(
|
|
|
130
137
|
leaderId,
|
|
131
138
|
startedAt: performance.now(),
|
|
132
139
|
closed: false,
|
|
140
|
+
revision: 0,
|
|
133
141
|
members: [
|
|
134
142
|
{
|
|
135
143
|
toolCallId: leaderId,
|
|
@@ -146,6 +154,11 @@ function createBatch(
|
|
|
146
154
|
return batch;
|
|
147
155
|
}
|
|
148
156
|
|
|
157
|
+
function bumpBatchRevision(batch: BatchState): void {
|
|
158
|
+
batch.revision++;
|
|
159
|
+
delete batch.renderCache;
|
|
160
|
+
}
|
|
161
|
+
|
|
149
162
|
/**
|
|
150
163
|
* Register a call renderer invocation. Idempotent per toolCallId: re-fires
|
|
151
164
|
* (updateDisplay on the same component) reuse the call's existing batch, even
|
|
@@ -161,9 +174,12 @@ export function registerBatchCall(
|
|
|
161
174
|
if (existing) {
|
|
162
175
|
const member = existing.members.find((entry) => entry.toolCallId === context.toolCallId);
|
|
163
176
|
if (member) {
|
|
177
|
+
const changed =
|
|
178
|
+
member.detail !== detail || member.pattern !== opts.pattern || member.pathLabel !== opts.pathLabel;
|
|
164
179
|
member.detail = detail;
|
|
165
180
|
if (opts.pattern !== undefined) member.pattern = opts.pattern;
|
|
166
181
|
if (opts.pathLabel !== undefined) member.pathLabel = opts.pathLabel;
|
|
182
|
+
if (changed) bumpBatchRevision(existing);
|
|
167
183
|
}
|
|
168
184
|
return { batch: existing, isLeader: existing.leaderId === context.toolCallId };
|
|
169
185
|
}
|
|
@@ -182,6 +198,7 @@ export function registerBatchCall(
|
|
|
182
198
|
};
|
|
183
199
|
current.members.push(member);
|
|
184
200
|
batchByCallId.set(context.toolCallId, current);
|
|
201
|
+
bumpBatchRevision(current);
|
|
185
202
|
return { batch: current, isLeader: false };
|
|
186
203
|
}
|
|
187
204
|
|
|
@@ -198,6 +215,12 @@ export interface BatchResultData {
|
|
|
198
215
|
* and records batch completion once every member has settled. The member's
|
|
199
216
|
* display detail stays as registered by the call renderer (the result context's
|
|
200
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.
|
|
201
224
|
*/
|
|
202
225
|
export function registerBatchResult(
|
|
203
226
|
meta: BatchToolMeta,
|
|
@@ -208,18 +231,37 @@ export function registerBatchResult(
|
|
|
208
231
|
if (!batch || batch.meta.toolName !== meta.toolName) return { batch: undefined, isLeader: false };
|
|
209
232
|
const member = batch.members.find((entry) => entry.toolCallId === context.toolCallId);
|
|
210
233
|
if (member) {
|
|
211
|
-
|
|
212
|
-
|
|
234
|
+
const nextStatus = data.isPartial ? "running" : "done";
|
|
235
|
+
const nextIsError = !data.isPartial && data.isError;
|
|
236
|
+
const changed =
|
|
237
|
+
member.status !== nextStatus ||
|
|
238
|
+
member.isError !== nextIsError ||
|
|
239
|
+
member.errorText !== (nextIsError ? data.errorText : undefined) ||
|
|
240
|
+
(data.entries !== undefined && member.outputEntries !== data.entries);
|
|
241
|
+
member.status = nextStatus;
|
|
242
|
+
member.isError = nextIsError;
|
|
213
243
|
if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
|
|
214
244
|
else delete member.errorText;
|
|
215
245
|
if (data.entries !== undefined) member.outputEntries = data.entries;
|
|
246
|
+
if (changed) bumpBatchRevision(batch);
|
|
216
247
|
}
|
|
217
248
|
if (batch.completedAt === undefined && batch.members.every((entry) => entry.status === "done")) {
|
|
218
249
|
batch.completedAt = performance.now();
|
|
250
|
+
bumpBatchRevision(batch);
|
|
219
251
|
}
|
|
220
252
|
return { batch, isLeader: batch.leaderId === context.toolCallId };
|
|
221
253
|
}
|
|
222
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
|
+
|
|
223
265
|
interface BatchStatus {
|
|
224
266
|
readonly total: number;
|
|
225
267
|
readonly done: number;
|
|
@@ -459,9 +501,17 @@ function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: Batch
|
|
|
459
501
|
*/
|
|
460
502
|
export function renderBatchAwareCall(theme: BoxTheme, batch: BatchState): Component {
|
|
461
503
|
return {
|
|
462
|
-
invalidate() {
|
|
504
|
+
invalidate() {
|
|
505
|
+
delete batch.renderCache;
|
|
506
|
+
},
|
|
463
507
|
render(width: number): string[] {
|
|
464
|
-
|
|
508
|
+
const status = batchStatus(batch);
|
|
509
|
+
if (!status.allDone) return renderBatchPanelLines(theme, batch, status, width);
|
|
510
|
+
const cacheKey = [themeCacheKey(theme), getToolsRenderCacheSignature(), width, batch.revision].join("|");
|
|
511
|
+
if (batch.renderCache?.key === cacheKey) return batch.renderCache.lines;
|
|
512
|
+
const lines = renderBatchPanelLines(theme, batch, status, width);
|
|
513
|
+
batch.renderCache = { key: cacheKey, lines };
|
|
514
|
+
return lines;
|
|
465
515
|
},
|
|
466
516
|
};
|
|
467
517
|
}
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
type BoxedToolContext,
|
|
25
25
|
type BoxedToolDefinition,
|
|
26
26
|
displayPath,
|
|
27
|
+
getRenderCacheKey,
|
|
28
|
+
memoizedStateComponent,
|
|
27
29
|
noteBoxedCallState,
|
|
28
30
|
noteBoxedResultPhase,
|
|
29
31
|
noteExecutionStart,
|
|
@@ -115,38 +117,56 @@ export const editTool: BoxedToolDefinition = {
|
|
|
115
117
|
});
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
// Resolve language
|
|
120
|
+
// Resolve the edited path (cache-key input + language hint source).
|
|
119
121
|
const message = firstText(result.content as Array<{ type: string; text?: string }>);
|
|
120
122
|
const argPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
|
|
121
123
|
const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
|
|
122
|
-
const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
|
|
123
|
-
|
|
124
|
-
// Build diff rows + adaptive layout
|
|
125
|
-
const rows = buildSplitRows(diff);
|
|
126
124
|
const expanded = options.expanded;
|
|
127
|
-
|
|
128
|
-
|
|
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).
|
|
129
127
|
const stats = countDiffStats(diff);
|
|
130
128
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
return memoizedStateComponent(
|
|
130
|
+
context.state,
|
|
131
|
+
"__piStyleEditDiffResult",
|
|
132
|
+
getRenderCacheKey(
|
|
133
|
+
"edit-diff-result",
|
|
134
|
+
theme,
|
|
135
|
+
Boolean(expanded),
|
|
136
|
+
diff,
|
|
137
|
+
sourcePath ?? "",
|
|
138
|
+
editDiffFooter(theme, result, context, stats),
|
|
139
|
+
),
|
|
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;
|
|
135
148
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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(
|
|
155
|
+
theme,
|
|
156
|
+
{
|
|
157
|
+
render(width: number): string[] {
|
|
158
|
+
return diffView.render(width);
|
|
159
|
+
},
|
|
160
|
+
invalidate(): void {
|
|
161
|
+
diffView.invalidate();
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
dividerLabel: diffDividerLabel(theme, stats),
|
|
166
|
+
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
167
|
+
footerLines: [editDiffFooter(theme, result, context, stats)],
|
|
168
|
+
},
|
|
169
|
+
);
|
|
150
170
|
},
|
|
151
171
|
);
|
|
152
172
|
},
|
|
@@ -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,
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
type BoxedToolContext,
|
|
17
17
|
type BoxedToolDefinition,
|
|
18
18
|
displayPath,
|
|
19
|
+
getRenderCacheKey,
|
|
20
|
+
memoizedStateComponent,
|
|
19
21
|
noteBoxedCallState,
|
|
20
22
|
noteBoxedResultPhase,
|
|
21
23
|
noteExecutionStart,
|
|
@@ -172,32 +174,53 @@ function renderQuickEditResult(
|
|
|
172
174
|
});
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
const rows = buildSplitRows(diff);
|
|
176
177
|
const expanded = options.expanded;
|
|
177
178
|
const argPath = String(context?.args?.path ?? "");
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
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).
|
|
181
181
|
const stats = countDiffStats(diff);
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
183
|
+
return memoizedStateComponent(
|
|
184
|
+
context.state,
|
|
185
|
+
"__piStyleQuickEditDiffResult",
|
|
186
|
+
getRenderCacheKey(
|
|
187
|
+
"quick-edit-diff-result",
|
|
188
|
+
theme,
|
|
189
|
+
config.toolLabel,
|
|
190
|
+
Boolean(expanded),
|
|
191
|
+
diff,
|
|
192
|
+
argPath,
|
|
193
|
+
quickEditDiffFooter(theme, result, context, stats),
|
|
194
|
+
),
|
|
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(
|
|
209
|
+
theme,
|
|
210
|
+
{
|
|
211
|
+
render(width: number): string[] {
|
|
212
|
+
return diffView.render(width);
|
|
213
|
+
},
|
|
214
|
+
invalidate(): void {
|
|
215
|
+
diffView.invalidate();
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
dividerLabel: quickEditDividerLabel(theme, stats),
|
|
220
|
+
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
221
|
+
footerLines: [quickEditDiffFooter(theme, result, context, stats)],
|
|
222
|
+
},
|
|
223
|
+
);
|
|
201
224
|
},
|
|
202
225
|
);
|
|
203
226
|
}
|
|
@@ -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
|
);
|
|
@@ -37,6 +37,19 @@ export function getToolsRenderConfig(): ToolsRenderConfig {
|
|
|
37
37
|
return sessionToolsConfig;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function getToolsRenderCacheSignature(): string {
|
|
41
|
+
return [
|
|
42
|
+
sessionToolsConfig.maxCollapsedLines,
|
|
43
|
+
sessionToolsConfig.maxExpandedLines,
|
|
44
|
+
sessionToolsConfig.dimOutput ? 1 : 0,
|
|
45
|
+
sessionToolsConfig.showElapsed ? 1 : 0,
|
|
46
|
+
sessionToolsConfig.batchOpenGlyph,
|
|
47
|
+
sessionToolsConfig.nerdFonts ? 1 : 0,
|
|
48
|
+
sessionToolsConfig.collapseAfterTurn ? 1 : 0,
|
|
49
|
+
sessionToolsConfig.collapseMutatingTools ? 1 : 0,
|
|
50
|
+
].join("|");
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
// Wall-clock elapsed tracking through the renderer context state (no tool
|
|
41
54
|
// re-registration, so result.details has no execution timing).
|
|
42
55
|
//
|
|
@@ -83,8 +96,26 @@ export function markResultSeen(state: Record<string, unknown> | undefined): void
|
|
|
83
96
|
|
|
84
97
|
type TickerHandle = ReturnType<typeof setInterval>;
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
type ElapsedTickerEntry = {
|
|
100
|
+
invalidate: () => void;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** States currently subscribed to the shared elapsed-render ticker. */
|
|
104
|
+
const tickerEntries = new Map<Record<string, unknown>, ElapsedTickerEntry>();
|
|
105
|
+
let sharedTickerHandle: TickerHandle | undefined;
|
|
106
|
+
|
|
107
|
+
function ensureSharedTicker(): void {
|
|
108
|
+
if (sharedTickerHandle !== undefined || tickerEntries.size === 0) return;
|
|
109
|
+
sharedTickerHandle = setInterval(() => {
|
|
110
|
+
for (const { invalidate } of tickerEntries.values()) invalidate();
|
|
111
|
+
}, 1000) as unknown as TickerHandle;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stopSharedTickerIfIdle(): void {
|
|
115
|
+
if (sharedTickerHandle === undefined || tickerEntries.size > 0) return;
|
|
116
|
+
clearInterval(sharedTickerHandle);
|
|
117
|
+
sharedTickerHandle = undefined;
|
|
118
|
+
}
|
|
88
119
|
|
|
89
120
|
/**
|
|
90
121
|
* While a tool is running, re-render once per second so live elapsed labels
|
|
@@ -92,26 +123,26 @@ const tickerStates = new Set<Record<string, unknown>>();
|
|
|
92
123
|
*/
|
|
93
124
|
export function startElapsedTicker(state: Record<string, unknown> | undefined, invalidate: () => void): void {
|
|
94
125
|
if (!state || typeof state !== "object") return;
|
|
95
|
-
|
|
96
|
-
state
|
|
97
|
-
|
|
126
|
+
state[TICKER_KEY] = true;
|
|
127
|
+
tickerEntries.set(state, { invalidate });
|
|
128
|
+
ensureSharedTicker();
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
/** Stop a running tool's elapsed ticker (terminal result, error, session end). */
|
|
101
132
|
export function stopElapsedTicker(state: Record<string, unknown> | undefined): void {
|
|
102
133
|
if (!state || typeof state !== "object") return;
|
|
103
|
-
const handle = state[TICKER_KEY] as TickerHandle | undefined;
|
|
104
|
-
if (handle !== undefined) clearInterval(handle);
|
|
105
134
|
delete state[TICKER_KEY];
|
|
106
|
-
|
|
135
|
+
tickerEntries.delete(state);
|
|
136
|
+
stopSharedTickerIfIdle();
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
/** Stop every elapsed ticker (session start/shutdown). */
|
|
110
140
|
export function stopAllElapsedTickers(): void {
|
|
111
|
-
for (const state of
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
141
|
+
for (const state of tickerEntries.keys()) delete state[TICKER_KEY];
|
|
142
|
+
tickerEntries.clear();
|
|
143
|
+
stopSharedTickerIfIdle();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function __getElapsedTickerDebugState(): { trackedStates: number; hasSharedTicker: boolean } {
|
|
147
|
+
return { trackedStates: tickerEntries.size, hasSharedTicker: sharedTickerHandle !== undefined };
|
|
117
148
|
}
|
|
@@ -12,9 +12,11 @@ import {
|
|
|
12
12
|
renderCompactBoxedToolCall,
|
|
13
13
|
resolveRelativePath,
|
|
14
14
|
shortenPath,
|
|
15
|
+
themeCacheKey,
|
|
15
16
|
} from "../../../shared/box.js";
|
|
16
17
|
import {
|
|
17
18
|
getStateElapsedMs,
|
|
19
|
+
getToolsRenderCacheSignature,
|
|
18
20
|
isResultSeen,
|
|
19
21
|
markResultSeen,
|
|
20
22
|
recordExecutionEnded,
|
|
@@ -174,6 +176,55 @@ export function resultFooterLines(
|
|
|
174
176
|
return [formatBoxedFooter(theme, result, extraParts, stateElapsedMs(context))];
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
type StateComponentCacheEntry = {
|
|
180
|
+
key: string;
|
|
181
|
+
component: Component;
|
|
182
|
+
};
|
|
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
|
+
|
|
208
|
+
export function getRenderCacheKey(prefix: string, theme: BoxTheme, ...parts: Array<string | number | boolean>): string {
|
|
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("|");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function memoizedStateComponent(
|
|
215
|
+
state: Record<string, unknown> | undefined,
|
|
216
|
+
slot: string,
|
|
217
|
+
key: string,
|
|
218
|
+
build: () => Component,
|
|
219
|
+
): Component {
|
|
220
|
+
if (!state || typeof state !== "object") return build();
|
|
221
|
+
const cached = state[slot] as StateComponentCacheEntry | undefined;
|
|
222
|
+
if (cached && cached.key === key) return cached.component;
|
|
223
|
+
const component = build();
|
|
224
|
+
state[slot] = { key, component } satisfies StateComponentCacheEntry;
|
|
225
|
+
return component;
|
|
226
|
+
}
|
|
227
|
+
|
|
177
228
|
export function clearFooterState(context: BoxedToolContext): void {
|
|
178
229
|
clearCompactBoxedFooter(context.state);
|
|
179
230
|
}
|