@quandev104/pi-style 0.1.7 → 0.2.1
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/dist/extensions/pi-style.js +763 -319
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/app/index.ts +3 -2
- package/extension-src/pi-style/app/runtime.ts +72 -84
- package/extension-src/pi-style/app/snapshot.ts +41 -2
- package/extension-src/pi-style/domain/config-normalization.ts +3 -0
- package/extension-src/pi-style/domain/config-types.ts +4 -0
- package/extension-src/pi-style/features/editor/index.ts +80 -61
- package/extension-src/pi-style/features/messages/index.ts +194 -56
- package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +99 -31
- package/extension-src/pi-style/features/tools/boxed/batch.ts +44 -10
- package/extension-src/pi-style/features/tools/boxed/edit.ts +30 -15
- package/extension-src/pi-style/features/tools/boxed/index.ts +7 -2
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +31 -15
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +48 -14
- package/extension-src/pi-style/features/tools/boxed/shared.ts +25 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +56 -11
- package/extension-src/pi-style/features/tools/index.ts +19 -4
- package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
- package/extension-src/pi-style/pi/index.ts +26 -13
- package/extension-src/pi-style/pi/session-usage.ts +204 -21
- package/extension-src/pi-style/shared/box.ts +43 -8
- 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
|
|
|
@@ -208,14 +225,23 @@ export function registerBatchResult(
|
|
|
208
225
|
if (!batch || batch.meta.toolName !== meta.toolName) return { batch: undefined, isLeader: false };
|
|
209
226
|
const member = batch.members.find((entry) => entry.toolCallId === context.toolCallId);
|
|
210
227
|
if (member) {
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
const nextStatus = data.isPartial ? "running" : "done";
|
|
229
|
+
const nextIsError = !data.isPartial && data.isError;
|
|
230
|
+
const changed =
|
|
231
|
+
member.status !== nextStatus ||
|
|
232
|
+
member.isError !== nextIsError ||
|
|
233
|
+
member.errorText !== (nextIsError ? data.errorText : undefined) ||
|
|
234
|
+
member.outputEntries !== data.entries;
|
|
235
|
+
member.status = nextStatus;
|
|
236
|
+
member.isError = nextIsError;
|
|
213
237
|
if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
|
|
214
238
|
else delete member.errorText;
|
|
215
239
|
if (data.entries !== undefined) member.outputEntries = data.entries;
|
|
240
|
+
if (changed) bumpBatchRevision(batch);
|
|
216
241
|
}
|
|
217
242
|
if (batch.completedAt === undefined && batch.members.every((entry) => entry.status === "done")) {
|
|
218
243
|
batch.completedAt = performance.now();
|
|
244
|
+
bumpBatchRevision(batch);
|
|
219
245
|
}
|
|
220
246
|
return { batch, isLeader: batch.leaderId === context.toolCallId };
|
|
221
247
|
}
|
|
@@ -459,9 +485,17 @@ function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: Batch
|
|
|
459
485
|
*/
|
|
460
486
|
export function renderBatchAwareCall(theme: BoxTheme, batch: BatchState): Component {
|
|
461
487
|
return {
|
|
462
|
-
invalidate() {
|
|
488
|
+
invalidate() {
|
|
489
|
+
delete batch.renderCache;
|
|
490
|
+
},
|
|
463
491
|
render(width: number): string[] {
|
|
464
|
-
|
|
492
|
+
const status = batchStatus(batch);
|
|
493
|
+
if (!status.allDone) return renderBatchPanelLines(theme, batch, status, width);
|
|
494
|
+
const cacheKey = [themeCacheKey(theme), getToolsRenderCacheSignature(), width, batch.revision].join("|");
|
|
495
|
+
if (batch.renderCache?.key === cacheKey) return batch.renderCache.lines;
|
|
496
|
+
const lines = renderBatchPanelLines(theme, batch, status, width);
|
|
497
|
+
batch.renderCache = { key: cacheKey, lines };
|
|
498
|
+
return lines;
|
|
465
499
|
},
|
|
466
500
|
};
|
|
467
501
|
}
|
|
@@ -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,
|
|
@@ -133,21 +135,34 @@ export const editTool: BoxedToolDefinition = {
|
|
|
133
135
|
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
134
136
|
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
135
137
|
|
|
136
|
-
return
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
138
|
+
return memoizedStateComponent(
|
|
139
|
+
context.state,
|
|
140
|
+
"__piStyleEditDiffResult",
|
|
141
|
+
getRenderCacheKey(
|
|
142
|
+
"edit-diff-result",
|
|
143
|
+
theme,
|
|
144
|
+
Boolean(expanded),
|
|
145
|
+
diff,
|
|
146
|
+
sourcePath ?? "",
|
|
147
|
+
editDiffFooter(theme, result, context, stats),
|
|
148
|
+
),
|
|
149
|
+
() =>
|
|
150
|
+
renderBoxedToolResult(
|
|
151
|
+
theme,
|
|
152
|
+
{
|
|
153
|
+
render(width: number): string[] {
|
|
154
|
+
return diffView.render(width);
|
|
155
|
+
},
|
|
156
|
+
invalidate(): void {
|
|
157
|
+
diffView.invalidate();
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
dividerLabel: diffDividerLabel(theme, stats),
|
|
162
|
+
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
163
|
+
footerLines: [editDiffFooter(theme, result, context, stats)],
|
|
164
|
+
},
|
|
165
|
+
),
|
|
151
166
|
);
|
|
152
167
|
},
|
|
153
168
|
};
|
|
@@ -20,6 +20,7 @@ import type { BoxedToolContext, BoxedToolDefinition } from "./shared.js";
|
|
|
20
20
|
import {
|
|
21
21
|
emptyTurnResult,
|
|
22
22
|
getTurnEntry,
|
|
23
|
+
isMutatingTool,
|
|
23
24
|
noteTurnMemberElapsed,
|
|
24
25
|
noteTurnMemberRender,
|
|
25
26
|
renderTurnSummaryCall,
|
|
@@ -53,12 +54,16 @@ export function hasBoxedRenderer(toolName: unknown): boolean {
|
|
|
53
54
|
/**
|
|
54
55
|
* Turn-summary gate (ADR 0007): the member belongs to an ended turn, Pi's
|
|
55
56
|
* global tool-output state is collapsed, the surface is enabled, and the block
|
|
56
|
-
* itself is not an error (errors always stay visible).
|
|
57
|
+
* itself is not an error (errors always stay visible). Mutating tools
|
|
58
|
+
* (edit/write/…) are exempt unless `tools.collapseMutatingTools` is on — their
|
|
59
|
+
* blocks are the record of what was done and stay visible by default.
|
|
57
60
|
*/
|
|
58
61
|
function collapsedTurnFor(toolCallId: string, expanded: boolean): TurnState | undefined {
|
|
59
|
-
|
|
62
|
+
const config = getToolsRenderConfig();
|
|
63
|
+
if (expanded || !config.collapseAfterTurn) return undefined;
|
|
60
64
|
const entry = getTurnEntry(toolCallId);
|
|
61
65
|
if (!entry?.turn.ended || entry.member.isError) return undefined;
|
|
66
|
+
if (isMutatingTool(entry.member.toolName) && !config.collapseMutatingTools) return undefined;
|
|
62
67
|
return entry.turn;
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -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,
|
|
@@ -184,21 +186,35 @@ function renderQuickEditResult(
|
|
|
184
186
|
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
185
187
|
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
186
188
|
|
|
187
|
-
return
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
189
|
+
return memoizedStateComponent(
|
|
190
|
+
context.state,
|
|
191
|
+
"__piStyleQuickEditDiffResult",
|
|
192
|
+
getRenderCacheKey(
|
|
193
|
+
"quick-edit-diff-result",
|
|
194
|
+
theme,
|
|
195
|
+
config.toolLabel,
|
|
196
|
+
Boolean(expanded),
|
|
197
|
+
diff,
|
|
198
|
+
argPath,
|
|
199
|
+
quickEditDiffFooter(theme, result, context, stats),
|
|
200
|
+
),
|
|
201
|
+
() =>
|
|
202
|
+
renderBoxedToolResult(
|
|
203
|
+
theme,
|
|
204
|
+
{
|
|
205
|
+
render(width: number): string[] {
|
|
206
|
+
return diffView.render(width);
|
|
207
|
+
},
|
|
208
|
+
invalidate(): void {
|
|
209
|
+
diffView.invalidate();
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
dividerLabel: quickEditDividerLabel(theme, stats),
|
|
214
|
+
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
215
|
+
footerLines: [quickEditDiffFooter(theme, result, context, stats)],
|
|
216
|
+
},
|
|
217
|
+
),
|
|
202
218
|
);
|
|
203
219
|
}
|
|
204
220
|
|
|
@@ -14,6 +14,8 @@ export interface ToolsRenderConfig {
|
|
|
14
14
|
nerdFonts: boolean;
|
|
15
15
|
/** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
|
|
16
16
|
collapseAfterTurn: boolean;
|
|
17
|
+
/** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
|
|
18
|
+
collapseMutatingTools: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
let sessionToolsConfig: ToolsRenderConfig = {
|
|
@@ -24,6 +26,7 @@ let sessionToolsConfig: ToolsRenderConfig = {
|
|
|
24
26
|
batchOpenGlyph: "●",
|
|
25
27
|
nerdFonts: false,
|
|
26
28
|
collapseAfterTurn: true,
|
|
29
|
+
collapseMutatingTools: false,
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
export function setToolsRenderConfig(config: Partial<ToolsRenderConfig>): void {
|
|
@@ -34,6 +37,19 @@ export function getToolsRenderConfig(): ToolsRenderConfig {
|
|
|
34
37
|
return sessionToolsConfig;
|
|
35
38
|
}
|
|
36
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
|
+
|
|
37
53
|
// Wall-clock elapsed tracking through the renderer context state (no tool
|
|
38
54
|
// re-registration, so result.details has no execution timing).
|
|
39
55
|
//
|
|
@@ -80,8 +96,26 @@ export function markResultSeen(state: Record<string, unknown> | undefined): void
|
|
|
80
96
|
|
|
81
97
|
type TickerHandle = ReturnType<typeof setInterval>;
|
|
82
98
|
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
119
|
|
|
86
120
|
/**
|
|
87
121
|
* While a tool is running, re-render once per second so live elapsed labels
|
|
@@ -89,26 +123,26 @@ const tickerStates = new Set<Record<string, unknown>>();
|
|
|
89
123
|
*/
|
|
90
124
|
export function startElapsedTicker(state: Record<string, unknown> | undefined, invalidate: () => void): void {
|
|
91
125
|
if (!state || typeof state !== "object") return;
|
|
92
|
-
|
|
93
|
-
state
|
|
94
|
-
|
|
126
|
+
state[TICKER_KEY] = true;
|
|
127
|
+
tickerEntries.set(state, { invalidate });
|
|
128
|
+
ensureSharedTicker();
|
|
95
129
|
}
|
|
96
130
|
|
|
97
131
|
/** Stop a running tool's elapsed ticker (terminal result, error, session end). */
|
|
98
132
|
export function stopElapsedTicker(state: Record<string, unknown> | undefined): void {
|
|
99
133
|
if (!state || typeof state !== "object") return;
|
|
100
|
-
const handle = state[TICKER_KEY] as TickerHandle | undefined;
|
|
101
|
-
if (handle !== undefined) clearInterval(handle);
|
|
102
134
|
delete state[TICKER_KEY];
|
|
103
|
-
|
|
135
|
+
tickerEntries.delete(state);
|
|
136
|
+
stopSharedTickerIfIdle();
|
|
104
137
|
}
|
|
105
138
|
|
|
106
139
|
/** Stop every elapsed ticker (session start/shutdown). */
|
|
107
140
|
export function stopAllElapsedTickers(): void {
|
|
108
|
-
for (const state of
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 };
|
|
114
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,29 @@ 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
|
+
export function getRenderCacheKey(prefix: string, theme: BoxTheme, ...parts: Array<string | number | boolean>): string {
|
|
185
|
+
return [prefix, themeCacheKey(theme), getToolsRenderCacheSignature(), ...parts].join("|");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function memoizedStateComponent(
|
|
189
|
+
state: Record<string, unknown> | undefined,
|
|
190
|
+
slot: string,
|
|
191
|
+
key: string,
|
|
192
|
+
build: () => Component,
|
|
193
|
+
): Component {
|
|
194
|
+
if (!state || typeof state !== "object") return build();
|
|
195
|
+
const cached = state[slot] as StateComponentCacheEntry | undefined;
|
|
196
|
+
if (cached && cached.key === key) return cached.component;
|
|
197
|
+
const component = build();
|
|
198
|
+
state[slot] = { key, component } satisfies StateComponentCacheEntry;
|
|
199
|
+
return component;
|
|
200
|
+
}
|
|
201
|
+
|
|
177
202
|
export function clearFooterState(context: BoxedToolContext): void {
|
|
178
203
|
clearCompactBoxedFooter(context.state);
|
|
179
204
|
}
|
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
//
|
|
3
3
|
// When a turn completes, its finalized tool blocks collapse into a single
|
|
4
4
|
// summary line (`➔ Read 2 files, ran 4 shell commands · 3.1s`) rendered by the
|
|
5
|
-
// turn's leader (its first non-error tool call
|
|
6
|
-
// item of the turn renders zero lines.
|
|
7
|
-
// turns never collapse, and Pi's
|
|
8
|
-
//
|
|
5
|
+
// turn's leader (its first non-error tool call that collapses under the render
|
|
6
|
+
// config); every other collapsible tool item of the turn renders zero lines.
|
|
7
|
+
// Error results stay visible, interrupted turns never collapse, and Pi's
|
|
8
|
+
// global tool-output toggle (Ctrl+O) expands everything again
|
|
9
|
+
// (`options.expanded` is read, never written).
|
|
10
|
+
//
|
|
11
|
+
// Mutating tools (edit/write/quick_edit/substitute_edit/target_edit) are
|
|
12
|
+
// exempt from the summary by default (`tools.collapseMutatingTools: off`):
|
|
13
|
+
// their blocks are the record of what was done to the user's files, so they
|
|
14
|
+
// always stay visible (compact preview) even in an ended turn — the summary
|
|
15
|
+
// covers only read-only tools (read/ls/find/grep/bash). Turning the leaf on
|
|
16
|
+
// restores the full collapse.
|
|
9
17
|
//
|
|
10
18
|
// Design notes:
|
|
11
19
|
// - The registry is populated from **session content**, never from runtime
|
|
@@ -27,6 +35,7 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
27
35
|
import type { BoxTheme } from "../../../shared/box.js";
|
|
28
36
|
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
29
37
|
import { pluralForm } from "./output-tree.js";
|
|
38
|
+
import { getToolsRenderConfig } from "./session-config.js";
|
|
30
39
|
|
|
31
40
|
export interface TurnMemberInfo {
|
|
32
41
|
readonly toolCallId: string;
|
|
@@ -39,12 +48,37 @@ export interface TurnMemberInfo {
|
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
export interface TurnState {
|
|
42
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* First non-error member that collapses under the current render config
|
|
53
|
+
* (mutating members are skipped unless `tools.collapseMutatingTools` is
|
|
54
|
+
* on); renders the summary line. Empty when every member errored or when
|
|
55
|
+
* the turn's members are all mutating with the exemption active (such a
|
|
56
|
+
* turn collapses nothing).
|
|
57
|
+
*/
|
|
43
58
|
leaderId: string;
|
|
44
59
|
ended: boolean;
|
|
45
60
|
members: readonly TurnMemberInfo[];
|
|
46
61
|
}
|
|
47
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Tools that change the user's files. Their blocks are the record of what was
|
|
65
|
+
* done — they stay visible after the turn and are excluded from the summary
|
|
66
|
+
* unless `tools.collapseMutatingTools` is on. bash is deliberately NOT here:
|
|
67
|
+
* read-only and mutating commands are indistinguishable without parsing the
|
|
68
|
+
* command text.
|
|
69
|
+
*/
|
|
70
|
+
const MUTATING_TOOLS: ReadonlySet<string> = new Set(["edit", "write", "quick_edit", "substitute_edit", "target_edit"]);
|
|
71
|
+
|
|
72
|
+
/** Whether the tool changes the user's files (exempt from turn collapse). */
|
|
73
|
+
export function isMutatingTool(toolName: string): boolean {
|
|
74
|
+
return MUTATING_TOOLS.has(toolName);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether the summary should also cover mutating tools (render config). */
|
|
78
|
+
function mutatingCollapses(): boolean {
|
|
79
|
+
return getToolsRenderConfig().collapseMutatingTools;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
interface TurnEntry {
|
|
49
83
|
readonly turn: TurnState;
|
|
50
84
|
readonly member: TurnMemberInfo;
|
|
@@ -96,7 +130,7 @@ function registerTurn(
|
|
|
96
130
|
isError: isErrorById.get(toolCallId) === true,
|
|
97
131
|
};
|
|
98
132
|
});
|
|
99
|
-
const leader = members.find((member) => !member.isError);
|
|
133
|
+
const leader = members.find((member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()));
|
|
100
134
|
const turn: TurnState = {
|
|
101
135
|
leaderId: leader?.toolCallId ?? "",
|
|
102
136
|
ended: ended && complete,
|
|
@@ -145,7 +179,9 @@ export function registerTurnFromMessage(message: unknown, toolResults: readonly
|
|
|
145
179
|
isError: isErrorById.get(toolCallId) === true,
|
|
146
180
|
};
|
|
147
181
|
});
|
|
148
|
-
const leader = newMembers.find(
|
|
182
|
+
const leader = newMembers.find(
|
|
183
|
+
(member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()),
|
|
184
|
+
);
|
|
149
185
|
if (!currentRun) {
|
|
150
186
|
currentRun = {
|
|
151
187
|
leaderId: leader?.toolCallId ?? "",
|
|
@@ -303,17 +339,24 @@ export interface TurnSummaryParts {
|
|
|
303
339
|
readonly elapsedMs: number | undefined;
|
|
304
340
|
}
|
|
305
341
|
|
|
306
|
-
/**
|
|
342
|
+
/**
|
|
343
|
+
* Aggregate a turn's collapsed members into summary parts (pure). Mutating
|
|
344
|
+
* members are excluded unless `tools.collapseMutatingTools` is on — by default
|
|
345
|
+
* their visible blocks are the record; the summary describes only what it
|
|
346
|
+
* hides.
|
|
347
|
+
*/
|
|
307
348
|
export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
|
|
308
349
|
const counts = new Map<string, number>();
|
|
309
350
|
const order: string[] = [];
|
|
310
351
|
let failedCount = 0;
|
|
311
352
|
let elapsedMs: number | undefined;
|
|
353
|
+
const collapseMutating = mutatingCollapses();
|
|
312
354
|
for (const member of turn.members) {
|
|
313
355
|
if (member.isError) {
|
|
314
356
|
failedCount++;
|
|
315
357
|
continue;
|
|
316
358
|
}
|
|
359
|
+
if (!collapseMutating && isMutatingTool(member.toolName)) continue;
|
|
317
360
|
if (member.elapsedMs !== undefined) elapsedMs = (elapsedMs ?? 0) + member.elapsedMs;
|
|
318
361
|
const existing = counts.get(member.toolName);
|
|
319
362
|
if (existing === undefined) {
|
|
@@ -323,8 +366,10 @@ export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
|
|
|
323
366
|
}
|
|
324
367
|
const parts = order.map((toolName) => {
|
|
325
368
|
const count = counts.get(toolName) ?? 0;
|
|
326
|
-
const style = TURN_SUMMARY_STYLE[toolName]
|
|
327
|
-
|
|
369
|
+
const style = TURN_SUMMARY_STYLE[toolName];
|
|
370
|
+
// Unknown tools (extension tools like TaskCreate/ask_user_question) use a
|
|
371
|
+
// neutral phrasing with the invariant tool name: `used 5 TaskCreate`.
|
|
372
|
+
return style ? `${style.verb} ${count} ${pluralForm(style.unit, count)}` : `used ${count} ${toolName}`;
|
|
328
373
|
});
|
|
329
374
|
return { parts, failedCount, elapsedMs };
|
|
330
375
|
}
|
|
@@ -337,7 +382,7 @@ function formatTurnSummaryLine(theme: BoxTheme, turn: TurnState): string {
|
|
|
337
382
|
const parts = summary.parts.join(", ");
|
|
338
383
|
let line = `${theme.fg("dim", `➔ ${parts}`)}`;
|
|
339
384
|
if (summary.failedCount > 0)
|
|
340
|
-
line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("
|
|
385
|
+
line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("failure", summary.failedCount)}`);
|
|
341
386
|
if (summary.elapsedMs !== undefined) line += theme.fg("dim", ` · ${(summary.elapsedMs / 1000).toFixed(2)}s`);
|
|
342
387
|
return line;
|
|
343
388
|
}
|
|
@@ -423,16 +423,31 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
|
|
|
423
423
|
if (typeof renderer !== "function") {
|
|
424
424
|
neutralizeToolContainerBackground(instance);
|
|
425
425
|
if (subtype === "tool-call-renderer")
|
|
426
|
-
return (callArgs: unknown, theme: unknown, context: unknown) =>
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
426
|
+
return (callArgs: unknown, theme: unknown, context: unknown) => {
|
|
427
|
+
const component = renderBoxedToolCall(
|
|
428
|
+
toolName,
|
|
429
|
+
callArgs as Record<string, unknown>,
|
|
430
|
+
theme as never,
|
|
431
|
+
context as never,
|
|
432
|
+
);
|
|
433
|
+
// Same batch-member contract as the native-renderer path: a
|
|
434
|
+
// collapsed turn member (or quiet batch member) returns the
|
|
435
|
+
// singleton and must be hidden, or Pi leaves a stray native
|
|
436
|
+
// placeholder row per block after the collapse.
|
|
437
|
+
if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
|
|
438
|
+
return component;
|
|
439
|
+
};
|
|
440
|
+
return (result: unknown, options: unknown, theme: unknown, context: unknown) => {
|
|
441
|
+
const component = renderBoxedToolResult(
|
|
430
442
|
toolName,
|
|
431
443
|
result as { content?: readonly unknown[]; details?: unknown },
|
|
432
444
|
options as { expanded: boolean; isPartial: boolean },
|
|
433
445
|
theme as never,
|
|
434
446
|
context as never,
|
|
435
447
|
);
|
|
448
|
+
if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
|
|
449
|
+
return component;
|
|
450
|
+
};
|
|
436
451
|
}
|
|
437
452
|
return function (this: unknown, ...rendererArgs: unknown[]) {
|
|
438
453
|
const valid = subtype === "tool-call-renderer" ? validCallArgs(rendererArgs) : validResultArgs(rendererArgs);
|
|
@@ -145,7 +145,7 @@ export const KNOWN_NATIVE_IDENTITIES: Readonly<Record<string, readonly KnownNati
|
|
|
145
145
|
/** Primary (first-recorded) fingerprint per surface, for diagnostics and back-compat. */
|
|
146
146
|
export const TRUSTED_NATIVE_FINGERPRINTS: Readonly<Record<string, string>> = Object.freeze(
|
|
147
147
|
Object.fromEntries(
|
|
148
|
-
Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]
|
|
148
|
+
Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]?.fingerprint ?? ""]),
|
|
149
149
|
),
|
|
150
150
|
);
|
|
151
151
|
|