@pi-kaush/pi-tool-call-markers 0.1.0

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 ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Collapse adjacent successful tool calls into one compact block per tool type, each with a gear header and bulleted call summaries.
6
+ - Add vertical spacing between tool types and a hanging indent for wrapped bullet summaries.
7
+ - Keep visible thinking/text, active calls, and errors as group boundaries; expand errors in place.
8
+ - Combine only directly adjacent thinking blocks, falling back to Pi's renderer exactly once on malformed content.
9
+ - Restore individual full blocks when tools are expanded (Ctrl+O).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaushik Gopal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @pi-kaush/pi-tool-call-markers
2
+
3
+ Collapse Pi's adjacent successful tool calls into one compact, gear-headed block per tool type, so a run of similar calls reads as a tidy bulleted list instead of a wall of repeated headers and results.
4
+
5
+ ## What it changes
6
+
7
+ When several tool calls of the same type succeed in a row, Pi normally renders each one as its own expanded block. This extension groups them:
8
+
9
+ - **One gear header per contiguous tool type.** A run of `read` calls shares a single `⚙️ read` header; the following `write` run gets its own `⚙️ write` header.
10
+ - **Bulleted call summaries.** Each call in a group becomes one bullet with a short summary (the tool name is stripped from the bullet since the header already names the tool).
11
+ - **Vertical spacing between tool types.** A blank line separates one tool group from the next.
12
+ - **Hanging indent for wrapped bullets.** When a summary wraps, continuation lines align under the bullet text rather than under the gear.
13
+ - **Boundaries stay separate.** Visible thinking or text, still-running (active) calls, and failed calls split groups, so they never get silently merged.
14
+ - **Errors expand in place.** A failed call keeps its own block and shows its full detail.
15
+ - **Ctrl+O restores full blocks.** Expanding tools (`setToolsExpanded(true)`) brings back Pi's individual full blocks, results and all.
16
+ - **Adjacent thinking blocks combine.** Only directly adjacent `thinking` blocks merge into one; a non-thinking block between them keeps them separate. Malformed thinking content safely falls back to Pi's renderer exactly once, so the display never breaks.
17
+
18
+ ## Install
19
+
20
+ After the first npm release:
21
+
22
+ ```bash
23
+ pi install npm:@pi-kaush/pi-tool-call-markers@0.1.0
24
+ ```
25
+
26
+ For local development:
27
+
28
+ ```bash
29
+ pi -e ./extensions/pi-tool-call-markers/src/index.ts
30
+ ```
31
+
32
+ ## Compatibility and risk
33
+
34
+ This extension currently relies on **guarded, reversible prototype patches** against a small number of Pi component classes:
35
+
36
+ - `ToolExecutionComponent` (render + display presentation),
37
+ - `Container` (transcript grouping), and
38
+ - `AssistantMessageComponent` (adjacent thinking merge).
39
+
40
+ Pi exposes no public transcript or tool-grouping hook today, so the extension patches those prototypes on `session_start` and restores the originals on `session_shutdown`. Every patch is wrapped in `try`/`catch` with an idempotency guard (`Symbol.for(...)` markers), so if Pi's internals change the extension silently no-ops and Pi's default rendering is preserved.
41
+
42
+ **Compatible Pi version:** `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` `>=0.80.6`. Because the patches touch internal prototype methods, a future Pi release that renames or restructures those methods can silently disable the grouping until this extension is updated. The extension never broadens the private-API footprint beyond the three classes above, and all original methods are restored on shutdown.
43
+
44
+ > TODO: migrate to a public Pi tool/transcript rendering API when one becomes available, and remove the prototype patches.
45
+
46
+ ## Design
47
+
48
+ - No runtime dependencies.
49
+ - Startup only registers `session_start` / `session_shutdown` handlers and installs the reversible patches; no I/O, subprocesses, model requests, or timers.
50
+ - Grouped output is cached per row and invalidated when any member's display version changes, so repeated renders reuse work while stale groups refresh on demand.
51
+ - Removing the package restores Pi's default rendering on the next session.
52
+
53
+ ## Development
54
+
55
+ From the repository root:
56
+
57
+ ```bash
58
+ npm ci --ignore-scripts
59
+ npm run check
60
+ ```
61
+
62
+ Inspect the publish payload:
63
+
64
+ ```bash
65
+ npm pack --workspace @pi-kaush/pi-tool-call-markers --dry-run
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@pi-kaush/pi-tool-call-markers",
3
+ "version": "0.1.0",
4
+ "description": "Collapse adjacent successful tool calls into one compact, gear-headed block per tool type in Pi's transcript.",
5
+ "license": "MIT",
6
+ "author": "Kaushik Gopal",
7
+ "type": "module",
8
+ "exports": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "keywords": [
11
+ "pi-package",
12
+ "pi-extension",
13
+ "tool-calls",
14
+ "transcript",
15
+ "terminal-ui"
16
+ ],
17
+ "homepage": "https://github.com/kaushikgopal/pi-kaush/tree/main/extensions/pi-tool-call-markers#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/kaushikgopal/pi-kaush/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/kaushikgopal/pi-kaush.git",
24
+ "directory": "extensions/pi-tool-call-markers"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "CHANGELOG.md",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./src/index.ts"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
39
+ "@earendil-works/pi-tui": ">=0.80.6"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@earendil-works/pi-coding-agent": {
43
+ "optional": true
44
+ },
45
+ "@earendil-works/pi-tui": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "scripts": {
56
+ "package:check": "node ./scripts/check-package.mjs"
57
+ },
58
+ "sideEffects": false
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,827 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ AssistantMessageComponent,
4
+ ToolExecutionComponent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ Box,
8
+ Container,
9
+ sliceByColumn,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ wrapTextWithAnsi,
13
+ } from "@earendil-works/pi-tui";
14
+
15
+ const BADGE = " ⚙️";
16
+ const BADGE_WIDTH = visibleWidth(BADGE);
17
+ const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
18
+ const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
19
+ const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
20
+ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
21
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
22
+ const BOLD_ON_RE = /\u001b\[1m/g;
23
+
24
+ type ThemeLike = {
25
+ bold(text: string): string;
26
+ fg(color: string, text: string): string;
27
+ bg(color: string, text: string): string;
28
+ };
29
+
30
+ type ComponentLike = {
31
+ render(width: number): string[];
32
+ invalidate(): void;
33
+ };
34
+
35
+ type ComponentContainer = ComponentLike & {
36
+ children?: unknown[];
37
+ removeChild?(component: unknown): void;
38
+ };
39
+
40
+ type TextComponent = {
41
+ text?: string;
42
+ setText?(text: string): void;
43
+ };
44
+
45
+ type ToolExecutionRow = {
46
+ toolName?: string;
47
+ args?: unknown;
48
+ expanded?: boolean;
49
+ isPartial?: boolean;
50
+ result?: { isError?: boolean };
51
+ contentBox?: ComponentContainer;
52
+ contentText?: TextComponent;
53
+ selfRenderContainer?: ComponentContainer;
54
+ callRendererComponent?: ComponentLike;
55
+ imageComponents?: unknown[];
56
+ imageSpacers?: unknown[];
57
+ hasRendererDefinition?(): boolean;
58
+ getRenderShell?(): "default" | "self";
59
+ getTextOutput?(): string;
60
+ removeChild?(component: unknown): void;
61
+ };
62
+
63
+ type PresentationPatchState = {
64
+ theme?: ThemeLike;
65
+ groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
66
+ rowVersions: WeakMap<ToolExecutionRow, number>;
67
+ originalRender: (width: number) => string[];
68
+ originalUpdateDisplay: () => void;
69
+ patchedRender?: (width: number) => string[];
70
+ patchedUpdateDisplay?: () => void;
71
+ };
72
+
73
+ type GroupingPatchState = {
74
+ presentation: PresentationPatchState;
75
+ originalRender: (width: number) => string[];
76
+ patchedRender?: (width: number) => string[];
77
+ };
78
+
79
+ type GroupRenderCache = {
80
+ lines: string[];
81
+ members: ToolExecutionRow[];
82
+ memberVersions: number[];
83
+ themeSample: string;
84
+ width: number;
85
+ };
86
+
87
+ type AssistantMessageLike = {
88
+ content?: unknown[];
89
+ };
90
+
91
+ type AssistantMessageRow = {
92
+ updateContent(message: AssistantMessageLike): void;
93
+ };
94
+
95
+ type ThinkingGroupingPatchState = {
96
+ originalUpdateContent: (message: AssistantMessageLike) => void;
97
+ patchedUpdateContent?: (message: AssistantMessageLike) => void;
98
+ };
99
+
100
+ type ThinkingContentLike = {
101
+ type: "thinking";
102
+ thinking: string;
103
+ [key: string]: unknown;
104
+ };
105
+
106
+ function stripAnsi(text: string): string {
107
+ return text.replace(ANSI_RE, "");
108
+ }
109
+
110
+ function hasVisibleContent(line: string): boolean {
111
+ return stripAnsi(line).trim().length > 0;
112
+ }
113
+
114
+ function prefixBadge(line: string): string {
115
+ let index = 0;
116
+ while (line[index] === "\x1b" && line[index + 1] === "[") {
117
+ const end = line.indexOf("m", index + 2);
118
+ if (end === -1) break;
119
+ index = end + 1;
120
+ }
121
+ return line.slice(0, index) + BADGE + line.slice(index);
122
+ }
123
+
124
+ function hasGearBadge(line: string): boolean {
125
+ return stripAnsi(line).trimStart().startsWith("⚙️");
126
+ }
127
+
128
+ function boldLeadingToolToken(
129
+ line: string,
130
+ token: string,
131
+ theme: ThemeLike,
132
+ ): string {
133
+ const visible = stripAnsi(line);
134
+ const prefix = visible.match(/^\s*(?:⚙️\s*)?/)?.[0] ?? "";
135
+ if (!visible.startsWith(token, prefix.length)) return line;
136
+
137
+ const start = visibleWidth(prefix);
138
+ const tokenWidth = visibleWidth(token);
139
+ const before = sliceByColumn(line, 0, start);
140
+ const styledToken = sliceByColumn(line, start, tokenWidth);
141
+ const after = sliceByColumn(
142
+ line,
143
+ start + tokenWidth,
144
+ visibleWidth(line),
145
+ ).replace(BOLD_ON_RE, "");
146
+ return before + theme.bold(styledToken) + "\x1b[22m" + after;
147
+ }
148
+
149
+ function decorateHeader(
150
+ row: ToolExecutionRow,
151
+ lines: string[],
152
+ width: number,
153
+ theme?: ThemeLike,
154
+ ): string[] {
155
+ const lineIndex = lines.findIndex(hasVisibleContent);
156
+ if (lineIndex === -1) return lines;
157
+
158
+ const next = [...lines];
159
+ let header = next[lineIndex];
160
+ if (header === undefined) return lines;
161
+ if (!hasGearBadge(header) && width > BADGE_WIDTH) {
162
+ header = truncateToWidth(prefixBadge(header), width, "", false);
163
+ }
164
+
165
+ const token = row.toolName === "bash" ? "$" : row.toolName;
166
+ if (theme && token) header = boldLeadingToolToken(header, token, theme);
167
+ next[lineIndex] = header;
168
+ return next;
169
+ }
170
+
171
+ function removeResultComponent(container?: ComponentContainer): boolean {
172
+ if (
173
+ !container ||
174
+ !Array.isArray(container.children) ||
175
+ typeof container.removeChild !== "function"
176
+ )
177
+ return false;
178
+ for (const child of container.children.slice(1)) container.removeChild(child);
179
+ return true;
180
+ }
181
+
182
+ function collapseGenericResult(row: ToolExecutionRow): boolean {
183
+ const text = row.contentText?.text;
184
+ if (
185
+ typeof text !== "string" ||
186
+ typeof row.contentText?.setText !== "function"
187
+ )
188
+ return false;
189
+
190
+ const output = row.getTextOutput?.();
191
+ if (!output) return true;
192
+ const suffix = `\n${output}`;
193
+ if (!text.endsWith(suffix)) return false;
194
+ row.contentText.setText(text.slice(0, -suffix.length));
195
+ return true;
196
+ }
197
+
198
+ function hideResultImages(row: ToolExecutionRow): void {
199
+ if (typeof row.removeChild !== "function") return;
200
+ for (const image of row.imageComponents ?? []) row.removeChild(image);
201
+ for (const spacer of row.imageSpacers ?? []) row.removeChild(spacer);
202
+ row.imageComponents = [];
203
+ row.imageSpacers = [];
204
+ }
205
+
206
+ function collapseSuccessfulResult(row: ToolExecutionRow): void {
207
+ if (row.expanded !== false || !row.result || row.result.isError) return;
208
+
209
+ const collapsed = row.hasRendererDefinition?.()
210
+ ? removeResultComponent(
211
+ row.getRenderShell?.() === "self"
212
+ ? row.selfRenderContainer
213
+ : row.contentBox,
214
+ )
215
+ : collapseGenericResult(row);
216
+ if (collapsed) hideResultImages(row);
217
+ }
218
+
219
+ function isToolExecutionRow(
220
+ component: unknown,
221
+ ): component is ToolExecutionRow & ComponentLike {
222
+ return component instanceof ToolExecutionComponent;
223
+ }
224
+
225
+ function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
226
+ return (
227
+ row.expanded === false &&
228
+ row.isPartial === false &&
229
+ !!row.result &&
230
+ !row.result.isError
231
+ );
232
+ }
233
+
234
+ function renderComponent(component: unknown, width: number): string[] {
235
+ if (!component || typeof (component as ComponentLike).render !== "function")
236
+ return [];
237
+ return (component as ComponentLike).render(width);
238
+ }
239
+
240
+ function isThinkingContent(content: unknown): content is ThinkingContentLike {
241
+ return (
242
+ !!content &&
243
+ typeof content === "object" &&
244
+ (content as { type?: unknown }).type === "thinking" &&
245
+ typeof (content as { thinking?: unknown }).thinking === "string"
246
+ );
247
+ }
248
+
249
+ function combineAdjacentThinking(
250
+ message: AssistantMessageLike,
251
+ ): AssistantMessageLike {
252
+ if (!Array.isArray(message.content)) return message;
253
+
254
+ // Merge a display-only copy; the original provider blocks and their signatures stay untouched.
255
+ let changed = false;
256
+ const content: unknown[] = [];
257
+ for (const block of message.content) {
258
+ const previous = content.at(-1);
259
+ if (isThinkingContent(previous) && isThinkingContent(block)) {
260
+ content[content.length - 1] = {
261
+ ...previous,
262
+ thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
263
+ };
264
+ changed = true;
265
+ continue;
266
+ }
267
+ content.push(block);
268
+ }
269
+
270
+ return changed ? { ...message, content } : message;
271
+ }
272
+
273
+ function compactArgs(args: unknown): string {
274
+ if (args === undefined || args === null) return "";
275
+ try {
276
+ const text = JSON.stringify(args);
277
+ return text === "{}" ? "" : text;
278
+ } catch {
279
+ return String(args);
280
+ }
281
+ }
282
+
283
+ function removeTrailingExpandHint(text: string): string {
284
+ const plain = stripAnsi(text).trimEnd();
285
+ const hint = plain.match(/\s+\([^)]*to expand\)$/i);
286
+ if (hint?.index === undefined) return text.trimEnd();
287
+ return sliceByColumn(
288
+ text,
289
+ 0,
290
+ visibleWidth(plain.slice(0, hint.index)),
291
+ ).trimEnd();
292
+ }
293
+
294
+ function trimRenderedLine(text: string): string {
295
+ const plain = stripAnsi(text);
296
+ const leading = plain.match(/^\s*/)?.[0] ?? "";
297
+ const trimmed = plain.trim();
298
+ if (!trimmed) return "";
299
+ return sliceByColumn(
300
+ text,
301
+ visibleWidth(leading),
302
+ visibleWidth(trimmed),
303
+ ).trimEnd();
304
+ }
305
+
306
+ function renderedCallSummary(
307
+ row: ToolExecutionRow,
308
+ width: number,
309
+ theme: ThemeLike,
310
+ ): string {
311
+ let component = row.callRendererComponent;
312
+ if (!component && Array.isArray(row.contentBox?.children)) {
313
+ component = row.contentBox.children[0] as ComponentLike | undefined;
314
+ }
315
+
316
+ if (component && typeof component.render === "function") {
317
+ const visibleLines = component
318
+ .render(Math.max(1, width))
319
+ .filter(hasVisibleContent);
320
+ const rendered = visibleLines.slice(0, 3);
321
+ const line = rendered[0];
322
+ if (line) {
323
+ const first = trimRenderedLine(line);
324
+ const plain = stripAnsi(first);
325
+ const match = /^(\s*)(\S+)(\s*)/.exec(plain);
326
+ if (match) {
327
+ const expectedToken = row.toolName === "bash" ? "$" : row.toolName;
328
+ const hasKnownHeading =
329
+ match[2] === expectedToken ||
330
+ (row.toolName === "read" &&
331
+ (match[2] === "read" || match[2] === "[skill]"));
332
+ const summaryStart = hasKnownHeading
333
+ ? visibleWidth((match[1] ?? "") + (match[2] ?? "") + (match[3] ?? ""))
334
+ : 0;
335
+ const firstSummary = removeTrailingExpandHint(
336
+ sliceByColumn(
337
+ first,
338
+ summaryStart,
339
+ Math.max(0, visibleWidth(first) - summaryStart),
340
+ ),
341
+ );
342
+ const continuations = rendered
343
+ .slice(1)
344
+ .map(trimRenderedLine)
345
+ .filter(hasVisibleContent);
346
+ if (visibleLines.length > rendered.length)
347
+ continuations.push(theme.fg("muted", "…"));
348
+ const compact = [firstSummary, ...continuations]
349
+ .filter(hasVisibleContent)
350
+ .join(theme.fg("muted", " · "));
351
+ if (hasVisibleContent(compact)) return compact;
352
+ }
353
+ }
354
+ }
355
+
356
+ const fallback = compactArgs(row.args);
357
+ return fallback
358
+ ? theme.fg("accent", fallback)
359
+ : theme.fg("muted", "(no arguments)");
360
+ }
361
+
362
+ function wrappedBulletLines(
363
+ summary: string,
364
+ width: number,
365
+ theme: ThemeLike,
366
+ ): string[] {
367
+ const prefix = ` ${theme.fg("muted", "•")} `;
368
+ const indent = visibleWidth(prefix);
369
+ if (width <= indent) return [truncateToWidth(prefix, width, "", false)];
370
+
371
+ const wrapped = wrapTextWithAnsi(summary, width - indent);
372
+ return wrapped.map((line, index) => {
373
+ const linePrefix = index === 0 ? prefix : " ".repeat(indent);
374
+ return truncateToWidth(linePrefix + line, width, "", false);
375
+ });
376
+ }
377
+
378
+ function groupedCallComponent(
379
+ rows: ToolExecutionRow[],
380
+ theme: ThemeLike,
381
+ ): ComponentLike {
382
+ return {
383
+ render(width: number): string[] {
384
+ const lines: string[] = [];
385
+ let previousToolName: string | undefined;
386
+ for (const row of rows) {
387
+ if (lines.length === 0 || row.toolName !== previousToolName) {
388
+ if (lines.length > 0) lines.push("");
389
+ const token =
390
+ row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
391
+ const heading = theme.fg(
392
+ "toolTitle",
393
+ `${BADGE} ${theme.bold(token)}`,
394
+ );
395
+ lines.push(truncateToWidth(heading, width, "", false));
396
+ previousToolName = row.toolName;
397
+ }
398
+ const summary = renderedCallSummary(row, Math.max(1, width - 4), theme);
399
+ lines.push(...wrappedBulletLines(summary, width, theme));
400
+ }
401
+ return lines;
402
+ },
403
+ invalidate() {},
404
+ };
405
+ }
406
+
407
+ function renderWithTemporaryChild(
408
+ container: ComponentContainer,
409
+ child: ComponentLike,
410
+ render: () => string[],
411
+ ): string[] {
412
+ const children = container.children;
413
+ if (!Array.isArray(children)) return render();
414
+ container.children = [child];
415
+ try {
416
+ return render();
417
+ } finally {
418
+ container.children = children;
419
+ }
420
+ }
421
+
422
+ function sameMembers(
423
+ left: ToolExecutionRow[],
424
+ right: ToolExecutionRow[],
425
+ ): boolean {
426
+ return (
427
+ left.length === right.length &&
428
+ left.every((member, index) => member === right[index])
429
+ );
430
+ }
431
+
432
+ function sameMemberVersions(
433
+ rows: ToolExecutionRow[],
434
+ versions: number[],
435
+ state: PresentationPatchState,
436
+ ): boolean {
437
+ return (
438
+ rows.length === versions.length &&
439
+ rows.every(
440
+ (row, index) => (state.rowVersions.get(row) ?? 0) === versions[index],
441
+ )
442
+ );
443
+ }
444
+
445
+ function renderGroupedToolRows(
446
+ row: ToolExecutionRow,
447
+ rows: ToolExecutionRow[],
448
+ width: number,
449
+ state: PresentationPatchState,
450
+ ): string[] {
451
+ const theme = state.theme;
452
+ if (!theme)
453
+ return decorateHeader(
454
+ row,
455
+ state.originalRender.call(row, width),
456
+ width,
457
+ theme,
458
+ );
459
+ const themeSample =
460
+ theme.fg("toolTitle", "x") +
461
+ theme.fg("muted", "x") +
462
+ theme.bg("toolSuccessBg", "x");
463
+ const cached = state.groupCache.get(row);
464
+ if (
465
+ cached &&
466
+ cached.width === width &&
467
+ cached.themeSample === themeSample &&
468
+ sameMembers(cached.members, rows) &&
469
+ sameMemberVersions(rows, cached.memberVersions, state)
470
+ ) {
471
+ return cached.lines;
472
+ }
473
+
474
+ const summary = groupedCallComponent(rows, theme);
475
+ let lines: string[];
476
+ if (!row.hasRendererDefinition?.()) {
477
+ const text = row.contentText;
478
+ const previous = text?.text;
479
+ if (typeof previous !== "string" || typeof text?.setText !== "function") {
480
+ return decorateHeader(
481
+ row,
482
+ state.originalRender.call(row, width),
483
+ width,
484
+ theme,
485
+ );
486
+ }
487
+ text.setText(summary.render(Math.max(1, width - 2)).join("\n"));
488
+ try {
489
+ lines = state.originalRender.call(row, width);
490
+ } finally {
491
+ text.setText(previous);
492
+ }
493
+ } else if (row.getRenderShell?.() === "self") {
494
+ const container = row.selfRenderContainer;
495
+ if (!container)
496
+ return decorateHeader(
497
+ row,
498
+ state.originalRender.call(row, width),
499
+ width,
500
+ theme,
501
+ );
502
+ const box = new Box(1, 1, (text) => theme.bg("toolSuccessBg", text));
503
+ box.addChild(summary);
504
+ lines = renderWithTemporaryChild(container, box, () =>
505
+ state.originalRender.call(row, width),
506
+ );
507
+ } else {
508
+ const container = row.contentBox;
509
+ if (!container)
510
+ return decorateHeader(
511
+ row,
512
+ state.originalRender.call(row, width),
513
+ width,
514
+ theme,
515
+ );
516
+ lines = renderWithTemporaryChild(container, summary, () =>
517
+ state.originalRender.call(row, width),
518
+ );
519
+ }
520
+
521
+ const decorated = decorateHeader(row, lines, width, theme);
522
+ state.groupCache.set(row, {
523
+ lines: decorated,
524
+ members: [...rows],
525
+ memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
526
+ themeSample,
527
+ width,
528
+ });
529
+ return decorated;
530
+ }
531
+
532
+ function renderContainerWithToolGroups(
533
+ children: unknown[],
534
+ width: number,
535
+ presentation: PresentationPatchState,
536
+ ): string[] {
537
+ const lines: string[] = [];
538
+ const rendered = new Map<number, string[]>();
539
+ const renderAt = (index: number): string[] => {
540
+ const cached = rendered.get(index);
541
+ if (cached) return cached;
542
+ const next = renderComponent(children[index], width);
543
+ rendered.set(index, next);
544
+ return next;
545
+ };
546
+
547
+ for (let index = 0; index < children.length; index++) {
548
+ const child = children[index];
549
+ if (!isToolExecutionRow(child) || !isCollapsibleSuccess(child)) {
550
+ lines.push(...renderAt(index));
551
+ continue;
552
+ }
553
+
554
+ const group: ToolExecutionRow[] = [child];
555
+ let lastMemberIndex = index;
556
+ for (
557
+ let candidateIndex = index + 1;
558
+ candidateIndex < children.length;
559
+ candidateIndex++
560
+ ) {
561
+ const candidate = children[candidateIndex];
562
+ if (isToolExecutionRow(candidate)) {
563
+ if (!isCollapsibleSuccess(candidate)) break;
564
+ group.push(candidate);
565
+ lastMemberIndex = candidateIndex;
566
+ continue;
567
+ }
568
+ if (renderAt(candidateIndex).some(hasVisibleContent)) break;
569
+ }
570
+
571
+ if (group.length === 1) {
572
+ lines.push(...renderAt(index));
573
+ continue;
574
+ }
575
+
576
+ lines.push(...renderGroupedToolRows(child, group, width, presentation));
577
+ index = lastMemberIndex;
578
+ }
579
+
580
+ return lines;
581
+ }
582
+
583
+ // TODO: Replace prototype patching with a public Pi tool/transcript rendering API when available.
584
+ function installThinkingGroupingPatch():
585
+ | ThinkingGroupingPatchState
586
+ | undefined {
587
+ try {
588
+ const proto =
589
+ AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
590
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
591
+ updateContent?: (message: AssistantMessageLike) => void;
592
+ };
593
+ if (!proto || typeof proto.updateContent !== "function") return undefined;
594
+
595
+ const existing = proto[THINKING_GROUPING_PATCHED];
596
+ if (existing) return existing;
597
+
598
+ const state: ThinkingGroupingPatchState = {
599
+ originalUpdateContent: proto.updateContent,
600
+ };
601
+ const patchedUpdateContent = function updateContentWithCombinedThinking(
602
+ this: AssistantMessageRow,
603
+ message: AssistantMessageLike,
604
+ ): void {
605
+ // Combine adjacent thinking blocks for display, but fall back to the original
606
+ // message if combining throws. Either way, invoke Pi's renderer exactly once.
607
+ let combined = message;
608
+ try {
609
+ combined = combineAdjacentThinking(message);
610
+ } catch {
611
+ // Thinking grouping is cosmetic; preserve the original message intact.
612
+ }
613
+ state.originalUpdateContent.call(this, combined);
614
+ };
615
+
616
+ state.patchedUpdateContent = patchedUpdateContent;
617
+ proto.updateContent = patchedUpdateContent;
618
+ Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
619
+ configurable: true,
620
+ value: state,
621
+ });
622
+ return state;
623
+ } catch {
624
+ // Thinking grouping is cosmetic; preserve Pi's renderer if its internals change.
625
+ return undefined;
626
+ }
627
+ }
628
+
629
+ function uninstallThinkingGroupingPatch(
630
+ state: ThinkingGroupingPatchState | undefined,
631
+ ): void {
632
+ if (!state) return;
633
+ const proto =
634
+ AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
635
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
636
+ updateContent?: (message: AssistantMessageLike) => void;
637
+ };
638
+ if (
639
+ proto[THINKING_GROUPING_PATCHED] !== state ||
640
+ proto.updateContent !== state.patchedUpdateContent
641
+ )
642
+ return;
643
+ proto.updateContent = state.originalUpdateContent;
644
+ delete proto[THINKING_GROUPING_PATCHED];
645
+ }
646
+
647
+ function installGroupingPatch(
648
+ presentation: PresentationPatchState,
649
+ ): GroupingPatchState | undefined {
650
+ try {
651
+ const proto = Container?.prototype as unknown as ComponentContainer & {
652
+ [GROUPING_PATCHED]?: GroupingPatchState;
653
+ render?: (width: number) => string[];
654
+ };
655
+ if (!proto || typeof proto.render !== "function") return undefined;
656
+
657
+ const existing = proto[GROUPING_PATCHED];
658
+ if (existing) {
659
+ existing.presentation = presentation;
660
+ return existing;
661
+ }
662
+
663
+ const state: GroupingPatchState = {
664
+ presentation,
665
+ originalRender: proto.render,
666
+ };
667
+ const patchedRender = function renderWithCollapsedToolGroups(
668
+ this: ComponentContainer,
669
+ width: number,
670
+ ): string[] {
671
+ const children = this.children;
672
+ if (!Array.isArray(children) || !children.some(isToolExecutionRow)) {
673
+ return state.originalRender.call(this, width);
674
+ }
675
+ try {
676
+ return renderContainerWithToolGroups(
677
+ children,
678
+ width,
679
+ state.presentation,
680
+ );
681
+ } catch {
682
+ return state.originalRender.call(this, width);
683
+ }
684
+ };
685
+
686
+ state.patchedRender = patchedRender;
687
+ proto.render = patchedRender;
688
+ Object.defineProperty(proto, GROUPING_PATCHED, {
689
+ configurable: true,
690
+ value: state,
691
+ });
692
+ return state;
693
+ } catch {
694
+ // Grouping is cosmetic; preserve Pi's container renderer if its internals change.
695
+ return undefined;
696
+ }
697
+ }
698
+
699
+ function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
700
+ if (!state) return;
701
+ const proto = Container?.prototype as unknown as ComponentContainer & {
702
+ [GROUPING_PATCHED]?: GroupingPatchState;
703
+ render?: (width: number) => string[];
704
+ };
705
+ if (proto[GROUPING_PATCHED] !== state || proto.render !== state.patchedRender)
706
+ return;
707
+ proto.render = state.originalRender;
708
+ delete proto[GROUPING_PATCHED];
709
+ }
710
+
711
+ function installPresentationPatch(): PresentationPatchState | undefined {
712
+ try {
713
+ const proto =
714
+ ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
715
+ [PRESENTATION_PATCHED]?: PresentationPatchState;
716
+ [LEGACY_PRESENTATION_PATCHED]?: PresentationPatchState;
717
+ render?: (width: number) => string[];
718
+ updateDisplay?: () => void;
719
+ };
720
+ if (!proto) return undefined;
721
+
722
+ const existing = proto[PRESENTATION_PATCHED];
723
+ if (existing) return existing;
724
+ const legacy = proto[LEGACY_PRESENTATION_PATCHED];
725
+ if (legacy) {
726
+ proto.render = legacy.originalRender;
727
+ proto.updateDisplay = legacy.originalUpdateDisplay;
728
+ }
729
+ if (
730
+ typeof proto.render !== "function" ||
731
+ typeof proto.updateDisplay !== "function"
732
+ )
733
+ return undefined;
734
+
735
+ const state: PresentationPatchState = {
736
+ groupCache: new WeakMap(),
737
+ rowVersions: new WeakMap(),
738
+ originalRender: proto.render,
739
+ originalUpdateDisplay: proto.updateDisplay,
740
+ };
741
+ const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
742
+ this: ToolExecutionRow,
743
+ ): void {
744
+ state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
745
+ state.groupCache.delete(this);
746
+ state.originalUpdateDisplay.call(this);
747
+ try {
748
+ if (this.result?.isError && this.expanded === false) {
749
+ this.expanded = true;
750
+ state.originalUpdateDisplay.call(this);
751
+ }
752
+ collapseSuccessfulResult(this);
753
+ } catch {
754
+ // Presentation is cosmetic; preserve Pi's renderer if its internals change.
755
+ }
756
+ };
757
+ const patchedRender = function renderWithToolPresentation(
758
+ this: ToolExecutionRow,
759
+ width: number,
760
+ ): string[] {
761
+ const lines = state.originalRender.call(this, width);
762
+ try {
763
+ return decorateHeader(this, lines, width, state.theme);
764
+ } catch {
765
+ return lines;
766
+ }
767
+ };
768
+
769
+ try {
770
+ state.patchedUpdateDisplay = patchedUpdateDisplay;
771
+ state.patchedRender = patchedRender;
772
+ proto.updateDisplay = patchedUpdateDisplay;
773
+ proto.render = patchedRender;
774
+ Object.defineProperty(proto, PRESENTATION_PATCHED, {
775
+ configurable: true,
776
+ value: state,
777
+ });
778
+ } catch {
779
+ proto.updateDisplay = state.originalUpdateDisplay;
780
+ proto.render = state.originalRender;
781
+ return undefined;
782
+ }
783
+ return state;
784
+ } catch {
785
+ // Pi internals can change across versions; fail silently rather than break the session.
786
+ return undefined;
787
+ }
788
+ }
789
+
790
+ function uninstallPresentationPatch(
791
+ state: PresentationPatchState | undefined,
792
+ ): void {
793
+ if (!state) return;
794
+ const proto =
795
+ ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
796
+ [PRESENTATION_PATCHED]?: PresentationPatchState;
797
+ render?: (width: number) => string[];
798
+ updateDisplay?: () => void;
799
+ };
800
+ if (
801
+ proto[PRESENTATION_PATCHED] !== state ||
802
+ proto.render !== state.patchedRender ||
803
+ proto.updateDisplay !== state.patchedUpdateDisplay
804
+ ) {
805
+ return;
806
+ }
807
+ proto.render = state.originalRender;
808
+ proto.updateDisplay = state.originalUpdateDisplay;
809
+ delete proto[PRESENTATION_PATCHED];
810
+ }
811
+
812
+ export default function (pi: ExtensionAPI) {
813
+ const patch = installPresentationPatch();
814
+ const grouping = patch ? installGroupingPatch(patch) : undefined;
815
+ const thinkingGrouping = installThinkingGroupingPatch();
816
+
817
+ pi.on("session_start", (_event, ctx) => {
818
+ if (patch) patch.theme = ctx.ui.theme;
819
+ ctx.ui.setToolsExpanded(false);
820
+ });
821
+
822
+ pi.on("session_shutdown", () => {
823
+ uninstallThinkingGroupingPatch(thinkingGrouping);
824
+ uninstallGroupingPatch(grouping);
825
+ uninstallPresentationPatch(patch);
826
+ });
827
+ }