@pi-kaush/pi-tool-call-markers 0.1.1 → 0.1.3

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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Keep failed calls collapsed by default while retaining their native error background and Ctrl+O expansion.
6
+ - Add compact result tails for common successful tools in singleton and grouped summaries.
7
+ - Keep grouped bullets to one line and preserve the useful result tail on narrow terminals.
8
+ - Merge settled calls across assistant turns when no visible prose separates them, without reopening earlier groups while a later batch is active.
9
+ - Move adjacent thinking-block merging to a separate extension entrypoint bundled in this package.
10
+
11
+ ## 0.1.2
12
+
13
+ - Keep grouping within one assistant tool batch, so a later pending batch does not reopen completed groups.
14
+ - Wait for every tool in a batch to settle, including tools beyond failed or expanded siblings.
15
+ - Preserve partial and image-bearing results instead of collapsing them.
16
+ - Treat self-rendered tools as owning their full shell and use only their stable header line in grouped summaries.
17
+
3
18
  ## 0.1.1
4
19
 
5
20
  - Wait for every call in a contiguous parallel run to settle before grouping successful calls, which prevents completed prefixes and suffixes from repeatedly collapsing around active calls.
package/README.md CHANGED
@@ -7,13 +7,20 @@ Collapse Pi's adjacent successful tool calls into one compact, gear-headed block
7
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
8
 
9
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).
10
+ - **Bulleted call summaries.** Each call in a group becomes one bullet with a short summary and, for common tools, a compact outcome such as `→ done`, `→ 42 lines`, or `→ +2/-1`.
11
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
- - **Parallel runs settle before grouping.** A contiguous run stays ungrouped while any call is active. After every call settles, successful calls group around visible thinking or text and failed-call boundaries.
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.
12
+ - **One-line, width-safe summaries.** Long targets truncate before their useful outcome tail instead of wrapping into taller blocks.
13
+ - **Tool batches settle before grouping.** A tool batch stays ungrouped while any sibling call is active. Earlier groups remain stable while a later batch runs, then visually adjacent settled calls merge across assistant turns that rendered no prose.
14
+ - **Self-rendered tools keep their shell.** Tools that own their framing keep singleton previews intact. Grouped summaries use only their stable header instead of scraping preview or diff lines.
15
+ - **Partial and image results stay visible.** Streaming progress and image-bearing results are not collapsed into text-only groups.
16
+ - **Errors stay compact and visibly failed.** A failed call keeps its own error-colored block and native collapsed detail until expanded.
17
+ - **Ctrl+O restores full blocks.** Expanding tools (`setToolsExpanded(true)`) brings back Pi's individual full blocks, including complete error details and successful results.
18
+
19
+ ## Bundled thinking-block extension
20
+
21
+ The package ships a second extension entrypoint, `src/thinking-block-merger.ts`. Pi loads it independently from the tool presentation extension, so its `AssistantMessageComponent` patch and shutdown lifecycle stay isolated while install, update, and removal remain one package operation.
22
+
23
+ It combines only directly adjacent `thinking` blocks for display. Tool calls, text, and other content remain boundaries; provider blocks and signatures are not modified.
17
24
 
18
25
  ## Install
19
26
 
@@ -26,20 +33,23 @@ pi install npm:@pi-kaush/pi-tool-call-markers@0.1.0
26
33
  For local development:
27
34
 
28
35
  ```bash
29
- pi -e ./extensions/pi-tool-call-markers/src/index.ts
36
+ pi \
37
+ -e ./extensions/pi-tool-call-markers/src/index.ts \
38
+ -e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
30
39
  ```
31
40
 
32
41
  ## Compatibility and risk
33
42
 
34
- This extension currently relies on **guarded, reversible prototype patches** against a small number of Pi component classes:
43
+ The tool presentation entrypoint currently relies on **guarded, reversible prototype patches** against two Pi component classes:
35
44
 
36
45
  - `ToolExecutionComponent` (render + display presentation),
37
- - `Container` (transcript grouping), and
38
- - `AssistantMessageComponent` (adjacent thinking merge).
46
+ - `Container` (transcript grouping).
47
+
48
+ The bundled thinking-block entrypoint separately patches `AssistantMessageComponent.updateContent`. Its patch and lifecycle do not share state with tool presentation.
39
49
 
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.
50
+ Pi exposes no public transcript or tool-grouping hook today, so the extension patches those prototypes 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
51
 
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.
52
+ **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 affected presentation until this package is updated. All original methods are restored on shutdown.
43
53
 
44
54
  > TODO: migrate to a public Pi tool/transcript rendering API when one becomes available, and remove the prototype patches.
45
55
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.1.1",
4
- "description": "Collapse adjacent successful tool calls into one compact, gear-headed block per tool type in Pi's transcript.",
3
+ "version": "0.1.3",
4
+ "description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
7
7
  "type": "module",
@@ -31,7 +31,8 @@
31
31
  ],
32
32
  "pi": {
33
33
  "extensions": [
34
- "./src/index.ts"
34
+ "./src/index.ts",
35
+ "./src/thinking-block-merger.ts"
35
36
  ]
36
37
  },
37
38
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  sliceByColumn,
10
10
  truncateToWidth,
11
11
  visibleWidth,
12
- wrapTextWithAnsi,
13
12
  } from "@earendil-works/pi-tui";
14
13
 
15
14
  const BADGE = " ⚙️";
@@ -17,7 +16,6 @@ const BADGE_WIDTH = visibleWidth(BADGE);
17
16
  const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
18
17
  const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
19
18
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
20
- const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
21
19
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
22
20
  const BOLD_ON_RE = /\u001b\[1m/g;
23
21
 
@@ -47,7 +45,11 @@ type ToolExecutionRow = {
47
45
  args?: unknown;
48
46
  expanded?: boolean;
49
47
  isPartial?: boolean;
50
- result?: { isError?: boolean };
48
+ result?: {
49
+ isError?: boolean;
50
+ content?: Array<{ type?: unknown; text?: unknown }>;
51
+ details?: Record<string, unknown>;
52
+ };
51
53
  contentBox?: ComponentContainer;
52
54
  contentText?: TextComponent;
53
55
  selfRenderContainer?: ComponentContainer;
@@ -84,25 +86,6 @@ type GroupRenderCache = {
84
86
  width: number;
85
87
  };
86
88
 
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
89
  function stripAnsi(text: string): string {
107
90
  return text.replace(ANSI_RE, "");
108
91
  }
@@ -203,17 +186,27 @@ function hideResultImages(row: ToolExecutionRow): void {
203
186
  row.imageSpacers = [];
204
187
  }
205
188
 
206
- function collapseSuccessfulResult(row: ToolExecutionRow): void {
207
- if (row.expanded !== false || !row.result || row.result.isError) return;
189
+ function collapseSuccessfulResult(
190
+ row: ToolExecutionRow,
191
+ theme?: ThemeLike,
192
+ ): void {
193
+ if (
194
+ row.expanded !== false ||
195
+ row.isPartial !== false ||
196
+ !row.result ||
197
+ row.result.isError ||
198
+ hasImageResult(row) ||
199
+ row.getRenderShell?.() === "self"
200
+ )
201
+ return;
208
202
 
209
203
  const collapsed = row.hasRendererDefinition?.()
210
- ? removeResultComponent(
211
- row.getRenderShell?.() === "self"
212
- ? row.selfRenderContainer
213
- : row.contentBox,
214
- )
204
+ ? removeResultComponent(row.contentBox)
215
205
  : collapseGenericResult(row);
216
- if (collapsed) hideResultImages(row);
206
+ if (collapsed) {
207
+ hideResultImages(row);
208
+ decorateSuccessfulCall(row, theme);
209
+ }
217
210
  }
218
211
 
219
212
  function isToolExecutionRow(
@@ -222,12 +215,25 @@ function isToolExecutionRow(
222
215
  return component instanceof ToolExecutionComponent;
223
216
  }
224
217
 
218
+ function isAssistantMessageRow(component: unknown): boolean {
219
+ return component instanceof AssistantMessageComponent;
220
+ }
221
+
222
+ function hasImageResult(row: ToolExecutionRow): boolean {
223
+ return (
224
+ row.result?.content?.some((content) => content.type === "image") === true ||
225
+ (row.imageComponents?.length ?? 0) > 0 ||
226
+ (row.imageSpacers?.length ?? 0) > 0
227
+ );
228
+ }
229
+
225
230
  function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
226
231
  return (
227
232
  row.expanded === false &&
228
233
  row.isPartial === false &&
229
234
  !!row.result &&
230
- !row.result.isError
235
+ !row.result.isError &&
236
+ !hasImageResult(row)
231
237
  );
232
238
  }
233
239
 
@@ -235,7 +241,7 @@ function isSettledToolRow(row: ToolExecutionRow): boolean {
235
241
  return row.isPartial === false && !!row.result;
236
242
  }
237
243
 
238
- function hasUnsettledToolInGroupRun(
244
+ function hasUnsettledToolInBatch(
239
245
  children: unknown[],
240
246
  index: number,
241
247
  renderAt: (index: number) => string[],
@@ -247,9 +253,9 @@ function hasUnsettledToolInGroupRun(
247
253
  candidateIndex += direction
248
254
  ) {
249
255
  const candidate = children[candidateIndex];
256
+ if (isAssistantMessageRow(candidate)) break;
250
257
  if (isToolExecutionRow(candidate)) {
251
258
  if (!isSettledToolRow(candidate)) return true;
252
- if (!isCollapsibleSuccess(candidate)) break;
253
259
  continue;
254
260
  }
255
261
  if (renderAt(candidateIndex).some(hasVisibleContent)) break;
@@ -264,39 +270,6 @@ function renderComponent(component: unknown, width: number): string[] {
264
270
  return (component as ComponentLike).render(width);
265
271
  }
266
272
 
267
- function isThinkingContent(content: unknown): content is ThinkingContentLike {
268
- return (
269
- !!content &&
270
- typeof content === "object" &&
271
- (content as { type?: unknown }).type === "thinking" &&
272
- typeof (content as { thinking?: unknown }).thinking === "string"
273
- );
274
- }
275
-
276
- function combineAdjacentThinking(
277
- message: AssistantMessageLike,
278
- ): AssistantMessageLike {
279
- if (!Array.isArray(message.content)) return message;
280
-
281
- // Merge a display-only copy; the original provider blocks and their signatures stay untouched.
282
- let changed = false;
283
- const content: unknown[] = [];
284
- for (const block of message.content) {
285
- const previous = content.at(-1);
286
- if (isThinkingContent(previous) && isThinkingContent(block)) {
287
- content[content.length - 1] = {
288
- ...previous,
289
- thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
290
- };
291
- changed = true;
292
- continue;
293
- }
294
- content.push(block);
295
- }
296
-
297
- return changed ? { ...message, content } : message;
298
- }
299
-
300
273
  function compactArgs(args: unknown): string {
301
274
  if (args === undefined || args === null) return "";
302
275
  try {
@@ -307,6 +280,169 @@ function compactArgs(args: unknown): string {
307
280
  }
308
281
  }
309
282
 
283
+ function resultText(row: ToolExecutionRow): string {
284
+ const contentText = row.result?.content?.find(
285
+ (content) => content.type === "text" && typeof content.text === "string",
286
+ )?.text;
287
+ if (typeof contentText === "string") return contentText;
288
+ return row.getTextOutput?.() ?? "";
289
+ }
290
+
291
+ function textLineCount(text: string): number {
292
+ if (!text) return 0;
293
+ const lines = text.split("\n");
294
+ return text.endsWith("\n") ? lines.length - 1 : lines.length;
295
+ }
296
+
297
+ function resultCount(row: ToolExecutionRow, text: string): number {
298
+ const totalMatched = row.result?.details?.totalMatched;
299
+ if (typeof totalMatched === "number" && Number.isFinite(totalMatched))
300
+ return Math.max(0, totalMatched);
301
+
302
+ const trimmed = text.trim();
303
+ if (
304
+ !trimmed ||
305
+ /^(?:No matches found|No files found|\(empty directory\))/i.test(trimmed)
306
+ )
307
+ return 0;
308
+
309
+ if (row.toolName === "grep" || row.toolName === "ffgrep") {
310
+ const lines = trimmed.split("\n");
311
+ const matches = lines.filter(
312
+ (line) => /^.+:\d+:/.test(line) || /^\s+\d+:/.test(line),
313
+ ).length;
314
+ if (matches > 0) return matches;
315
+ }
316
+
317
+ return trimmed
318
+ .split("\n")
319
+ .filter((line) => line.trim() && !/^\s*\[.*\]\s*$/.test(line)).length;
320
+ }
321
+
322
+ function readLineCount(row: ToolExecutionRow, text: string): number {
323
+ const outputLines = (
324
+ row.result?.details?.truncation as { outputLines?: unknown } | undefined
325
+ )?.outputLines;
326
+ if (typeof outputLines === "number" && Number.isFinite(outputLines))
327
+ return Math.max(0, outputLines);
328
+
329
+ const content = text.replace(
330
+ /\n\n\[[^\n]*(?:more lines|showing lines)[^\n]*\]\s*$/i,
331
+ "",
332
+ );
333
+ return textLineCount(content);
334
+ }
335
+
336
+ function diffCounts(diff: string): { added: number; removed: number } {
337
+ let added = 0;
338
+ let removed = 0;
339
+ for (const line of diff.split("\n")) {
340
+ if (line.startsWith("+") && !line.startsWith("+++")) added++;
341
+ if (line.startsWith("-") && !line.startsWith("---")) removed++;
342
+ }
343
+ return { added, removed };
344
+ }
345
+
346
+ function outcomeSummary(row: ToolExecutionRow): string | undefined {
347
+ if (!isCollapsibleSuccess(row)) return undefined;
348
+
349
+ const text = resultText(row);
350
+ switch (row.toolName) {
351
+ case "bash":
352
+ return "done";
353
+ case "read": {
354
+ const lines = readLineCount(row, text);
355
+ return `${lines} ${lines === 1 ? "line" : "lines"}`;
356
+ }
357
+ case "write": {
358
+ const content = (row.args as { content?: unknown } | undefined)?.content;
359
+ if (typeof content !== "string") return "written";
360
+ const lines = textLineCount(content);
361
+ return `${lines} ${lines === 1 ? "line" : "lines"}`;
362
+ }
363
+ case "edit": {
364
+ const diff = row.result?.details?.diff;
365
+ if (typeof diff !== "string") return "applied";
366
+ const { added, removed } = diffCounts(diff);
367
+ return `+${added}/-${removed}`;
368
+ }
369
+ case "grep":
370
+ case "ffgrep":
371
+ case "find":
372
+ case "fffind":
373
+ case "ls": {
374
+ const results = resultCount(row, text);
375
+ return `${results} ${results === 1 ? "result" : "results"}`;
376
+ }
377
+ default:
378
+ return undefined;
379
+ }
380
+ }
381
+
382
+ function renderedOutcome(
383
+ row: ToolExecutionRow,
384
+ theme: ThemeLike,
385
+ ): string | undefined {
386
+ const summary = outcomeSummary(row);
387
+ if (!summary) return undefined;
388
+ return `${theme.fg("muted", "→")} ${theme.fg("success", summary)}`;
389
+ }
390
+
391
+ function truncatePlain(text: string, width: number, suffix = "…"): string {
392
+ const max = Math.max(0, width);
393
+ const plain = stripAnsi(text);
394
+ if (visibleWidth(plain) <= max) return plain;
395
+
396
+ const suffixWidth = visibleWidth(suffix);
397
+ if (suffixWidth >= max) return sliceByColumn(suffix, 0, max, true);
398
+ return `${sliceByColumn(plain, 0, max - suffixWidth, true)}${suffix}`;
399
+ }
400
+
401
+ function fitSummary(summary: string, width: number): string {
402
+ const max = Math.max(1, width);
403
+ return visibleWidth(summary) <= max ? summary : truncatePlain(summary, max);
404
+ }
405
+
406
+ function fitSummaryTail(head: string, tail: string, width: number): string {
407
+ const max = Math.max(1, width);
408
+ const summary = `${head.trimEnd()} ${tail}`;
409
+ if (visibleWidth(summary) <= max) return summary;
410
+
411
+ const tailWidth = visibleWidth(tail);
412
+ if (tailWidth >= max) return truncatePlain(tail, max);
413
+ return `${truncatePlain(head, max - tailWidth - 1)} ${tail}`;
414
+ }
415
+
416
+ function decorateSuccessfulCall(
417
+ row: ToolExecutionRow,
418
+ theme?: ThemeLike,
419
+ ): void {
420
+ if (!theme || !Array.isArray(row.contentBox?.children)) return;
421
+ const original = row.contentBox.children[0] as ComponentLike | undefined;
422
+ const outcome = renderedOutcome(row, theme);
423
+ if (!original || typeof original.render !== "function" || !outcome) return;
424
+
425
+ row.contentBox.children[0] = {
426
+ render(width: number): string[] {
427
+ const lines = original.render(width);
428
+ const lineIndex = lines.findIndex(hasVisibleContent);
429
+ if (lineIndex === -1) return lines;
430
+ const line = lines[lineIndex];
431
+ if (line === undefined) return lines;
432
+ const next = [...lines];
433
+ next[lineIndex] = fitSummaryTail(
434
+ trimRenderedLine(line),
435
+ outcome,
436
+ Math.max(1, width - BADGE_WIDTH),
437
+ );
438
+ return next;
439
+ },
440
+ invalidate() {
441
+ original.invalidate();
442
+ },
443
+ };
444
+ }
445
+
310
446
  function removeTrailingExpandHint(text: string): string {
311
447
  const plain = stripAnsi(text).trimEnd();
312
448
  const hint = plain.match(/\s+\([^)]*to expand\)$/i);
@@ -340,11 +476,12 @@ function renderedCallSummary(
340
476
  component = row.contentBox.children[0] as ComponentLike | undefined;
341
477
  }
342
478
 
479
+ const selfRendered = row.getRenderShell?.() === "self";
343
480
  if (component && typeof component.render === "function") {
344
481
  const visibleLines = component
345
482
  .render(Math.max(1, width))
346
483
  .filter(hasVisibleContent);
347
- const rendered = visibleLines.slice(0, 3);
484
+ const rendered = visibleLines.slice(0, selfRendered ? 1 : 3);
348
485
  const line = rendered[0];
349
486
  if (line) {
350
487
  const first = trimRenderedLine(line);
@@ -380,26 +517,30 @@ function renderedCallSummary(
380
517
  }
381
518
  }
382
519
 
520
+ if (selfRendered) return theme.fg("muted", "(details omitted)");
521
+
383
522
  const fallback = compactArgs(row.args);
384
523
  return fallback
385
524
  ? theme.fg("accent", fallback)
386
525
  : theme.fg("muted", "(no arguments)");
387
526
  }
388
527
 
389
- function wrappedBulletLines(
528
+ function compactBulletLine(
390
529
  summary: string,
530
+ outcome: string | undefined,
391
531
  width: number,
392
532
  theme: ThemeLike,
393
- ): string[] {
533
+ ): string {
394
534
  const prefix = ` ${theme.fg("muted", "•")} `;
395
535
  const indent = visibleWidth(prefix);
396
- if (width <= indent) return [truncateToWidth(prefix, width, "", false)];
397
-
398
- const wrapped = wrapTextWithAnsi(summary, width - indent);
399
- return wrapped.map((line, index) => {
400
- const linePrefix = index === 0 ? prefix : " ".repeat(indent);
401
- return truncateToWidth(linePrefix + line, width, "", false);
402
- });
536
+ if (width <= indent) return truncateToWidth(prefix, width, "", false);
537
+ const available = width - indent;
538
+ return (
539
+ prefix +
540
+ (outcome
541
+ ? fitSummaryTail(summary, outcome, available)
542
+ : fitSummary(summary, available))
543
+ );
403
544
  }
404
545
 
405
546
  function groupedCallComponent(
@@ -422,8 +563,9 @@ function groupedCallComponent(
422
563
  lines.push(truncateToWidth(heading, width, "", false));
423
564
  previousToolName = row.toolName;
424
565
  }
425
- const summary = renderedCallSummary(row, Math.max(1, width - 4), theme);
426
- lines.push(...wrappedBulletLines(summary, width, theme));
566
+ const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
567
+ const outcome = renderedOutcome(row, theme);
568
+ lines.push(compactBulletLine(call, outcome, width, theme));
427
569
  }
428
570
  return lines;
429
571
  },
@@ -577,7 +719,7 @@ function renderContainerWithToolGroups(
577
719
  lines.push(...renderAt(index));
578
720
  continue;
579
721
  }
580
- if (hasUnsettledToolInGroupRun(children, index, renderAt)) {
722
+ if (hasUnsettledToolInBatch(children, index, renderAt)) {
581
723
  lines.push(...renderAt(index));
582
724
  continue;
583
725
  }
@@ -590,6 +732,10 @@ function renderContainerWithToolGroups(
590
732
  candidateIndex++
591
733
  ) {
592
734
  const candidate = children[candidateIndex];
735
+ if (isAssistantMessageRow(candidate)) {
736
+ if (renderAt(candidateIndex).some(hasVisibleContent)) break;
737
+ continue;
738
+ }
593
739
  if (isToolExecutionRow(candidate)) {
594
740
  if (!isCollapsibleSuccess(candidate)) break;
595
741
  group.push(candidate);
@@ -612,69 +758,6 @@ function renderContainerWithToolGroups(
612
758
  }
613
759
 
614
760
  // TODO: Replace prototype patching with a public Pi tool/transcript rendering API when available.
615
- function installThinkingGroupingPatch():
616
- | ThinkingGroupingPatchState
617
- | undefined {
618
- try {
619
- const proto =
620
- AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
621
- [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
622
- updateContent?: (message: AssistantMessageLike) => void;
623
- };
624
- if (!proto || typeof proto.updateContent !== "function") return undefined;
625
-
626
- const existing = proto[THINKING_GROUPING_PATCHED];
627
- if (existing) return existing;
628
-
629
- const state: ThinkingGroupingPatchState = {
630
- originalUpdateContent: proto.updateContent,
631
- };
632
- const patchedUpdateContent = function updateContentWithCombinedThinking(
633
- this: AssistantMessageRow,
634
- message: AssistantMessageLike,
635
- ): void {
636
- // Combine adjacent thinking blocks for display, but fall back to the original
637
- // message if combining throws. Either way, invoke Pi's renderer exactly once.
638
- let combined = message;
639
- try {
640
- combined = combineAdjacentThinking(message);
641
- } catch {
642
- // Thinking grouping is cosmetic; preserve the original message intact.
643
- }
644
- state.originalUpdateContent.call(this, combined);
645
- };
646
-
647
- state.patchedUpdateContent = patchedUpdateContent;
648
- proto.updateContent = patchedUpdateContent;
649
- Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
650
- configurable: true,
651
- value: state,
652
- });
653
- return state;
654
- } catch {
655
- // Thinking grouping is cosmetic; preserve Pi's renderer if its internals change.
656
- return undefined;
657
- }
658
- }
659
-
660
- function uninstallThinkingGroupingPatch(
661
- state: ThinkingGroupingPatchState | undefined,
662
- ): void {
663
- if (!state) return;
664
- const proto =
665
- AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
666
- [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
667
- updateContent?: (message: AssistantMessageLike) => void;
668
- };
669
- if (
670
- proto[THINKING_GROUPING_PATCHED] !== state ||
671
- proto.updateContent !== state.patchedUpdateContent
672
- )
673
- return;
674
- proto.updateContent = state.originalUpdateContent;
675
- delete proto[THINKING_GROUPING_PATCHED];
676
- }
677
-
678
761
  function installGroupingPatch(
679
762
  presentation: PresentationPatchState,
680
763
  ): GroupingPatchState | undefined {
@@ -776,11 +859,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
776
859
  state.groupCache.delete(this);
777
860
  state.originalUpdateDisplay.call(this);
778
861
  try {
779
- if (this.result?.isError && this.expanded === false) {
780
- this.expanded = true;
781
- state.originalUpdateDisplay.call(this);
782
- }
783
- collapseSuccessfulResult(this);
862
+ collapseSuccessfulResult(this, state.theme);
784
863
  } catch {
785
864
  // Presentation is cosmetic; preserve Pi's renderer if its internals change.
786
865
  }
@@ -843,7 +922,6 @@ function uninstallPresentationPatch(
843
922
  export default function (pi: ExtensionAPI) {
844
923
  const patch = installPresentationPatch();
845
924
  const grouping = patch ? installGroupingPatch(patch) : undefined;
846
- const thinkingGrouping = installThinkingGroupingPatch();
847
925
 
848
926
  pi.on("session_start", (_event, ctx) => {
849
927
  if (patch) patch.theme = ctx.ui.theme;
@@ -851,7 +929,6 @@ export default function (pi: ExtensionAPI) {
851
929
  });
852
930
 
853
931
  pi.on("session_shutdown", () => {
854
- uninstallThinkingGroupingPatch(thinkingGrouping);
855
932
  uninstallGroupingPatch(grouping);
856
933
  uninstallPresentationPatch(patch);
857
934
  });
@@ -0,0 +1,123 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
3
+
4
+ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
5
+
6
+ type AssistantMessageLike = {
7
+ content?: unknown[];
8
+ };
9
+
10
+ type AssistantMessageRow = {
11
+ updateContent(message: AssistantMessageLike): void;
12
+ };
13
+
14
+ type ThinkingGroupingPatchState = {
15
+ originalUpdateContent: (message: AssistantMessageLike) => void;
16
+ patchedUpdateContent?: (message: AssistantMessageLike) => void;
17
+ };
18
+
19
+ type ThinkingContentLike = {
20
+ type: "thinking";
21
+ thinking: string;
22
+ [key: string]: unknown;
23
+ };
24
+
25
+ function isThinkingContent(content: unknown): content is ThinkingContentLike {
26
+ return (
27
+ !!content &&
28
+ typeof content === "object" &&
29
+ (content as { type?: unknown }).type === "thinking" &&
30
+ typeof (content as { thinking?: unknown }).thinking === "string"
31
+ );
32
+ }
33
+
34
+ function combineAdjacentThinking(
35
+ message: AssistantMessageLike,
36
+ ): AssistantMessageLike {
37
+ if (!Array.isArray(message.content)) return message;
38
+
39
+ // Merge a display-only copy; provider blocks and signatures stay untouched.
40
+ let changed = false;
41
+ const content: unknown[] = [];
42
+ for (const block of message.content) {
43
+ const previous = content.at(-1);
44
+ if (isThinkingContent(previous) && isThinkingContent(block)) {
45
+ content[content.length - 1] = {
46
+ ...previous,
47
+ thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
48
+ };
49
+ changed = true;
50
+ continue;
51
+ }
52
+ content.push(block);
53
+ }
54
+
55
+ return changed ? { ...message, content } : message;
56
+ }
57
+
58
+ // TODO: Replace prototype patching with a public assistant-message rendering API.
59
+ function installThinkingGroupingPatch():
60
+ | ThinkingGroupingPatchState
61
+ | undefined {
62
+ try {
63
+ const proto =
64
+ AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
65
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
66
+ updateContent?: (message: AssistantMessageLike) => void;
67
+ };
68
+ if (!proto || typeof proto.updateContent !== "function") return undefined;
69
+
70
+ const existing = proto[THINKING_GROUPING_PATCHED];
71
+ if (existing) return existing;
72
+
73
+ const state: ThinkingGroupingPatchState = {
74
+ originalUpdateContent: proto.updateContent,
75
+ };
76
+ const patchedUpdateContent = function updateContentWithCombinedThinking(
77
+ this: AssistantMessageRow,
78
+ message: AssistantMessageLike,
79
+ ): void {
80
+ // Thinking grouping is cosmetic. If it fails, render the original once.
81
+ let combined = message;
82
+ try {
83
+ combined = combineAdjacentThinking(message);
84
+ } catch {
85
+ // Preserve the original message intact.
86
+ }
87
+ state.originalUpdateContent.call(this, combined);
88
+ };
89
+
90
+ state.patchedUpdateContent = patchedUpdateContent;
91
+ proto.updateContent = patchedUpdateContent;
92
+ Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
93
+ configurable: true,
94
+ value: state,
95
+ });
96
+ return state;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ function uninstallThinkingGroupingPatch(
103
+ state: ThinkingGroupingPatchState | undefined,
104
+ ): void {
105
+ if (!state) return;
106
+ const proto =
107
+ AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
108
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
109
+ updateContent?: (message: AssistantMessageLike) => void;
110
+ };
111
+ if (
112
+ proto[THINKING_GROUPING_PATCHED] !== state ||
113
+ proto.updateContent !== state.patchedUpdateContent
114
+ )
115
+ return;
116
+ proto.updateContent = state.originalUpdateContent;
117
+ delete proto[THINKING_GROUPING_PATCHED];
118
+ }
119
+
120
+ export default function (pi: ExtensionAPI) {
121
+ const patch = installThinkingGroupingPatch();
122
+ pi.on("session_shutdown", () => uninstallThinkingGroupingPatch(patch));
123
+ }