@pi-kaush/pi-tool-call-markers 0.2.1 → 0.2.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
@@ -2,6 +2,7 @@
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.
package/README.md CHANGED
@@ -12,9 +12,9 @@ When several tool calls run in a row, Pi normally renders each one as its own ex
12
12
  - **One-line, width-safe summaries.** Long targets truncate before their useful outcome tail instead of wrapping into taller blocks.
13
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
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
- - **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
+ - **Self-rendered tools get the same treatment.** Tools that own their framing (e.g. MCP adapter rows) collapse to the same one-line summary args label plus outcome with pending, success, and error backgrounds; expanding restores their full custom render.
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
- - **Errors stay compact and visibly failed.** A failed call keeps its own error-colored block and native collapsed detail until expanded.
17
+ - **Errors stay compact and visibly failed.** A failed call keeps an error-colored block: native collapsed detail for built-in tools, or a one-line summary with the first error line for self-rendered tools.
18
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
19
 
20
20
  ## Bundled thinking-block extension
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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
@@ -51,7 +51,11 @@ type ToolExecutionRow = {
51
51
  content?: Array<{ type?: unknown; text?: unknown }>;
52
52
  details?: Record<string, unknown>;
53
53
  };
54
- rendererState?: { startedAt?: unknown; endedAt?: unknown };
54
+ rendererState?: {
55
+ startedAt?: unknown;
56
+ endedAt?: unknown;
57
+ compactTitle?: unknown;
58
+ };
55
59
  contentBox?: ComponentContainer;
56
60
  contentText?: TextComponent;
57
61
  selfRenderContainer?: ComponentContainer;
@@ -68,7 +72,6 @@ type PresentationPatchState = {
68
72
  theme?: ThemeLike;
69
73
  collapseParallel: boolean;
70
74
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
71
- liveRenderedRows: WeakSet<ToolExecutionRow>;
72
75
  rowVersions: WeakMap<ToolExecutionRow, number>;
73
76
  rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
74
77
  rowSignatures: WeakMap<ToolExecutionRow, string>;
@@ -208,12 +211,20 @@ function collapseSuccessfulResult(
208
211
  row.expanded !== false ||
209
212
  row.isPartial !== false ||
210
213
  !row.result ||
211
- row.result.isError ||
212
- hasImageResult(row) ||
213
- row.getRenderShell?.() === "self"
214
+ hasImageResult(row)
214
215
  )
215
216
  return;
216
217
 
218
+ if (row.getRenderShell?.() === "self") {
219
+ collapseSelfRenderedRow(row, theme);
220
+ return;
221
+ }
222
+
223
+ // Default-shell failures keep Pi's native error render, which already
224
+ // applies the error background. rowHasFailed also catches MCP tools that
225
+ // use the default shell (e.g. mcpScript) and report via details.error.
226
+ if (rowHasFailed(row)) return;
227
+
217
228
  const collapsed = row.hasRendererDefinition?.()
218
229
  ? removeResultComponent(row.contentBox)
219
230
  : collapseGenericResult(row);
@@ -223,6 +234,193 @@ function collapseSuccessfulResult(
223
234
  }
224
235
  }
225
236
 
237
+ // Self-rendered tools (e.g. MCP adapter rows) own their framing, so there is
238
+ // no result component to trim. Swap the whole container for a single summary
239
+ // line in the same style as collapsed default-shell rows. Pi rebuilds the
240
+ // container on every updateDisplay, so this never corrupts expanded renders.
241
+ // Failures collapse too, with the error background and the first error line,
242
+ // since MCP error output tends to be a huge markdown blob.
243
+ function collapseSelfRenderedRow(
244
+ row: ToolExecutionRow,
245
+ theme?: ThemeLike,
246
+ ): void {
247
+ const container = row.selfRenderContainer;
248
+ if (!theme || !container || !Array.isArray(container.children)) return;
249
+
250
+ const failed = rowHasFailed(row);
251
+ const tail = failed
252
+ ? renderedErrorOutcome(row, theme)
253
+ : (renderedOutcome(row, theme) ?? renderedGenericOutcome(theme));
254
+ const box = new Box(1, 1, (text) =>
255
+ theme.bg(failed ? "toolErrorBg" : "toolSuccessBg", text),
256
+ );
257
+ box.addChild(
258
+ selfRenderedSummaryComponent(row, theme, tail, failed ? 0.5 : 1),
259
+ );
260
+ container.children = [box];
261
+ }
262
+
263
+ // The MCP adapter reports outcomes in details.error but only promotes
264
+ // tool_error/call_failed to result.isError (see its toolErrorOverride);
265
+ // remaining codes are informational guidance (ambiguous, no_instructions,
266
+ // auth flows, ...). Keep the failure code list here so every presentation
267
+ // decision — singletons and groups — shares one classification.
268
+ const MCP_FAILURE_ERROR_CODES: ReadonlySet<string> = new Set([
269
+ "tool_error",
270
+ "call_failed",
271
+ "tool_not_found",
272
+ "tool_not_found_after_reconnect",
273
+ "connect_failed",
274
+ "not_found",
275
+ "server_not_found",
276
+ "server_disabled",
277
+ "server_backoff",
278
+ "server_not_connected",
279
+ "init_failed",
280
+ "init_timeout",
281
+ "not_initialized",
282
+ "server_unavailable",
283
+ "not_connected",
284
+ "timeout",
285
+ "script_error",
286
+ ]);
287
+
288
+ function rowHasFailed(row: ToolExecutionRow): boolean {
289
+ if (row.result?.isError) return true;
290
+ const code = row.result?.details?.error;
291
+ return typeof code === "string" && MCP_FAILURE_ERROR_CODES.has(code);
292
+ }
293
+
294
+ function renderedErrorOutcome(row: ToolExecutionRow, theme: ThemeLike): string {
295
+ const firstLine =
296
+ resultText(row)
297
+ .split("\n")
298
+ .map((line) => line.trim())
299
+ .find((line) => line.length > 0) ?? "error";
300
+ return `${theme.fg("muted", "→")} ${theme.fg("error", firstLine)}`;
301
+ }
302
+
303
+ function selfRenderedSummaryComponent(
304
+ row: ToolExecutionRow,
305
+ theme: ThemeLike,
306
+ tail: string,
307
+ maxTailShare = 1,
308
+ ): ComponentLike {
309
+ return {
310
+ render(width: number): string[] {
311
+ const budget = Math.max(1, width - BADGE_WIDTH - 2);
312
+ const summary = selfRenderedSummary(row, budget);
313
+ // Long error lines would otherwise evict the call summary entirely;
314
+ // cap the tail's share so the label always survives.
315
+ const cappedTail =
316
+ maxTailShare >= 1
317
+ ? tail
318
+ : capFailureTail(tail, Math.floor(budget * maxTailShare), theme);
319
+ return [fitSummaryTail(summary, cappedTail, budget)];
320
+ },
321
+ invalidate() {},
322
+ };
323
+ }
324
+
325
+ // Trim a failure tail to its quota of the budget, counting the appended
326
+ // ellipsis inside the cap, so the call label keeps the leftover columns
327
+ // plus the joining space instead of being truncated to nothing.
328
+ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
329
+ const tailWidth = visibleWidth(tail);
330
+ if (tailWidth <= cap) return tail;
331
+ const ellipsis = theme.fg("muted", "…");
332
+ return `${sliceByColumn(tail, 0, Math.max(0, cap - visibleWidth(ellipsis)), true)}${ellipsis}`;
333
+ }
334
+
335
+ function renderedGenericOutcome(theme: ThemeLike): string {
336
+ return `${theme.fg("muted", "→")} ${theme.fg("success", "done")}`;
337
+ }
338
+
339
+ // The MCP adapter stashes its call's first line in renderer state before
340
+ // collapsing to an empty component, so prefer it over re-rendering the call.
341
+ function selfRenderedCallTitle(row: ToolExecutionRow): string | undefined {
342
+ const title = row.rendererState?.compactTitle;
343
+ return typeof title === "string" && title.trim().length > 0
344
+ ? title.trim()
345
+ : undefined;
346
+ }
347
+
348
+ // The MCP proxy passes tool arguments as a JSON-encoded string; parse and
349
+ // re-stringify so the summary shows compact JSON instead of escaped quotes.
350
+ function squashJsonish(value: unknown): string {
351
+ if (typeof value === "string") {
352
+ const trimmed = value.trim();
353
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
354
+ try {
355
+ const compact = JSON.stringify(JSON.parse(trimmed));
356
+ return compact === "{}" ? "" : compact;
357
+ } catch {
358
+ return flattenNewlines(trimmed);
359
+ }
360
+ }
361
+ return flattenNewlines(value);
362
+ }
363
+ return compactArgs(value);
364
+ }
365
+
366
+ // String fallbacks still feed the one-line "tool {args}" summary label;
367
+ // normalize embedded line breaks so a multiline argument cannot split it.
368
+ function flattenNewlines(text: string): string {
369
+ return text.replace(/[\r\n]+/g, " ");
370
+ }
371
+
372
+ // Builds a one-line "tool {args}" label from the call arguments. The
373
+ // adapter's own title drops the arguments and its pretty-printed JSON spans
374
+ // many lines, so squash the JSON ourselves to keep the row at one line.
375
+ function selfRenderedCallLabel(row: ToolExecutionRow): string {
376
+ const token = row.toolName ?? "tool";
377
+ const args = row.args;
378
+ // Only the adapter's "mcp" proxy tool uses these action shapes. Build the
379
+ // label from the args: the adapter's own compactTitle drops parameters
380
+ // (limit/offset) and can misname the action, so it is not trusted here.
381
+ if (
382
+ token === "mcp" &&
383
+ args &&
384
+ typeof args === "object" &&
385
+ !Array.isArray(args)
386
+ ) {
387
+ const record = args as Record<string, unknown>;
388
+ if (typeof record.tool === "string") {
389
+ const inner = squashJsonish(record.args);
390
+ const target =
391
+ typeof record.server === "string"
392
+ ? `${record.tool} @ ${record.server}`
393
+ : record.tool;
394
+ return inner ? `${target} ${inner}` : target;
395
+ }
396
+ for (const key of ["search", "connect", "describe", "action"] as const) {
397
+ const value = record[key];
398
+ if (typeof value !== "string" || value.length === 0) continue;
399
+ const rest: Record<string, unknown> = { ...record };
400
+ delete rest[key];
401
+ let label = key === "action" ? `mcp ${value}` : `mcp ${key} ${value}`;
402
+ if (typeof rest.server === "string") {
403
+ label += ` @ ${rest.server}`;
404
+ delete rest.server;
405
+ }
406
+ const restJson = squashJsonish(rest);
407
+ return restJson ? `${label} ${restJson}` : label;
408
+ }
409
+ if (typeof record.server === "string") return `mcp list ${record.server}`;
410
+ }
411
+ const title = selfRenderedCallTitle(row);
412
+ const json = squashJsonish(args);
413
+ // Proxy actions format nicer in the adapter's own title ("mcp search …");
414
+ // direct tools put only the bare name there, so append the args ourselves.
415
+ if (title && title !== token) return title;
416
+ if (json) return `${token} ${json}`;
417
+ return title ?? token;
418
+ }
419
+
420
+ function selfRenderedSummary(row: ToolExecutionRow, width: number): string {
421
+ return fitSummary(selfRenderedCallLabel(row), width);
422
+ }
423
+
226
424
  function isToolExecutionRow(
227
425
  component: unknown,
228
426
  ): component is ToolExecutionRow & ComponentLike {
@@ -270,7 +468,6 @@ function isLiveRow(row: ToolExecutionRow): boolean {
270
468
  return (
271
469
  row.expanded === false &&
272
470
  (row.isPartial !== false || !row.result) &&
273
- row.getRenderShell?.() !== "self" &&
274
471
  !hasImageResult(row)
275
472
  );
276
473
  }
@@ -310,6 +507,18 @@ function fitLiveHeader(
310
507
  // cap is composed before the Box applies background and padding, so the live
311
508
  // line keeps the tool background edge to edge and the block's bottom padding.
312
509
  function capLiveRowDisplay(row: ToolExecutionRow, theme?: ThemeLike): void {
510
+ // Pin live self-rendered rows (e.g. MCP calls) to the same one-line summary
511
+ // they settle into, so the block never grows and hops on completion.
512
+ if (row.getRenderShell?.() === "self") {
513
+ const container = row.selfRenderContainer;
514
+ if (!theme || !container || !Array.isArray(container.children)) return;
515
+ const box = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
516
+ box.addChild(
517
+ selfRenderedSummaryComponent(row, theme, theme.fg("muted", "…")),
518
+ );
519
+ container.children = [box];
520
+ return;
521
+ }
313
522
  if (row.hasRendererDefinition?.()) {
314
523
  const container = row.contentBox;
315
524
  if (!container || !Array.isArray(container.children)) return;
@@ -353,15 +562,11 @@ function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
353
562
  row.expanded === false &&
354
563
  row.isPartial === false &&
355
564
  !!row.result &&
356
- !row.result.isError &&
565
+ !rowHasFailed(row) &&
357
566
  !hasImageResult(row)
358
567
  );
359
568
  }
360
569
 
361
- function isSettledToolRow(row: ToolExecutionRow): boolean {
362
- return row.isPartial === false && !!row.result;
363
- }
364
-
365
570
  function hasToolSiblingInAssistantBatch(
366
571
  children: unknown[],
367
572
  index: number,
@@ -390,10 +595,6 @@ function isGroupableToolRow(
390
595
  state: PresentationPatchState,
391
596
  ): boolean {
392
597
  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
598
  return (
398
599
  state.collapseParallel ||
399
600
  !hasToolSiblingInAssistantBatch(children, index, renderAt)
@@ -532,9 +733,14 @@ function renderedGroupedOutcome(
532
733
  ): string | undefined {
533
734
  const outcome = renderedOutcome(row, theme);
534
735
  if (outcome) return outcome;
535
- if (!isLiveRow(row)) return undefined;
536
- const elapsed = liveElapsedText(row);
537
- return theme.fg("muted", elapsed ? `· ${elapsed}` : "…");
736
+ if (isLiveRow(row)) {
737
+ const elapsed = liveElapsedText(row);
738
+ return theme.fg("muted", elapsed ? `· ${elapsed}` : "…");
739
+ }
740
+ // Self-rendered tools have no per-tool outcome summary; keep the group's
741
+ // settled tail consistent with collapsed singletons.
742
+ if (row.getRenderShell?.() === "self") return renderedGenericOutcome(theme);
743
+ return undefined;
538
744
  }
539
745
 
540
746
  function truncatePlain(text: string, width: number, suffix = "…"): string {
@@ -574,17 +780,17 @@ function decorateSuccessfulCall(
574
780
  row.contentBox.children[0] = {
575
781
  render(width: number): string[] {
576
782
  const lines = original.render(width);
577
- const lineIndex = lines.findIndex(hasVisibleContent);
578
- if (lineIndex === -1) return lines;
579
- const line = lines[lineIndex];
783
+ const visibleLines = lines.filter(hasVisibleContent);
784
+ const line = visibleLines[0];
580
785
  if (line === undefined) return lines;
581
- const next = [...lines];
582
- next[lineIndex] = fitSummaryTail(
583
- trimRenderedLine(line),
584
- outcome,
585
- Math.max(1, width - BADGE_WIDTH),
586
- );
587
- return next;
786
+
787
+ const rendered = visibleLines.slice(0, 3).map(trimRenderedLine);
788
+ if (visibleLines.length > rendered.length)
789
+ rendered.push(theme.fg("muted", "…"));
790
+ const summary = rendered.join(theme.fg("muted", " · "));
791
+ return [
792
+ fitSummaryTail(summary, outcome, Math.max(1, width - BADGE_WIDTH)),
793
+ ];
588
794
  },
589
795
  invalidate() {
590
796
  original.invalidate();
@@ -620,17 +826,20 @@ function renderedCallSummary(
620
826
  width: number,
621
827
  theme: ThemeLike,
622
828
  ): string {
829
+ // Self-rendered call components change shape across the live/settled
830
+ // boundary (the adapter's call disappears once settled), so always build
831
+ // the summary from the stable args label instead of scraping the preview.
832
+ if (row.getRenderShell?.() === "self") return selfRenderedSummary(row, width);
833
+
623
834
  let component = row.callRendererComponent;
624
835
  if (!component && Array.isArray(row.contentBox?.children)) {
625
836
  component = row.contentBox.children[0] as ComponentLike | undefined;
626
837
  }
627
-
628
- const selfRendered = row.getRenderShell?.() === "self";
629
838
  if (component && typeof component.render === "function") {
630
839
  const visibleLines = component
631
840
  .render(Math.max(1, width))
632
841
  .filter(hasVisibleContent);
633
- const rendered = visibleLines.slice(0, selfRendered ? 1 : 3);
842
+ const rendered = visibleLines.slice(0, 3);
634
843
  const line = rendered[0];
635
844
  if (line) {
636
845
  const first = trimRenderedLine(line);
@@ -666,8 +875,6 @@ function renderedCallSummary(
666
875
  }
667
876
  }
668
877
 
669
- if (selfRendered) return theme.fg("muted", "(details omitted)");
670
-
671
878
  const fallback = compactArgs(row.args);
672
879
  return fallback
673
880
  ? theme.fg("accent", fallback)
@@ -820,7 +1027,9 @@ function renderGroupedToolRows(
820
1027
  width,
821
1028
  theme,
822
1029
  );
823
- const box = new Box(1, 1, (text) => theme.bg("toolSuccessBg", text));
1030
+ const box = new Box(1, 1, (text) =>
1031
+ theme.bg(hasLiveMembers ? "toolPendingBg" : "toolSuccessBg", text),
1032
+ );
824
1033
  box.addChild(summary);
825
1034
  lines = renderWithTemporaryChild(container, box, () =>
826
1035
  state.originalRender.call(row, width),
@@ -1018,7 +1227,6 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1018
1227
  const state: PresentationPatchState = {
1019
1228
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1020
1229
  groupCache: new WeakMap(),
1021
- liveRenderedRows: new WeakSet(),
1022
1230
  rowVersions: new WeakMap(),
1023
1231
  rowGroups: new WeakMap(),
1024
1232
  rowSignatures: new WeakMap(),
@@ -1052,7 +1260,6 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1052
1260
  this: ToolExecutionRow,
1053
1261
  width: number,
1054
1262
  ): string[] {
1055
- if (!isSettledToolRow(this)) state.liveRenderedRows.add(this);
1056
1263
  const lines = state.originalRender.call(this, width);
1057
1264
  try {
1058
1265
  return decorateHeader(this, lines, width, state.theme);