@pi-kaush/pi-tool-call-markers 0.1.2 → 0.2.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Compose capped in-progress rows before the Box paints its background, fixing elapsed tails leaking outside the tool background and restoring the block's bottom padding.
6
+
7
+ - Render in-progress calls as a single header line (elapsed time inline for `bash`) that settles into the final summary at the same height, so the transcript flows downwards without jumping.
8
+ - Show the real duration in successful bash summaries (`→ done · 2.3s`) instead of dropping the elapsed time on completion.
9
+ - Keep a call's settled shape stable once rendered: batches that settle next to an active sibling stay as individual one-liners instead of regrouping afterwards, which removes the second jump when a parallel batch completes.
10
+ - Only invalidate grouped-render caches on real state transitions, so bash's per-second ticks and resize invalidations stop busting them.
11
+
12
+ - Keep failed calls collapsed by default while retaining their native error background and Ctrl+O expansion.
13
+ - Add compact result tails for common successful tools in singleton and grouped summaries.
14
+ - Keep grouped bullets to one line and preserve the useful result tail on narrow terminals.
15
+ - Merge settled calls across assistant turns when no visible prose separates them, without reopening earlier groups while a later batch is active.
16
+ - Move adjacent thinking-block merging to a separate extension entrypoint bundled in this package.
17
+
3
18
  ## 0.1.2
4
19
 
5
20
  - Keep grouping within one assistant tool batch, so a later pending batch does not reopen completed groups.
package/README.md CHANGED
@@ -7,15 +7,21 @@ 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
+ - **Running calls stay one line.** While a call streams or executes, it renders as a single header line (with elapsed time inline for `bash`), so the transcript only grows downwards; the same line settles into the final `→ done · 2.3s`-style summary without changing height.
14
+ - **Settled shapes never re-render.** A call's settled shape is decided the first time it renders settled and never changes afterwards. Batches that finish while a sibling is still active stay as individual one-liners instead of regrouping later; batches already settled on first render (e.g. restored history) still merge into groups across assistant turns that rendered no prose.
14
15
  - **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 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
+ - **Image results stay visible.** Image-bearing results are not collapsed into text-only groups. Partial text output is held back while a call runs and surfaces through the final summary.
17
+ - **Errors stay compact and visibly failed.** A failed call keeps its own error-colored block and native collapsed detail until expanded.
18
+ - **Ctrl+O restores full blocks.** Expanding tools (`setToolsExpanded(true)`) brings back Pi's individual full blocks, including complete error details and successful results.
19
+
20
+ ## Bundled thinking-block extension
21
+
22
+ 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.
23
+
24
+ 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
25
 
20
26
  ## Install
21
27
 
@@ -28,20 +34,23 @@ pi install npm:@pi-kaush/pi-tool-call-markers@0.1.0
28
34
  For local development:
29
35
 
30
36
  ```bash
31
- pi -e ./extensions/pi-tool-call-markers/src/index.ts
37
+ pi \
38
+ -e ./extensions/pi-tool-call-markers/src/index.ts \
39
+ -e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
32
40
  ```
33
41
 
34
42
  ## Compatibility and risk
35
43
 
36
- This extension currently relies on **guarded, reversible prototype patches** against a small number of Pi component classes:
44
+ The tool presentation entrypoint currently relies on **guarded, reversible prototype patches** against two Pi component classes:
37
45
 
38
46
  - `ToolExecutionComponent` (render + display presentation),
39
- - `Container` (transcript grouping), and
40
- - `AssistantMessageComponent` (adjacent thinking merge).
47
+ - `Container` (transcript grouping).
48
+
49
+ The bundled thinking-block entrypoint separately patches `AssistantMessageComponent.updateContent`. Its patch and lifecycle do not share state with tool presentation.
41
50
 
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.
51
+ 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
52
 
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.
53
+ **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
54
 
46
55
  > TODO: migrate to a public Pi tool/transcript rendering API when one becomes available, and remove the prototype patches.
47
56
 
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.2.0",
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,8 +47,10 @@ 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
  };
53
+ rendererState?: { startedAt?: unknown; endedAt?: unknown };
54
54
  contentBox?: ComponentContainer;
55
55
  contentText?: TextComponent;
56
56
  selfRenderContainer?: ComponentContainer;
@@ -67,6 +67,10 @@ type PresentationPatchState = {
67
67
  theme?: ThemeLike;
68
68
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
69
69
  rowVersions: WeakMap<ToolExecutionRow, number>;
70
+ // A row's settled shape is decided the first time it renders settled and
71
+ // never changes afterwards, so live output only ever grows downwards.
72
+ rowModes: WeakMap<ToolExecutionRow, "individual" | ToolExecutionRow[]>;
73
+ rowSignatures: WeakMap<ToolExecutionRow, string>;
70
74
  originalRender: (width: number) => string[];
71
75
  originalUpdateDisplay: () => void;
72
76
  patchedRender?: (width: number) => string[];
@@ -87,25 +91,6 @@ type GroupRenderCache = {
87
91
  width: number;
88
92
  };
89
93
 
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
94
  function stripAnsi(text: string): string {
110
95
  return text.replace(ANSI_RE, "");
111
96
  }
@@ -206,7 +191,10 @@ function hideResultImages(row: ToolExecutionRow): void {
206
191
  row.imageSpacers = [];
207
192
  }
208
193
 
209
- function collapseSuccessfulResult(row: ToolExecutionRow): void {
194
+ function collapseSuccessfulResult(
195
+ row: ToolExecutionRow,
196
+ theme?: ThemeLike,
197
+ ): void {
210
198
  if (
211
199
  row.expanded !== false ||
212
200
  row.isPartial !== false ||
@@ -220,7 +208,10 @@ function collapseSuccessfulResult(row: ToolExecutionRow): void {
220
208
  const collapsed = row.hasRendererDefinition?.()
221
209
  ? removeResultComponent(row.contentBox)
222
210
  : collapseGenericResult(row);
223
- if (collapsed) hideResultImages(row);
211
+ if (collapsed) {
212
+ hideResultImages(row);
213
+ decorateSuccessfulCall(row, theme);
214
+ }
224
215
  }
225
216
 
226
217
  function isToolExecutionRow(
@@ -241,6 +232,113 @@ function hasImageResult(row: ToolExecutionRow): boolean {
241
232
  );
242
233
  }
243
234
 
235
+ function formatDuration(ms: number): string {
236
+ return `${(ms / 1000).toFixed(1)}s`;
237
+ }
238
+
239
+ function bashDurationText(row: ToolExecutionRow): string | undefined {
240
+ const startedAt = row.rendererState?.startedAt;
241
+ const endedAt = row.rendererState?.endedAt;
242
+ if (
243
+ typeof startedAt !== "number" ||
244
+ typeof endedAt !== "number" ||
245
+ !Number.isFinite(startedAt) ||
246
+ !Number.isFinite(endedAt)
247
+ )
248
+ return undefined;
249
+ return formatDuration(Math.max(0, endedAt - startedAt));
250
+ }
251
+
252
+ function liveElapsedText(row: ToolExecutionRow): string | undefined {
253
+ if (row.toolName !== "bash") return undefined;
254
+ const startedAt = row.rendererState?.startedAt;
255
+ if (typeof startedAt !== "number" || !Number.isFinite(startedAt))
256
+ return undefined;
257
+ return formatDuration(Math.max(0, Date.now() - startedAt));
258
+ }
259
+
260
+ function isLiveRow(row: ToolExecutionRow): boolean {
261
+ return (
262
+ row.expanded === false &&
263
+ (row.isPartial !== false || !row.result) &&
264
+ row.getRenderShell?.() !== "self" &&
265
+ !hasImageResult(row)
266
+ );
267
+ }
268
+
269
+ function liveTailText(
270
+ row: ToolExecutionRow,
271
+ droppedVisible: boolean,
272
+ theme?: ThemeLike,
273
+ ): string | undefined {
274
+ if (!theme) return undefined;
275
+ const elapsed = liveElapsedText(row);
276
+ if (!droppedVisible && !elapsed) return undefined;
277
+ const parts: string[] = [];
278
+ if (droppedVisible) parts.push("…");
279
+ if (elapsed) parts.push("·", elapsed);
280
+ return theme.fg("muted", parts.join(" "));
281
+ }
282
+
283
+ function fitLiveHeader(
284
+ header: string,
285
+ tail: string,
286
+ width: number,
287
+ droppedVisible: boolean,
288
+ ): string {
289
+ const max = Math.max(1, width);
290
+ const head = header.trimEnd();
291
+ if (visibleWidth(head) + visibleWidth(tail) + 1 <= max)
292
+ return `${head} ${tail}`;
293
+ const headWidth = Math.max(1, max - visibleWidth(tail) - 1);
294
+ const headSuffix = droppedVisible ? "" : "…";
295
+ return `${truncateToWidth(head, headWidth, headSuffix, false)} ${tail}`;
296
+ }
297
+
298
+ // While a call streams or runs, pin it to a single header line with the
299
+ // elapsed time inline, so the block never grows and never collapses on
300
+ // completion; the header text simply settles into the outcome summary. The
301
+ // cap is composed before the Box applies background and padding, so the live
302
+ // line keeps the tool background edge to edge and the block's bottom padding.
303
+ function capLiveRowDisplay(row: ToolExecutionRow, theme?: ThemeLike): void {
304
+ if (row.hasRendererDefinition?.()) {
305
+ const container = row.contentBox;
306
+ if (!container || !Array.isArray(container.children)) return;
307
+ const hadExtraChildren = container.children.length > 1;
308
+ removeResultComponent(container);
309
+ const original = container.children[0] as ComponentLike | undefined;
310
+ if (!original || typeof original.render !== "function") return;
311
+ container.children[0] = {
312
+ render(width: number): string[] {
313
+ const lines = original.render(width);
314
+ const headerIndex = lines.findIndex(hasVisibleContent);
315
+ if (headerIndex === -1) return lines;
316
+ const header = lines[headerIndex] ?? "";
317
+ const droppedVisible =
318
+ hadExtraChildren ||
319
+ lines.slice(headerIndex + 1).some(hasVisibleContent);
320
+ const tail = liveTailText(row, droppedVisible, theme);
321
+ return [
322
+ tail ? fitLiveHeader(header, tail, width, droppedVisible) : header,
323
+ ];
324
+ },
325
+ invalidate() {
326
+ original.invalidate();
327
+ },
328
+ };
329
+ return;
330
+ }
331
+
332
+ const text = row.contentText;
333
+ if (typeof text?.text !== "string" || typeof text.setText !== "function")
334
+ return;
335
+ const lines = text.text.split("\n");
336
+ const title = lines[0] ?? "";
337
+ const droppedVisible = lines.slice(1).some((line) => line.trim().length > 0);
338
+ const tail = liveTailText(row, droppedVisible, theme);
339
+ text.setText(tail ? `${title} ${tail}` : title);
340
+ }
341
+
244
342
  function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
245
343
  return (
246
344
  row.expanded === false &&
@@ -284,39 +382,6 @@ function renderComponent(component: unknown, width: number): string[] {
284
382
  return (component as ComponentLike).render(width);
285
383
  }
286
384
 
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
385
  function compactArgs(args: unknown): string {
321
386
  if (args === undefined || args === null) return "";
322
387
  try {
@@ -327,6 +392,171 @@ function compactArgs(args: unknown): string {
327
392
  }
328
393
  }
329
394
 
395
+ function resultText(row: ToolExecutionRow): string {
396
+ const contentText = row.result?.content?.find(
397
+ (content) => content.type === "text" && typeof content.text === "string",
398
+ )?.text;
399
+ if (typeof contentText === "string") return contentText;
400
+ return row.getTextOutput?.() ?? "";
401
+ }
402
+
403
+ function textLineCount(text: string): number {
404
+ if (!text) return 0;
405
+ const lines = text.split("\n");
406
+ return text.endsWith("\n") ? lines.length - 1 : lines.length;
407
+ }
408
+
409
+ function resultCount(row: ToolExecutionRow, text: string): number {
410
+ const totalMatched = row.result?.details?.totalMatched;
411
+ if (typeof totalMatched === "number" && Number.isFinite(totalMatched))
412
+ return Math.max(0, totalMatched);
413
+
414
+ const trimmed = text.trim();
415
+ if (
416
+ !trimmed ||
417
+ /^(?:No matches found|No files found|\(empty directory\))/i.test(trimmed)
418
+ )
419
+ return 0;
420
+
421
+ if (row.toolName === "grep" || row.toolName === "ffgrep") {
422
+ const lines = trimmed.split("\n");
423
+ const matches = lines.filter(
424
+ (line) => /^.+:\d+:/.test(line) || /^\s+\d+:/.test(line),
425
+ ).length;
426
+ if (matches > 0) return matches;
427
+ }
428
+
429
+ return trimmed
430
+ .split("\n")
431
+ .filter((line) => line.trim() && !/^\s*\[.*\]\s*$/.test(line)).length;
432
+ }
433
+
434
+ function readLineCount(row: ToolExecutionRow, text: string): number {
435
+ const outputLines = (
436
+ row.result?.details?.truncation as { outputLines?: unknown } | undefined
437
+ )?.outputLines;
438
+ if (typeof outputLines === "number" && Number.isFinite(outputLines))
439
+ return Math.max(0, outputLines);
440
+
441
+ const content = text.replace(
442
+ /\n\n\[[^\n]*(?:more lines|showing lines)[^\n]*\]\s*$/i,
443
+ "",
444
+ );
445
+ return textLineCount(content);
446
+ }
447
+
448
+ function diffCounts(diff: string): { added: number; removed: number } {
449
+ let added = 0;
450
+ let removed = 0;
451
+ for (const line of diff.split("\n")) {
452
+ if (line.startsWith("+") && !line.startsWith("+++")) added++;
453
+ if (line.startsWith("-") && !line.startsWith("---")) removed++;
454
+ }
455
+ return { added, removed };
456
+ }
457
+
458
+ function outcomeSummary(row: ToolExecutionRow): string | undefined {
459
+ if (!isCollapsibleSuccess(row)) return undefined;
460
+
461
+ const text = resultText(row);
462
+ switch (row.toolName) {
463
+ case "bash": {
464
+ const duration = bashDurationText(row);
465
+ return duration ? `done · ${duration}` : "done";
466
+ }
467
+ case "read": {
468
+ const lines = readLineCount(row, text);
469
+ return `${lines} ${lines === 1 ? "line" : "lines"}`;
470
+ }
471
+ case "write": {
472
+ const content = (row.args as { content?: unknown } | undefined)?.content;
473
+ if (typeof content !== "string") return "written";
474
+ const lines = textLineCount(content);
475
+ return `${lines} ${lines === 1 ? "line" : "lines"}`;
476
+ }
477
+ case "edit": {
478
+ const diff = row.result?.details?.diff;
479
+ if (typeof diff !== "string") return "applied";
480
+ const { added, removed } = diffCounts(diff);
481
+ return `+${added}/-${removed}`;
482
+ }
483
+ case "grep":
484
+ case "ffgrep":
485
+ case "find":
486
+ case "fffind":
487
+ case "ls": {
488
+ const results = resultCount(row, text);
489
+ return `${results} ${results === 1 ? "result" : "results"}`;
490
+ }
491
+ default:
492
+ return undefined;
493
+ }
494
+ }
495
+
496
+ function renderedOutcome(
497
+ row: ToolExecutionRow,
498
+ theme: ThemeLike,
499
+ ): string | undefined {
500
+ const summary = outcomeSummary(row);
501
+ if (!summary) return undefined;
502
+ return `${theme.fg("muted", "→")} ${theme.fg("success", summary)}`;
503
+ }
504
+
505
+ function truncatePlain(text: string, width: number, suffix = "…"): string {
506
+ const max = Math.max(0, width);
507
+ const plain = stripAnsi(text);
508
+ if (visibleWidth(plain) <= max) return plain;
509
+
510
+ const suffixWidth = visibleWidth(suffix);
511
+ if (suffixWidth >= max) return sliceByColumn(suffix, 0, max, true);
512
+ return `${sliceByColumn(plain, 0, max - suffixWidth, true)}${suffix}`;
513
+ }
514
+
515
+ function fitSummary(summary: string, width: number): string {
516
+ const max = Math.max(1, width);
517
+ return visibleWidth(summary) <= max ? summary : truncatePlain(summary, max);
518
+ }
519
+
520
+ function fitSummaryTail(head: string, tail: string, width: number): string {
521
+ const max = Math.max(1, width);
522
+ const summary = `${head.trimEnd()} ${tail}`;
523
+ if (visibleWidth(summary) <= max) return summary;
524
+
525
+ const tailWidth = visibleWidth(tail);
526
+ if (tailWidth >= max) return truncatePlain(tail, max);
527
+ return `${truncatePlain(head, max - tailWidth - 1)} ${tail}`;
528
+ }
529
+
530
+ function decorateSuccessfulCall(
531
+ row: ToolExecutionRow,
532
+ theme?: ThemeLike,
533
+ ): void {
534
+ if (!theme || !Array.isArray(row.contentBox?.children)) return;
535
+ const original = row.contentBox.children[0] as ComponentLike | undefined;
536
+ const outcome = renderedOutcome(row, theme);
537
+ if (!original || typeof original.render !== "function" || !outcome) return;
538
+
539
+ row.contentBox.children[0] = {
540
+ render(width: number): string[] {
541
+ const lines = original.render(width);
542
+ const lineIndex = lines.findIndex(hasVisibleContent);
543
+ if (lineIndex === -1) return lines;
544
+ const line = lines[lineIndex];
545
+ if (line === undefined) return lines;
546
+ const next = [...lines];
547
+ next[lineIndex] = fitSummaryTail(
548
+ trimRenderedLine(line),
549
+ outcome,
550
+ Math.max(1, width - BADGE_WIDTH),
551
+ );
552
+ return next;
553
+ },
554
+ invalidate() {
555
+ original.invalidate();
556
+ },
557
+ };
558
+ }
559
+
330
560
  function removeTrailingExpandHint(text: string): string {
331
561
  const plain = stripAnsi(text).trimEnd();
332
562
  const hint = plain.match(/\s+\([^)]*to expand\)$/i);
@@ -409,20 +639,22 @@ function renderedCallSummary(
409
639
  : theme.fg("muted", "(no arguments)");
410
640
  }
411
641
 
412
- function wrappedBulletLines(
642
+ function compactBulletLine(
413
643
  summary: string,
644
+ outcome: string | undefined,
414
645
  width: number,
415
646
  theme: ThemeLike,
416
- ): string[] {
647
+ ): string {
417
648
  const prefix = ` ${theme.fg("muted", "•")} `;
418
649
  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
- });
650
+ if (width <= indent) return truncateToWidth(prefix, width, "", false);
651
+ const available = width - indent;
652
+ return (
653
+ prefix +
654
+ (outcome
655
+ ? fitSummaryTail(summary, outcome, available)
656
+ : fitSummary(summary, available))
657
+ );
426
658
  }
427
659
 
428
660
  function groupedCallComponent(
@@ -445,8 +677,9 @@ function groupedCallComponent(
445
677
  lines.push(truncateToWidth(heading, width, "", false));
446
678
  previousToolName = row.toolName;
447
679
  }
448
- const summary = renderedCallSummary(row, Math.max(1, width - 4), theme);
449
- lines.push(...wrappedBulletLines(summary, width, theme));
680
+ const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
681
+ const outcome = renderedOutcome(row, theme);
682
+ lines.push(compactBulletLine(call, outcome, width, theme));
450
683
  }
451
684
  return lines;
452
685
  },
@@ -600,7 +833,41 @@ function renderContainerWithToolGroups(
600
833
  lines.push(...renderAt(index));
601
834
  continue;
602
835
  }
836
+ const decided = presentation.rowModes.get(child);
837
+ if (decided === "individual") {
838
+ lines.push(...renderAt(index));
839
+ continue;
840
+ }
841
+ if (Array.isArray(decided)) {
842
+ // A decided group is sticky, but transient states like Ctrl+O expansion
843
+ // still render members in full until they become collapsible again.
844
+ if (!decided.every(isCollapsibleSuccess)) {
845
+ lines.push(...renderAt(index));
846
+ continue;
847
+ }
848
+ const lastMember = decided[decided.length - 1];
849
+ let lastMemberIndex = index;
850
+ for (
851
+ let candidateIndex = index + 1;
852
+ candidateIndex < children.length;
853
+ candidateIndex++
854
+ ) {
855
+ if (children[candidateIndex] === lastMember) {
856
+ lastMemberIndex = candidateIndex;
857
+ break;
858
+ }
859
+ }
860
+ lines.push(...renderGroupedToolRows(child, decided, width, presentation));
861
+ index = lastMemberIndex;
862
+ continue;
863
+ }
864
+
865
+ // Undecided row: pick its settled shape once. A row that first settles
866
+ // next to an active sibling stays individual forever, so live batches
867
+ // never regroup after the fact; only batches that are fully settled on
868
+ // first render (e.g. restored history) become groups.
603
869
  if (hasUnsettledToolInBatch(children, index, renderAt)) {
870
+ presentation.rowModes.set(child, "individual");
604
871
  lines.push(...renderAt(index));
605
872
  continue;
606
873
  }
@@ -613,9 +880,13 @@ function renderContainerWithToolGroups(
613
880
  candidateIndex++
614
881
  ) {
615
882
  const candidate = children[candidateIndex];
616
- if (isAssistantMessageRow(candidate)) break;
883
+ if (isAssistantMessageRow(candidate)) {
884
+ if (renderAt(candidateIndex).some(hasVisibleContent)) break;
885
+ continue;
886
+ }
617
887
  if (isToolExecutionRow(candidate)) {
618
888
  if (!isCollapsibleSuccess(candidate)) break;
889
+ if (presentation.rowModes.has(candidate)) break;
619
890
  group.push(candidate);
620
891
  lastMemberIndex = candidateIndex;
621
892
  continue;
@@ -624,10 +895,12 @@ function renderContainerWithToolGroups(
624
895
  }
625
896
 
626
897
  if (group.length === 1) {
898
+ presentation.rowModes.set(child, "individual");
627
899
  lines.push(...renderAt(index));
628
900
  continue;
629
901
  }
630
902
 
903
+ for (const member of group) presentation.rowModes.set(member, group);
631
904
  lines.push(...renderGroupedToolRows(child, group, width, presentation));
632
905
  index = lastMemberIndex;
633
906
  }
@@ -636,69 +909,6 @@ function renderContainerWithToolGroups(
636
909
  }
637
910
 
638
911
  // 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
912
  function installGroupingPatch(
703
913
  presentation: PresentationPatchState,
704
914
  ): GroupingPatchState | undefined {
@@ -790,21 +1000,30 @@ function installPresentationPatch(): PresentationPatchState | undefined {
790
1000
  const state: PresentationPatchState = {
791
1001
  groupCache: new WeakMap(),
792
1002
  rowVersions: new WeakMap(),
1003
+ rowModes: new WeakMap(),
1004
+ rowSignatures: new WeakMap(),
793
1005
  originalRender: proto.render,
794
1006
  originalUpdateDisplay: proto.updateDisplay,
795
1007
  };
796
1008
  const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
797
1009
  this: ToolExecutionRow,
798
1010
  ): void {
799
- state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
800
- state.groupCache.delete(this);
1011
+ // Group members always bump so their leader's cache refreshes; other
1012
+ // rows only bump on state transitions, so bash's per-second invalidate
1013
+ // ticks and resize invalidations stop busting caches.
1014
+ const signature = `${this.isPartial}|${this.expanded}|${this.result ? 1 : 0}|${this.result?.isError ? 1 : 0}`;
1015
+ if (
1016
+ Array.isArray(state.rowModes.get(this)) ||
1017
+ state.rowSignatures.get(this) !== signature
1018
+ ) {
1019
+ state.rowSignatures.set(this, signature);
1020
+ state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
1021
+ state.groupCache.delete(this);
1022
+ }
801
1023
  state.originalUpdateDisplay.call(this);
802
1024
  try {
803
- if (this.result?.isError && this.expanded === false) {
804
- this.expanded = true;
805
- state.originalUpdateDisplay.call(this);
806
- }
807
- collapseSuccessfulResult(this);
1025
+ if (isLiveRow(this)) capLiveRowDisplay(this, state.theme);
1026
+ else collapseSuccessfulResult(this, state.theme);
808
1027
  } catch {
809
1028
  // Presentation is cosmetic; preserve Pi's renderer if its internals change.
810
1029
  }
@@ -867,7 +1086,6 @@ function uninstallPresentationPatch(
867
1086
  export default function (pi: ExtensionAPI) {
868
1087
  const patch = installPresentationPatch();
869
1088
  const grouping = patch ? installGroupingPatch(patch) : undefined;
870
- const thinkingGrouping = installThinkingGroupingPatch();
871
1089
 
872
1090
  pi.on("session_start", (_event, ctx) => {
873
1091
  if (patch) patch.theme = ctx.ui.theme;
@@ -875,7 +1093,6 @@ export default function (pi: ExtensionAPI) {
875
1093
  });
876
1094
 
877
1095
  pi.on("session_shutdown", () => {
878
- uninstallThinkingGroupingPatch(thinkingGrouping);
879
1096
  uninstallGroupingPatch(grouping);
880
1097
  uninstallPresentationPatch(patch);
881
1098
  });
@@ -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
+ }