@pi-kaush/pi-tool-call-markers 0.1.3 → 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
@@ -2,6 +2,13 @@
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
+ - 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
+
5
12
  - Keep failed calls collapsed by default while retaining their native error background and Ctrl+O expansion.
6
13
  - Add compact result tails for common successful tools in singleton and grouped summaries.
7
14
  - Keep grouped bullets to one line and preserve the useful result tail on narrow terminals.
package/README.md CHANGED
@@ -10,9 +10,10 @@ When several tool calls of the same type succeed in a row, Pi normally renders e
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 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
+ - **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
 
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.0",
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
@@ -50,6 +50,7 @@ type ToolExecutionRow = {
50
50
  content?: Array<{ type?: unknown; text?: unknown }>;
51
51
  details?: Record<string, unknown>;
52
52
  };
53
+ rendererState?: { startedAt?: unknown; endedAt?: unknown };
53
54
  contentBox?: ComponentContainer;
54
55
  contentText?: TextComponent;
55
56
  selfRenderContainer?: ComponentContainer;
@@ -66,6 +67,10 @@ type PresentationPatchState = {
66
67
  theme?: ThemeLike;
67
68
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
68
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>;
69
74
  originalRender: (width: number) => string[];
70
75
  originalUpdateDisplay: () => void;
71
76
  patchedRender?: (width: number) => string[];
@@ -227,6 +232,113 @@ function hasImageResult(row: ToolExecutionRow): boolean {
227
232
  );
228
233
  }
229
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
+
230
342
  function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
231
343
  return (
232
344
  row.expanded === false &&
@@ -348,8 +460,10 @@ function outcomeSummary(row: ToolExecutionRow): string | undefined {
348
460
 
349
461
  const text = resultText(row);
350
462
  switch (row.toolName) {
351
- case "bash":
352
- return "done";
463
+ case "bash": {
464
+ const duration = bashDurationText(row);
465
+ return duration ? `done · ${duration}` : "done";
466
+ }
353
467
  case "read": {
354
468
  const lines = readLineCount(row, text);
355
469
  return `${lines} ${lines === 1 ? "line" : "lines"}`;
@@ -719,7 +833,41 @@ function renderContainerWithToolGroups(
719
833
  lines.push(...renderAt(index));
720
834
  continue;
721
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.
722
869
  if (hasUnsettledToolInBatch(children, index, renderAt)) {
870
+ presentation.rowModes.set(child, "individual");
723
871
  lines.push(...renderAt(index));
724
872
  continue;
725
873
  }
@@ -738,6 +886,7 @@ function renderContainerWithToolGroups(
738
886
  }
739
887
  if (isToolExecutionRow(candidate)) {
740
888
  if (!isCollapsibleSuccess(candidate)) break;
889
+ if (presentation.rowModes.has(candidate)) break;
741
890
  group.push(candidate);
742
891
  lastMemberIndex = candidateIndex;
743
892
  continue;
@@ -746,10 +895,12 @@ function renderContainerWithToolGroups(
746
895
  }
747
896
 
748
897
  if (group.length === 1) {
898
+ presentation.rowModes.set(child, "individual");
749
899
  lines.push(...renderAt(index));
750
900
  continue;
751
901
  }
752
902
 
903
+ for (const member of group) presentation.rowModes.set(member, group);
753
904
  lines.push(...renderGroupedToolRows(child, group, width, presentation));
754
905
  index = lastMemberIndex;
755
906
  }
@@ -849,17 +1000,30 @@ function installPresentationPatch(): PresentationPatchState | undefined {
849
1000
  const state: PresentationPatchState = {
850
1001
  groupCache: new WeakMap(),
851
1002
  rowVersions: new WeakMap(),
1003
+ rowModes: new WeakMap(),
1004
+ rowSignatures: new WeakMap(),
852
1005
  originalRender: proto.render,
853
1006
  originalUpdateDisplay: proto.updateDisplay,
854
1007
  };
855
1008
  const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
856
1009
  this: ToolExecutionRow,
857
1010
  ): void {
858
- state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
859
- 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
+ }
860
1023
  state.originalUpdateDisplay.call(this);
861
1024
  try {
862
- collapseSuccessfulResult(this, state.theme);
1025
+ if (isLiveRow(this)) capLiveRowDisplay(this, state.theme);
1026
+ else collapseSuccessfulResult(this, state.theme);
863
1027
  } catch {
864
1028
  // Presentation is cosmetic; preserve Pi's renderer if its internals change.
865
1029
  }