@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/extensions/pi-style.js +763 -319
  3. package/dist/extensions/pi-style.js.map +1 -1
  4. package/extension-src/pi-style/app/command-service.ts +2 -0
  5. package/extension-src/pi-style/app/index.ts +3 -2
  6. package/extension-src/pi-style/app/runtime.ts +72 -84
  7. package/extension-src/pi-style/app/snapshot.ts +41 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -0
  9. package/extension-src/pi-style/domain/config-types.ts +4 -0
  10. package/extension-src/pi-style/features/editor/index.ts +80 -61
  11. package/extension-src/pi-style/features/messages/index.ts +194 -56
  12. package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
  13. package/extension-src/pi-style/features/tools/boxed/bash.ts +99 -31
  14. package/extension-src/pi-style/features/tools/boxed/batch.ts +44 -10
  15. package/extension-src/pi-style/features/tools/boxed/edit.ts +30 -15
  16. package/extension-src/pi-style/features/tools/boxed/index.ts +7 -2
  17. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +31 -15
  18. package/extension-src/pi-style/features/tools/boxed/session-config.ts +48 -14
  19. package/extension-src/pi-style/features/tools/boxed/shared.ts +25 -0
  20. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +56 -11
  21. package/extension-src/pi-style/features/tools/index.ts +19 -4
  22. package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
  23. package/extension-src/pi-style/pi/index.ts +26 -13
  24. package/extension-src/pi-style/pi/session-usage.ts +204 -21
  25. package/extension-src/pi-style/shared/box.ts +43 -8
  26. package/package.json +1 -1
@@ -8,10 +8,10 @@
8
8
  // consume no vertical space.
9
9
  //
10
10
  // Design notes:
11
- // - No caching in the batch panel: it reads the module-level registry on every
12
- // render, so member completions (which trigger ui.requestRender via Pi's
13
- // tool_execution_end handler) are picked up without cross-component
14
- // invalidation plumbing.
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
- member.status = data.isPartial ? "running" : "done";
212
- member.isError = !data.isPartial && data.isError;
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
- return renderBatchPanelLines(theme, batch, batchStatus(batch), width);
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 renderBoxedToolResult(
137
- theme,
138
- {
139
- render(width: number): string[] {
140
- return diffView.render(width);
141
- },
142
- invalidate(): void {
143
- diffView.invalidate();
144
- },
145
- },
146
- {
147
- dividerLabel: diffDividerLabel(theme, stats),
148
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
149
- footerLines: [editDiffFooter(theme, result, context, stats)],
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
- if (expanded || !getToolsRenderConfig().collapseAfterTurn) return undefined;
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 renderBoxedToolResult(
188
- theme,
189
- {
190
- render(width: number): string[] {
191
- return diffView.render(width);
192
- },
193
- invalidate(): void {
194
- diffView.invalidate();
195
- },
196
- },
197
- {
198
- dividerLabel: quickEditDividerLabel(theme, stats),
199
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
200
- footerLines: [quickEditDiffFooter(theme, result, context, stats)],
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
- /** States that currently own a 1s elapsed-render interval, for session cleanup. */
84
- const tickerStates = new Set<Record<string, unknown>>();
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
- if (state[TICKER_KEY] !== undefined) return;
93
- state[TICKER_KEY] = setInterval(() => invalidate(), 1000) as unknown as TickerHandle;
94
- tickerStates.add(state);
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
- tickerStates.delete(state);
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 tickerStates) {
109
- const handle = state[TICKER_KEY] as TickerHandle | undefined;
110
- if (handle !== undefined) clearInterval(handle);
111
- delete state[TICKER_KEY];
112
- }
113
- tickerStates.clear();
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); every other non-error tool
6
- // item of the turn renders zero lines. Error results stay visible, interrupted
7
- // turns never collapse, and Pi's global tool-output toggle (Ctrl+O) expands
8
- // everything again (`options.expanded` is read, never written).
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
- /** First non-error member; renders the summary line. Empty when every member errored. */
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((member) => !member.isError);
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
- /** Aggregate a turn's non-error members into summary parts (pure). */
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] ?? { verb: "ran", unit: `${toolName} call` };
327
- return `${style.verb} ${count} ${pluralForm(style.unit, count)}`;
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("failed", summary.failedCount)}`);
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
- renderBoxedToolCall(toolName, callArgs as Record<string, unknown>, theme as never, context as never);
428
- return (result: unknown, options: unknown, theme: unknown, context: unknown) =>
429
- renderBoxedToolResult(
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]!.fingerprint]),
148
+ Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]?.fingerprint ?? ""]),
149
149
  ),
150
150
  );
151
151