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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
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
+ - Group adjacent calls while they are still running, so new bullets grow downward and successful settlement updates outcomes in place without upward reflow.
10
+ - Add `PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL`, enabled by default, to keep same-assistant-message calls individual while still grouping sequential calls across quiet turns.
11
+ - Only invalidate grouped-render caches on real state transitions, so bash's per-second ticks and resize invalidations stop busting them.
12
+
5
13
  - Keep failed calls collapsed by default while retaining their native error background and Ctrl+O expansion.
6
14
  - Add compact result tails for common successful tools in singleton and grouped summaries.
7
15
  - 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.
16
+ - Merge calls across assistant turns as soon as they appear when no visible prose or thinking separates them.
9
17
  - Move adjacent thinking-block merging to a separate extension entrypoint bundled in this package.
10
18
 
11
19
  ## 0.1.2
package/README.md CHANGED
@@ -4,15 +4,16 @@ Collapse Pi's adjacent successful tool calls into one compact, gear-headed block
4
4
 
5
5
  ## What it changes
6
6
 
7
- When several tool calls of the same type succeed in a row, Pi normally renders each one as its own expanded block. This extension groups them:
7
+ When several tool calls run 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
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
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.
13
+ - **Running calls group immediately.** Adjacent calls become bullets as they appear, and each pending marker or elapsed `bash` time updates to the final outcome in place. Successful settlement does not shrink the block, so the transcript only grows downwards.
14
+ - **Sequential calls merge across quiet turns.** A later call joins the existing group immediately when no visible prose or thinking separates it; visible assistant content remains a hard boundary.
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
+ - **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.
16
17
  - **Errors stay compact and visibly failed.** A failed call keeps its own error-colored block and native collapsed detail until expanded.
17
18
  - **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
 
@@ -38,6 +39,17 @@ pi \
38
39
  -e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
39
40
  ```
40
41
 
42
+ ## Configuration
43
+
44
+ Grouping calls from the same assistant message is enabled by default. Pi normally executes those calls in parallel. To keep those same-message calls as individual compact rows while continuing to group sequential calls across quiet turns, start Pi with:
45
+
46
+ ```fish
47
+ set -lx PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL 0
48
+ pi
49
+ ```
50
+
51
+ `0`, `false`, `no`, and `off` disable parallel grouping. `1`, `true`, `yes`, and `on` enable it. The value is read when the extension loads.
52
+
41
53
  ## Compatibility and risk
42
54
 
43
55
  The tool presentation entrypoint currently relies on **guarded, reversible prototype patches** against two Pi component classes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
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",
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
18
18
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
19
19
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
20
20
  const BOLD_ON_RE = /\u001b\[1m/g;
21
+ const COLLAPSE_PARALLEL_ENV = "PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL";
21
22
 
22
23
  type ThemeLike = {
23
24
  bold(text: string): string;
@@ -50,6 +51,7 @@ type ToolExecutionRow = {
50
51
  content?: Array<{ type?: unknown; text?: unknown }>;
51
52
  details?: Record<string, unknown>;
52
53
  };
54
+ rendererState?: { startedAt?: unknown; endedAt?: unknown };
53
55
  contentBox?: ComponentContainer;
54
56
  contentText?: TextComponent;
55
57
  selfRenderContainer?: ComponentContainer;
@@ -64,8 +66,12 @@ type ToolExecutionRow = {
64
66
 
65
67
  type PresentationPatchState = {
66
68
  theme?: ThemeLike;
69
+ collapseParallel: boolean;
67
70
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
71
+ liveRenderedRows: WeakSet<ToolExecutionRow>;
68
72
  rowVersions: WeakMap<ToolExecutionRow, number>;
73
+ rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
74
+ rowSignatures: WeakMap<ToolExecutionRow, string>;
69
75
  originalRender: (width: number) => string[];
70
76
  originalUpdateDisplay: () => void;
71
77
  patchedRender?: (width: number) => string[];
@@ -86,6 +92,14 @@ type GroupRenderCache = {
86
92
  width: number;
87
93
  };
88
94
 
95
+ function envEnabled(name: string, defaultValue: boolean): boolean {
96
+ const value = process.env[name]?.trim().toLowerCase();
97
+ if (!value) return defaultValue;
98
+ if (["1", "true", "yes", "on"].includes(value)) return true;
99
+ if (["0", "false", "no", "off"].includes(value)) return false;
100
+ return defaultValue;
101
+ }
102
+
89
103
  function stripAnsi(text: string): string {
90
104
  return text.replace(ANSI_RE, "");
91
105
  }
@@ -227,6 +241,113 @@ function hasImageResult(row: ToolExecutionRow): boolean {
227
241
  );
228
242
  }
229
243
 
244
+ function formatDuration(ms: number): string {
245
+ return `${(ms / 1000).toFixed(1)}s`;
246
+ }
247
+
248
+ function bashDurationText(row: ToolExecutionRow): string | undefined {
249
+ const startedAt = row.rendererState?.startedAt;
250
+ const endedAt = row.rendererState?.endedAt;
251
+ if (
252
+ typeof startedAt !== "number" ||
253
+ typeof endedAt !== "number" ||
254
+ !Number.isFinite(startedAt) ||
255
+ !Number.isFinite(endedAt)
256
+ )
257
+ return undefined;
258
+ return formatDuration(Math.max(0, endedAt - startedAt));
259
+ }
260
+
261
+ function liveElapsedText(row: ToolExecutionRow): string | undefined {
262
+ if (row.toolName !== "bash") return undefined;
263
+ const startedAt = row.rendererState?.startedAt;
264
+ if (typeof startedAt !== "number" || !Number.isFinite(startedAt))
265
+ return undefined;
266
+ return formatDuration(Math.max(0, Date.now() - startedAt));
267
+ }
268
+
269
+ function isLiveRow(row: ToolExecutionRow): boolean {
270
+ return (
271
+ row.expanded === false &&
272
+ (row.isPartial !== false || !row.result) &&
273
+ row.getRenderShell?.() !== "self" &&
274
+ !hasImageResult(row)
275
+ );
276
+ }
277
+
278
+ function liveTailText(
279
+ row: ToolExecutionRow,
280
+ droppedVisible: boolean,
281
+ theme?: ThemeLike,
282
+ ): string | undefined {
283
+ if (!theme) return undefined;
284
+ const elapsed = liveElapsedText(row);
285
+ if (!droppedVisible && !elapsed) return undefined;
286
+ const parts: string[] = [];
287
+ if (droppedVisible) parts.push("…");
288
+ if (elapsed) parts.push("·", elapsed);
289
+ return theme.fg("muted", parts.join(" "));
290
+ }
291
+
292
+ function fitLiveHeader(
293
+ header: string,
294
+ tail: string,
295
+ width: number,
296
+ droppedVisible: boolean,
297
+ ): string {
298
+ const max = Math.max(1, width);
299
+ const head = header.trimEnd();
300
+ if (visibleWidth(head) + visibleWidth(tail) + 1 <= max)
301
+ return `${head} ${tail}`;
302
+ const headWidth = Math.max(1, max - visibleWidth(tail) - 1);
303
+ const headSuffix = droppedVisible ? "" : "…";
304
+ return `${truncateToWidth(head, headWidth, headSuffix, false)} ${tail}`;
305
+ }
306
+
307
+ // While a call streams or runs, pin it to a single header line with the
308
+ // elapsed time inline, so the block never grows and never collapses on
309
+ // completion; the header text simply settles into the outcome summary. The
310
+ // cap is composed before the Box applies background and padding, so the live
311
+ // line keeps the tool background edge to edge and the block's bottom padding.
312
+ function capLiveRowDisplay(row: ToolExecutionRow, theme?: ThemeLike): void {
313
+ if (row.hasRendererDefinition?.()) {
314
+ const container = row.contentBox;
315
+ if (!container || !Array.isArray(container.children)) return;
316
+ const hadExtraChildren = container.children.length > 1;
317
+ removeResultComponent(container);
318
+ const original = container.children[0] as ComponentLike | undefined;
319
+ if (!original || typeof original.render !== "function") return;
320
+ container.children[0] = {
321
+ render(width: number): string[] {
322
+ const lines = original.render(width);
323
+ const headerIndex = lines.findIndex(hasVisibleContent);
324
+ if (headerIndex === -1) return lines;
325
+ const header = lines[headerIndex] ?? "";
326
+ const droppedVisible =
327
+ hadExtraChildren ||
328
+ lines.slice(headerIndex + 1).some(hasVisibleContent);
329
+ const tail = liveTailText(row, droppedVisible, theme);
330
+ return [
331
+ tail ? fitLiveHeader(header, tail, width, droppedVisible) : header,
332
+ ];
333
+ },
334
+ invalidate() {
335
+ original.invalidate();
336
+ },
337
+ };
338
+ return;
339
+ }
340
+
341
+ const text = row.contentText;
342
+ if (typeof text?.text !== "string" || typeof text.setText !== "function")
343
+ return;
344
+ const lines = text.text.split("\n");
345
+ const title = lines[0] ?? "";
346
+ const droppedVisible = lines.slice(1).some((line) => line.trim().length > 0);
347
+ const tail = liveTailText(row, droppedVisible, theme);
348
+ text.setText(tail ? `${title} ${tail}` : title);
349
+ }
350
+
230
351
  function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
231
352
  return (
232
353
  row.expanded === false &&
@@ -241,7 +362,7 @@ function isSettledToolRow(row: ToolExecutionRow): boolean {
241
362
  return row.isPartial === false && !!row.result;
242
363
  }
243
364
 
244
- function hasUnsettledToolInBatch(
365
+ function hasToolSiblingInAssistantBatch(
245
366
  children: unknown[],
246
367
  index: number,
247
368
  renderAt: (index: number) => string[],
@@ -254,16 +375,31 @@ function hasUnsettledToolInBatch(
254
375
  ) {
255
376
  const candidate = children[candidateIndex];
256
377
  if (isAssistantMessageRow(candidate)) break;
257
- if (isToolExecutionRow(candidate)) {
258
- if (!isSettledToolRow(candidate)) return true;
259
- continue;
260
- }
378
+ if (isToolExecutionRow(candidate)) return true;
261
379
  if (renderAt(candidateIndex).some(hasVisibleContent)) break;
262
380
  }
263
381
  }
264
382
  return false;
265
383
  }
266
384
 
385
+ function isGroupableToolRow(
386
+ row: ToolExecutionRow,
387
+ children: unknown[],
388
+ index: number,
389
+ renderAt: (index: number) => string[],
390
+ state: PresentationPatchState,
391
+ ): boolean {
392
+ if (!isLiveRow(row) && !isCollapsibleSuccess(row)) return false;
393
+ // Self-rendered tools can change height while active. Keep live instances
394
+ // individual so settling never collapses an already-painted preview.
395
+ if (row.getRenderShell?.() === "self" && state.liveRenderedRows.has(row))
396
+ return false;
397
+ return (
398
+ state.collapseParallel ||
399
+ !hasToolSiblingInAssistantBatch(children, index, renderAt)
400
+ );
401
+ }
402
+
267
403
  function renderComponent(component: unknown, width: number): string[] {
268
404
  if (!component || typeof (component as ComponentLike).render !== "function")
269
405
  return [];
@@ -348,8 +484,10 @@ function outcomeSummary(row: ToolExecutionRow): string | undefined {
348
484
 
349
485
  const text = resultText(row);
350
486
  switch (row.toolName) {
351
- case "bash":
352
- return "done";
487
+ case "bash": {
488
+ const duration = bashDurationText(row);
489
+ return duration ? `done · ${duration}` : "done";
490
+ }
353
491
  case "read": {
354
492
  const lines = readLineCount(row, text);
355
493
  return `${lines} ${lines === 1 ? "line" : "lines"}`;
@@ -388,6 +526,17 @@ function renderedOutcome(
388
526
  return `${theme.fg("muted", "→")} ${theme.fg("success", summary)}`;
389
527
  }
390
528
 
529
+ function renderedGroupedOutcome(
530
+ row: ToolExecutionRow,
531
+ theme: ThemeLike,
532
+ ): string | undefined {
533
+ const outcome = renderedOutcome(row, theme);
534
+ if (outcome) return outcome;
535
+ if (!isLiveRow(row)) return undefined;
536
+ const elapsed = liveElapsedText(row);
537
+ return theme.fg("muted", elapsed ? `· ${elapsed}` : "…");
538
+ }
539
+
391
540
  function truncatePlain(text: string, width: number, suffix = "…"): string {
392
541
  const max = Math.max(0, width);
393
542
  const plain = stripAnsi(text);
@@ -564,7 +713,7 @@ function groupedCallComponent(
564
713
  previousToolName = row.toolName;
565
714
  }
566
715
  const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
567
- const outcome = renderedOutcome(row, theme);
716
+ const outcome = renderedGroupedOutcome(row, theme);
568
717
  lines.push(compactBulletLine(call, outcome, width, theme));
569
718
  }
570
719
  return lines;
@@ -628,9 +777,12 @@ function renderGroupedToolRows(
628
777
  const themeSample =
629
778
  theme.fg("toolTitle", "x") +
630
779
  theme.fg("muted", "x") +
780
+ theme.bg("toolPendingBg", "x") +
631
781
  theme.bg("toolSuccessBg", "x");
632
782
  const cached = state.groupCache.get(row);
783
+ const hasLiveMembers = rows.some(isLiveRow);
633
784
  if (
785
+ !hasLiveMembers &&
634
786
  cached &&
635
787
  cached.width === width &&
636
788
  cached.themeSample === themeSample &&
@@ -688,13 +840,17 @@ function renderGroupedToolRows(
688
840
  }
689
841
 
690
842
  const decorated = decorateHeader(row, lines, width, theme);
691
- state.groupCache.set(row, {
692
- lines: decorated,
693
- members: [...rows],
694
- memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
695
- themeSample,
696
- width,
697
- });
843
+ if (hasLiveMembers) {
844
+ state.groupCache.delete(row);
845
+ } else {
846
+ state.groupCache.set(row, {
847
+ lines: decorated,
848
+ members: [...rows],
849
+ memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
850
+ themeSample,
851
+ width,
852
+ });
853
+ }
698
854
  return decorated;
699
855
  }
700
856
 
@@ -715,11 +871,10 @@ function renderContainerWithToolGroups(
715
871
 
716
872
  for (let index = 0; index < children.length; index++) {
717
873
  const child = children[index];
718
- if (!isToolExecutionRow(child) || !isCollapsibleSuccess(child)) {
719
- lines.push(...renderAt(index));
720
- continue;
721
- }
722
- if (hasUnsettledToolInBatch(children, index, renderAt)) {
874
+ if (
875
+ !isToolExecutionRow(child) ||
876
+ !isGroupableToolRow(child, children, index, renderAt, presentation)
877
+ ) {
723
878
  lines.push(...renderAt(index));
724
879
  continue;
725
880
  }
@@ -737,7 +892,16 @@ function renderContainerWithToolGroups(
737
892
  continue;
738
893
  }
739
894
  if (isToolExecutionRow(candidate)) {
740
- if (!isCollapsibleSuccess(candidate)) break;
895
+ if (
896
+ !isGroupableToolRow(
897
+ candidate,
898
+ children,
899
+ candidateIndex,
900
+ renderAt,
901
+ presentation,
902
+ )
903
+ )
904
+ break;
741
905
  group.push(candidate);
742
906
  lastMemberIndex = candidateIndex;
743
907
  continue;
@@ -750,7 +914,12 @@ function renderContainerWithToolGroups(
750
914
  continue;
751
915
  }
752
916
 
753
- lines.push(...renderGroupedToolRows(child, group, width, presentation));
917
+ for (const member of group) presentation.rowGroups.set(member, group);
918
+ // An active member supplies the pending background. Once every call
919
+ // settles, the first member supplies the success background. Either way,
920
+ // the grouped row keeps the same number of lines.
921
+ const shellRow = group.find(isLiveRow) ?? child;
922
+ lines.push(...renderGroupedToolRows(shellRow, group, width, presentation));
754
923
  index = lastMemberIndex;
755
924
  }
756
925
 
@@ -847,19 +1016,34 @@ function installPresentationPatch(): PresentationPatchState | undefined {
847
1016
  return undefined;
848
1017
 
849
1018
  const state: PresentationPatchState = {
1019
+ collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
850
1020
  groupCache: new WeakMap(),
1021
+ liveRenderedRows: new WeakSet(),
851
1022
  rowVersions: new WeakMap(),
1023
+ rowGroups: new WeakMap(),
1024
+ rowSignatures: new WeakMap(),
852
1025
  originalRender: proto.render,
853
1026
  originalUpdateDisplay: proto.updateDisplay,
854
1027
  };
855
1028
  const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
856
1029
  this: ToolExecutionRow,
857
1030
  ): void {
858
- state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
859
- state.groupCache.delete(this);
1031
+ // Group members always bump so their leader's cache refreshes; other
1032
+ // rows only bump on state transitions, so bash's per-second invalidate
1033
+ // ticks and resize invalidations stop busting caches.
1034
+ const signature = `${this.isPartial}|${this.expanded}|${this.result ? 1 : 0}|${this.result?.isError ? 1 : 0}`;
1035
+ if (
1036
+ state.rowGroups.has(this) ||
1037
+ state.rowSignatures.get(this) !== signature
1038
+ ) {
1039
+ state.rowSignatures.set(this, signature);
1040
+ state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
1041
+ state.groupCache.delete(this);
1042
+ }
860
1043
  state.originalUpdateDisplay.call(this);
861
1044
  try {
862
- collapseSuccessfulResult(this, state.theme);
1045
+ if (isLiveRow(this)) capLiveRowDisplay(this, state.theme);
1046
+ else collapseSuccessfulResult(this, state.theme);
863
1047
  } catch {
864
1048
  // Presentation is cosmetic; preserve Pi's renderer if its internals change.
865
1049
  }
@@ -868,6 +1052,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
868
1052
  this: ToolExecutionRow,
869
1053
  width: number,
870
1054
  ): string[] {
1055
+ if (!isSettledToolRow(this)) state.liveRenderedRows.add(this);
871
1056
  const lines = state.originalRender.call(this, width);
872
1057
  try {
873
1058
  return decorateHeader(this, lines, width, state.theme);