@pi-kaush/pi-tool-call-markers 0.2.0 → 0.2.2

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,17 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Compact multiline singleton calls into one width-safe summary after settlement, including timeout metadata, while preserving the full native call under Ctrl+O.
5
6
  - 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
 
7
8
  - 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
9
  - 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
+ - Group adjacent calls while they are still running, so new bullets grow downward and successful settlement updates outcomes in place without upward reflow.
11
+ - 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.
10
12
  - Only invalidate grouped-render caches on real state transitions, so bash's per-second ticks and resize invalidations stop busting them.
11
13
 
12
14
  - Keep failed calls collapsed by default while retaining their native error background and Ctrl+O expansion.
13
15
  - Add compact result tails for common successful tools in singleton and grouped summaries.
14
16
  - 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.
17
+ - Merge calls across assistant turns as soon as they appear when no visible prose or thinking separates them.
16
18
  - Move adjacent thinking-block merging to a separate extension entrypoint bundled in this package.
17
19
 
18
20
  ## 0.1.2
package/README.md CHANGED
@@ -4,14 +4,14 @@ 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
- - **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.
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.
15
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.
16
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
17
  - **Errors stay compact and visibly failed.** A failed call keeps its own error-colored block and native collapsed detail until expanded.
@@ -39,6 +39,17 @@ pi \
39
39
  -e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
40
40
  ```
41
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
+
42
53
  ## Compatibility and risk
43
54
 
44
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.2.0",
3
+ "version": "0.2.2",
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;
@@ -65,11 +66,11 @@ type ToolExecutionRow = {
65
66
 
66
67
  type PresentationPatchState = {
67
68
  theme?: ThemeLike;
69
+ collapseParallel: boolean;
68
70
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
71
+ liveRenderedRows: WeakSet<ToolExecutionRow>;
69
72
  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
+ rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
73
74
  rowSignatures: WeakMap<ToolExecutionRow, string>;
74
75
  originalRender: (width: number) => string[];
75
76
  originalUpdateDisplay: () => void;
@@ -91,6 +92,14 @@ type GroupRenderCache = {
91
92
  width: number;
92
93
  };
93
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
+
94
103
  function stripAnsi(text: string): string {
95
104
  return text.replace(ANSI_RE, "");
96
105
  }
@@ -353,7 +362,7 @@ function isSettledToolRow(row: ToolExecutionRow): boolean {
353
362
  return row.isPartial === false && !!row.result;
354
363
  }
355
364
 
356
- function hasUnsettledToolInBatch(
365
+ function hasToolSiblingInAssistantBatch(
357
366
  children: unknown[],
358
367
  index: number,
359
368
  renderAt: (index: number) => string[],
@@ -366,16 +375,31 @@ function hasUnsettledToolInBatch(
366
375
  ) {
367
376
  const candidate = children[candidateIndex];
368
377
  if (isAssistantMessageRow(candidate)) break;
369
- if (isToolExecutionRow(candidate)) {
370
- if (!isSettledToolRow(candidate)) return true;
371
- continue;
372
- }
378
+ if (isToolExecutionRow(candidate)) return true;
373
379
  if (renderAt(candidateIndex).some(hasVisibleContent)) break;
374
380
  }
375
381
  }
376
382
  return false;
377
383
  }
378
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
+
379
403
  function renderComponent(component: unknown, width: number): string[] {
380
404
  if (!component || typeof (component as ComponentLike).render !== "function")
381
405
  return [];
@@ -502,6 +526,17 @@ function renderedOutcome(
502
526
  return `${theme.fg("muted", "→")} ${theme.fg("success", summary)}`;
503
527
  }
504
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
+
505
540
  function truncatePlain(text: string, width: number, suffix = "…"): string {
506
541
  const max = Math.max(0, width);
507
542
  const plain = stripAnsi(text);
@@ -539,17 +574,17 @@ function decorateSuccessfulCall(
539
574
  row.contentBox.children[0] = {
540
575
  render(width: number): string[] {
541
576
  const lines = original.render(width);
542
- const lineIndex = lines.findIndex(hasVisibleContent);
543
- if (lineIndex === -1) return lines;
544
- const line = lines[lineIndex];
577
+ const visibleLines = lines.filter(hasVisibleContent);
578
+ const line = visibleLines[0];
545
579
  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;
580
+
581
+ const rendered = visibleLines.slice(0, 3).map(trimRenderedLine);
582
+ if (visibleLines.length > rendered.length)
583
+ rendered.push(theme.fg("muted", "…"));
584
+ const summary = rendered.join(theme.fg("muted", " · "));
585
+ return [
586
+ fitSummaryTail(summary, outcome, Math.max(1, width - BADGE_WIDTH)),
587
+ ];
553
588
  },
554
589
  invalidate() {
555
590
  original.invalidate();
@@ -678,7 +713,7 @@ function groupedCallComponent(
678
713
  previousToolName = row.toolName;
679
714
  }
680
715
  const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
681
- const outcome = renderedOutcome(row, theme);
716
+ const outcome = renderedGroupedOutcome(row, theme);
682
717
  lines.push(compactBulletLine(call, outcome, width, theme));
683
718
  }
684
719
  return lines;
@@ -742,9 +777,12 @@ function renderGroupedToolRows(
742
777
  const themeSample =
743
778
  theme.fg("toolTitle", "x") +
744
779
  theme.fg("muted", "x") +
780
+ theme.bg("toolPendingBg", "x") +
745
781
  theme.bg("toolSuccessBg", "x");
746
782
  const cached = state.groupCache.get(row);
783
+ const hasLiveMembers = rows.some(isLiveRow);
747
784
  if (
785
+ !hasLiveMembers &&
748
786
  cached &&
749
787
  cached.width === width &&
750
788
  cached.themeSample === themeSample &&
@@ -802,13 +840,17 @@ function renderGroupedToolRows(
802
840
  }
803
841
 
804
842
  const decorated = decorateHeader(row, lines, width, theme);
805
- state.groupCache.set(row, {
806
- lines: decorated,
807
- members: [...rows],
808
- memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
809
- themeSample,
810
- width,
811
- });
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
+ }
812
854
  return decorated;
813
855
  }
814
856
 
@@ -829,45 +871,10 @@ function renderContainerWithToolGroups(
829
871
 
830
872
  for (let index = 0; index < children.length; index++) {
831
873
  const child = children[index];
832
- if (!isToolExecutionRow(child) || !isCollapsibleSuccess(child)) {
833
- lines.push(...renderAt(index));
834
- continue;
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.
869
- if (hasUnsettledToolInBatch(children, index, renderAt)) {
870
- presentation.rowModes.set(child, "individual");
874
+ if (
875
+ !isToolExecutionRow(child) ||
876
+ !isGroupableToolRow(child, children, index, renderAt, presentation)
877
+ ) {
871
878
  lines.push(...renderAt(index));
872
879
  continue;
873
880
  }
@@ -885,8 +892,16 @@ function renderContainerWithToolGroups(
885
892
  continue;
886
893
  }
887
894
  if (isToolExecutionRow(candidate)) {
888
- if (!isCollapsibleSuccess(candidate)) break;
889
- if (presentation.rowModes.has(candidate)) break;
895
+ if (
896
+ !isGroupableToolRow(
897
+ candidate,
898
+ children,
899
+ candidateIndex,
900
+ renderAt,
901
+ presentation,
902
+ )
903
+ )
904
+ break;
890
905
  group.push(candidate);
891
906
  lastMemberIndex = candidateIndex;
892
907
  continue;
@@ -895,13 +910,16 @@ function renderContainerWithToolGroups(
895
910
  }
896
911
 
897
912
  if (group.length === 1) {
898
- presentation.rowModes.set(child, "individual");
899
913
  lines.push(...renderAt(index));
900
914
  continue;
901
915
  }
902
916
 
903
- for (const member of group) presentation.rowModes.set(member, group);
904
- 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));
905
923
  index = lastMemberIndex;
906
924
  }
907
925
 
@@ -998,9 +1016,11 @@ function installPresentationPatch(): PresentationPatchState | undefined {
998
1016
  return undefined;
999
1017
 
1000
1018
  const state: PresentationPatchState = {
1019
+ collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1001
1020
  groupCache: new WeakMap(),
1021
+ liveRenderedRows: new WeakSet(),
1002
1022
  rowVersions: new WeakMap(),
1003
- rowModes: new WeakMap(),
1023
+ rowGroups: new WeakMap(),
1004
1024
  rowSignatures: new WeakMap(),
1005
1025
  originalRender: proto.render,
1006
1026
  originalUpdateDisplay: proto.updateDisplay,
@@ -1013,7 +1033,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1013
1033
  // ticks and resize invalidations stop busting caches.
1014
1034
  const signature = `${this.isPartial}|${this.expanded}|${this.result ? 1 : 0}|${this.result?.isError ? 1 : 0}`;
1015
1035
  if (
1016
- Array.isArray(state.rowModes.get(this)) ||
1036
+ state.rowGroups.has(this) ||
1017
1037
  state.rowSignatures.get(this) !== signature
1018
1038
  ) {
1019
1039
  state.rowSignatures.set(this, signature);
@@ -1032,6 +1052,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1032
1052
  this: ToolExecutionRow,
1033
1053
  width: number,
1034
1054
  ): string[] {
1055
+ if (!isSettledToolRow(this)) state.liveRenderedRows.add(this);
1035
1056
  const lines = state.originalRender.call(this, width);
1036
1057
  try {
1037
1058
  return decorateHeader(this, lines, width, state.theme);