@pi-kaush/pi-tool-call-markers 0.1.2 → 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,13 @@
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
+
3
11
  ## 0.1.2
4
12
 
5
13
  - Keep grouping within one assistant tool batch, so a later pending batch does not reopen completed groups.
package/README.md CHANGED
@@ -7,15 +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
- - **Tool batches settle before grouping.** A tool batch stays ungrouped while any sibling call is active. Completed batches remain stable when a later assistant turn starts, and successful calls group around failed-call boundaries after the batch settles.
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
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
15
  - **Partial and image results stay visible.** Streaming progress and image-bearing results are not collapsed into text-only groups.
16
- - **Errors expand in place.** A failed call keeps its own block and shows its full detail.
17
- - **Ctrl+O restores full blocks.** Expanding tools (`setToolsExpanded(true)`) brings back Pi's individual full blocks, results and all.
18
- - **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.
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.
19
24
 
20
25
  ## Install
21
26
 
@@ -28,20 +33,23 @@ pi install npm:@pi-kaush/pi-tool-call-markers@0.1.0
28
33
  For local development:
29
34
 
30
35
  ```bash
31
- 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
32
39
  ```
33
40
 
34
41
  ## Compatibility and risk
35
42
 
36
- 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:
37
44
 
38
45
  - `ToolExecutionComponent` (render + display presentation),
39
- - `Container` (transcript grouping), and
40
- - `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.
41
49
 
42
- 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.
43
51
 
44
- **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.
45
53
 
46
54
  > TODO: migrate to a public Pi tool/transcript rendering API when one becomes available, and remove the prototype patches.
47
55
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.1.2",
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
 
@@ -49,7 +47,8 @@ type ToolExecutionRow = {
49
47
  isPartial?: boolean;
50
48
  result?: {
51
49
  isError?: boolean;
52
- content?: Array<{ type?: unknown }>;
50
+ content?: Array<{ type?: unknown; text?: unknown }>;
51
+ details?: Record<string, unknown>;
53
52
  };
54
53
  contentBox?: ComponentContainer;
55
54
  contentText?: TextComponent;
@@ -87,25 +86,6 @@ type GroupRenderCache = {
87
86
  width: number;
88
87
  };
89
88
 
90
- type AssistantMessageLike = {
91
- content?: unknown[];
92
- };
93
-
94
- type AssistantMessageRow = {
95
- updateContent(message: AssistantMessageLike): void;
96
- };
97
-
98
- type ThinkingGroupingPatchState = {
99
- originalUpdateContent: (message: AssistantMessageLike) => void;
100
- patchedUpdateContent?: (message: AssistantMessageLike) => void;
101
- };
102
-
103
- type ThinkingContentLike = {
104
- type: "thinking";
105
- thinking: string;
106
- [key: string]: unknown;
107
- };
108
-
109
89
  function stripAnsi(text: string): string {
110
90
  return text.replace(ANSI_RE, "");
111
91
  }
@@ -206,7 +186,10 @@ function hideResultImages(row: ToolExecutionRow): void {
206
186
  row.imageSpacers = [];
207
187
  }
208
188
 
209
- function collapseSuccessfulResult(row: ToolExecutionRow): void {
189
+ function collapseSuccessfulResult(
190
+ row: ToolExecutionRow,
191
+ theme?: ThemeLike,
192
+ ): void {
210
193
  if (
211
194
  row.expanded !== false ||
212
195
  row.isPartial !== false ||
@@ -220,7 +203,10 @@ function collapseSuccessfulResult(row: ToolExecutionRow): void {
220
203
  const collapsed = row.hasRendererDefinition?.()
221
204
  ? removeResultComponent(row.contentBox)
222
205
  : collapseGenericResult(row);
223
- if (collapsed) hideResultImages(row);
206
+ if (collapsed) {
207
+ hideResultImages(row);
208
+ decorateSuccessfulCall(row, theme);
209
+ }
224
210
  }
225
211
 
226
212
  function isToolExecutionRow(
@@ -284,39 +270,6 @@ function renderComponent(component: unknown, width: number): string[] {
284
270
  return (component as ComponentLike).render(width);
285
271
  }
286
272
 
287
- function isThinkingContent(content: unknown): content is ThinkingContentLike {
288
- return (
289
- !!content &&
290
- typeof content === "object" &&
291
- (content as { type?: unknown }).type === "thinking" &&
292
- typeof (content as { thinking?: unknown }).thinking === "string"
293
- );
294
- }
295
-
296
- function combineAdjacentThinking(
297
- message: AssistantMessageLike,
298
- ): AssistantMessageLike {
299
- if (!Array.isArray(message.content)) return message;
300
-
301
- // Merge a display-only copy; the original provider blocks and their signatures stay untouched.
302
- let changed = false;
303
- const content: unknown[] = [];
304
- for (const block of message.content) {
305
- const previous = content.at(-1);
306
- if (isThinkingContent(previous) && isThinkingContent(block)) {
307
- content[content.length - 1] = {
308
- ...previous,
309
- thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
310
- };
311
- changed = true;
312
- continue;
313
- }
314
- content.push(block);
315
- }
316
-
317
- return changed ? { ...message, content } : message;
318
- }
319
-
320
273
  function compactArgs(args: unknown): string {
321
274
  if (args === undefined || args === null) return "";
322
275
  try {
@@ -327,6 +280,169 @@ function compactArgs(args: unknown): string {
327
280
  }
328
281
  }
329
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
+
330
446
  function removeTrailingExpandHint(text: string): string {
331
447
  const plain = stripAnsi(text).trimEnd();
332
448
  const hint = plain.match(/\s+\([^)]*to expand\)$/i);
@@ -409,20 +525,22 @@ function renderedCallSummary(
409
525
  : theme.fg("muted", "(no arguments)");
410
526
  }
411
527
 
412
- function wrappedBulletLines(
528
+ function compactBulletLine(
413
529
  summary: string,
530
+ outcome: string | undefined,
414
531
  width: number,
415
532
  theme: ThemeLike,
416
- ): string[] {
533
+ ): string {
417
534
  const prefix = ` ${theme.fg("muted", "•")} `;
418
535
  const indent = visibleWidth(prefix);
419
- if (width <= indent) return [truncateToWidth(prefix, width, "", false)];
420
-
421
- const wrapped = wrapTextWithAnsi(summary, width - indent);
422
- return wrapped.map((line, index) => {
423
- const linePrefix = index === 0 ? prefix : " ".repeat(indent);
424
- return truncateToWidth(linePrefix + line, width, "", false);
425
- });
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
+ );
426
544
  }
427
545
 
428
546
  function groupedCallComponent(
@@ -445,8 +563,9 @@ function groupedCallComponent(
445
563
  lines.push(truncateToWidth(heading, width, "", false));
446
564
  previousToolName = row.toolName;
447
565
  }
448
- const summary = renderedCallSummary(row, Math.max(1, width - 4), theme);
449
- 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));
450
569
  }
451
570
  return lines;
452
571
  },
@@ -613,7 +732,10 @@ function renderContainerWithToolGroups(
613
732
  candidateIndex++
614
733
  ) {
615
734
  const candidate = children[candidateIndex];
616
- if (isAssistantMessageRow(candidate)) break;
735
+ if (isAssistantMessageRow(candidate)) {
736
+ if (renderAt(candidateIndex).some(hasVisibleContent)) break;
737
+ continue;
738
+ }
617
739
  if (isToolExecutionRow(candidate)) {
618
740
  if (!isCollapsibleSuccess(candidate)) break;
619
741
  group.push(candidate);
@@ -636,69 +758,6 @@ function renderContainerWithToolGroups(
636
758
  }
637
759
 
638
760
  // TODO: Replace prototype patching with a public Pi tool/transcript rendering API when available.
639
- function installThinkingGroupingPatch():
640
- | ThinkingGroupingPatchState
641
- | undefined {
642
- try {
643
- const proto =
644
- AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
645
- [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
646
- updateContent?: (message: AssistantMessageLike) => void;
647
- };
648
- if (!proto || typeof proto.updateContent !== "function") return undefined;
649
-
650
- const existing = proto[THINKING_GROUPING_PATCHED];
651
- if (existing) return existing;
652
-
653
- const state: ThinkingGroupingPatchState = {
654
- originalUpdateContent: proto.updateContent,
655
- };
656
- const patchedUpdateContent = function updateContentWithCombinedThinking(
657
- this: AssistantMessageRow,
658
- message: AssistantMessageLike,
659
- ): void {
660
- // Combine adjacent thinking blocks for display, but fall back to the original
661
- // message if combining throws. Either way, invoke Pi's renderer exactly once.
662
- let combined = message;
663
- try {
664
- combined = combineAdjacentThinking(message);
665
- } catch {
666
- // Thinking grouping is cosmetic; preserve the original message intact.
667
- }
668
- state.originalUpdateContent.call(this, combined);
669
- };
670
-
671
- state.patchedUpdateContent = patchedUpdateContent;
672
- proto.updateContent = patchedUpdateContent;
673
- Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
674
- configurable: true,
675
- value: state,
676
- });
677
- return state;
678
- } catch {
679
- // Thinking grouping is cosmetic; preserve Pi's renderer if its internals change.
680
- return undefined;
681
- }
682
- }
683
-
684
- function uninstallThinkingGroupingPatch(
685
- state: ThinkingGroupingPatchState | undefined,
686
- ): void {
687
- if (!state) return;
688
- const proto =
689
- AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
690
- [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
691
- updateContent?: (message: AssistantMessageLike) => void;
692
- };
693
- if (
694
- proto[THINKING_GROUPING_PATCHED] !== state ||
695
- proto.updateContent !== state.patchedUpdateContent
696
- )
697
- return;
698
- proto.updateContent = state.originalUpdateContent;
699
- delete proto[THINKING_GROUPING_PATCHED];
700
- }
701
-
702
761
  function installGroupingPatch(
703
762
  presentation: PresentationPatchState,
704
763
  ): GroupingPatchState | undefined {
@@ -800,11 +859,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
800
859
  state.groupCache.delete(this);
801
860
  state.originalUpdateDisplay.call(this);
802
861
  try {
803
- if (this.result?.isError && this.expanded === false) {
804
- this.expanded = true;
805
- state.originalUpdateDisplay.call(this);
806
- }
807
- collapseSuccessfulResult(this);
862
+ collapseSuccessfulResult(this, state.theme);
808
863
  } catch {
809
864
  // Presentation is cosmetic; preserve Pi's renderer if its internals change.
810
865
  }
@@ -867,7 +922,6 @@ function uninstallPresentationPatch(
867
922
  export default function (pi: ExtensionAPI) {
868
923
  const patch = installPresentationPatch();
869
924
  const grouping = patch ? installGroupingPatch(patch) : undefined;
870
- const thinkingGrouping = installThinkingGroupingPatch();
871
925
 
872
926
  pi.on("session_start", (_event, ctx) => {
873
927
  if (patch) patch.theme = ctx.ui.theme;
@@ -875,7 +929,6 @@ export default function (pi: ExtensionAPI) {
875
929
  });
876
930
 
877
931
  pi.on("session_shutdown", () => {
878
- uninstallThinkingGroupingPatch(thinkingGrouping);
879
932
  uninstallGroupingPatch(grouping);
880
933
  uninstallPresentationPatch(patch);
881
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
+ }