@pi-kaush/pi-tool-call-markers 0.2.8 → 0.2.10

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/src/index.ts CHANGED
@@ -10,10 +10,16 @@ import {
10
10
  visibleWidth,
11
11
  wrapTextWithAnsi,
12
12
  } from "@earendil-works/pi-tui";
13
+ import {
14
+ installBashBlockPatch,
15
+ uninstallBashBlockPatch,
16
+ } from "./bash-block.ts";
13
17
  import { runChatContainerHooks } from "./container-hooks.ts";
18
+ import { installInfoVisibility } from "./info-visibility.ts";
14
19
 
15
20
  const OUTER_INSET = 2;
16
21
  const GROUP_MARKER = "%";
22
+ const SUBAGENT_MARKER = "&";
17
23
  const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
18
24
  const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
19
25
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
@@ -58,13 +64,13 @@ type ToolExecutionRow = {
58
64
  };
59
65
 
60
66
  type PresentationPatchState = {
67
+ owners: number;
61
68
  theme?: ThemeLike;
62
69
  collapseParallel: boolean;
63
70
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
64
71
  collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
65
72
  rowVersions: WeakMap<ToolExecutionRow, number>;
66
73
  rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
67
- rowSignatures: WeakMap<ToolExecutionRow, string>;
68
74
  originalRender: (width: number) => string[];
69
75
  originalUpdateDisplay: () => void;
70
76
  patchedRender?: (width: number) => string[];
@@ -72,6 +78,7 @@ type PresentationPatchState = {
72
78
  };
73
79
 
74
80
  type GroupingPatchState = {
81
+ owners: number;
75
82
  presentation: PresentationPatchState;
76
83
  originalRender: (width: number) => string[];
77
84
  patchedRender?: (width: number) => string[];
@@ -106,16 +113,19 @@ function stripAnsi(text: string): string {
106
113
  return text.replace(ANSI_RE, "");
107
114
  }
108
115
 
109
- // Command output can carry cursor-moving control bytes (git progress writes
110
- // \r) and raw terminal sequences. Collapsed rows are single-line, so result
111
- // and scraped text is stripped of display sequences and control bytes before
112
- // it can reach a row; a bare \r would otherwise return the cursor to column 0
113
- // and overwrite the row's own marker.
114
- const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
116
+ // Command output can carry cursor-moving control bytes, raw terminal
117
+ // sequences, line feeds, and tabs. Collapsed rows are single-line, so result
118
+ // and scraped text is stripped of display sequences and all C0 controls before
119
+ // it can reach a row; otherwise control bytes could split or overwrite it.
120
+ const INLINE_CONTROL_RE = /[\x00-\x1f\x7f]/g;
115
121
  // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
116
- // leave the hyperlink payload behind as visible text.
122
+ // leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
123
+ // hyperlinks, titles) end at their first BEL/ST terminator; a payload match
124
+ // must stop there too, because the visible text sits BETWEEN two OSC
125
+ // sequences — Pi wraps read paths as `ESC]8;;url ESC\ <path> ESC]8;; ESC\`,
126
+ // and a greedy `[^\x07]*` would swallow that path along with the sequences.
117
127
  const DISPLAY_ANSI_RE =
118
- /\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
128
+ /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
119
129
 
120
130
  function sanitizeInline(text: string): string {
121
131
  return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
@@ -125,11 +135,9 @@ function hasVisibleContent(line: string): boolean {
125
135
  return stripAnsi(line).trim().length > 0;
126
136
  }
127
137
 
128
- // State-transition fingerprint used by both the updateDisplay version bump
129
- // and the settled-row render cache. It deliberately covers only shape
130
- // transitions (partial/expanded/result/error): while a row is partial its
131
- // content streams and it is never cached, and once settled the rendered
132
- // content is fully determined by this fingerprint + width + theme.
138
+ // Shape fingerprint used in settled-row cache keys. It protects against
139
+ // direct state mutation followed by render without updateDisplay; real
140
+ // component display updates invalidate the cache through rowVersions.
133
141
  function rowSignatureOf(row: ToolExecutionRow): string {
134
142
  return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
135
143
  }
@@ -244,7 +252,7 @@ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
244
252
  }
245
253
 
246
254
  function renderedGenericOutcome(theme: ThemeLike): string {
247
- return theme.fg("muted", "→ done");
255
+ return theme.fg("toolOutput", "→ done");
248
256
  }
249
257
 
250
258
  // The MCP adapter stashes its call's first line in renderer state before
@@ -592,6 +600,64 @@ function diffCounts(diff: string): { added: number; removed: number } {
592
600
  return { added, removed };
593
601
  }
594
602
 
603
+ // Pi's edit tool (renderShell "self") stores its display diff in
604
+ // result.details.diff — lines like `+27 <content>`, `-27 <content>`,
605
+ // ` 28 <context>`, plus `...` for folded regions. Self-rendered rows
606
+ // normally keep only their one-line call label; edits additionally show
607
+ // the change as a bounded diff block under the summary and a +a/-b stat in
608
+ // the outcome tail, so the hunk stays visible without Ctrl+O while
609
+ // expanded rows keep Pi's native rendering.
610
+ const EDIT_DIFF_LINE_RE = /^([+\-\s])(\s*\d*)(.*)$/;
611
+ const MAX_EDIT_DIFF_LINES = 12;
612
+
613
+ function editDiffText(row: ToolExecutionRow): string | undefined {
614
+ if (row.toolName !== "edit") return undefined;
615
+ if (!row.result || row.isPartial !== false || rowHasFailed(row))
616
+ return undefined;
617
+ const diff = row.result.details?.diff;
618
+ return typeof diff === "string" && diff.length > 0 ? diff : undefined;
619
+ }
620
+
621
+ function renderedEditDiffStat(row: ToolExecutionRow): string | undefined {
622
+ const diff = editDiffText(row);
623
+ if (diff === undefined) return undefined;
624
+ const { added, removed } = diffCounts(diff);
625
+ return added + removed === 0 ? "applied" : `+${added}/-${removed}`;
626
+ }
627
+
628
+ function renderedEditDiffLines(
629
+ row: ToolExecutionRow,
630
+ theme: ThemeLike,
631
+ ): string[] {
632
+ const diff = editDiffText(row);
633
+ if (diff === undefined) return [];
634
+ const raw = diff.split("\n");
635
+ if (raw[raw.length - 1] === "") raw.pop();
636
+ const lines: string[] = [];
637
+ for (const line of raw.slice(0, MAX_EDIT_DIFF_LINES)) {
638
+ const clean = sanitizeInline(line).trimEnd();
639
+ if (clean.trim() === "...") {
640
+ lines.push(theme.fg("muted", " ..."));
641
+ continue;
642
+ }
643
+ const match = EDIT_DIFF_LINE_RE.exec(clean);
644
+ if (!match) continue;
645
+ const color =
646
+ match[1] === "+"
647
+ ? "toolDiffAdded"
648
+ : match[1] === "-"
649
+ ? "toolDiffRemoved"
650
+ : "toolDiffContext";
651
+ lines.push(theme.fg(color, ` ${match[1]}${match[2]}${match[3]}`));
652
+ }
653
+ if (raw.length > MAX_EDIT_DIFF_LINES) {
654
+ lines.push(
655
+ theme.fg("muted", ` ... +${raw.length - MAX_EDIT_DIFF_LINES} more`),
656
+ );
657
+ }
658
+ return lines;
659
+ }
660
+
595
661
  function outcomeSummary(row: ToolExecutionRow): string | undefined {
596
662
  // Self-rendered tools own their result framing; the built-in heuristics
597
663
  // (line counts, diff stats) describe default-shell rows only, so their
@@ -640,7 +706,7 @@ function renderedOutcome(
640
706
  ): string | undefined {
641
707
  const summary = outcomeSummary(row);
642
708
  if (!summary) return undefined;
643
- return theme.fg("muted", `→ ${summary}`);
709
+ return theme.fg("toolOutput", `→ ${summary}`);
644
710
  }
645
711
 
646
712
  function renderedGroupedOutcome(
@@ -780,7 +846,7 @@ function renderedCallSummary(
780
846
 
781
847
  const fallback = compactArgs(row.args);
782
848
  return fallback
783
- ? theme.fg("muted", fallback)
849
+ ? theme.fg("toolOutput", fallback)
784
850
  : theme.fg("muted", "(no arguments)");
785
851
  }
786
852
 
@@ -793,9 +859,13 @@ function styledCallLabel(
793
859
  const match = /^(\S+)(.*)$/s.exec(plain);
794
860
  if (!match) return theme.fg(color, plain);
795
861
  const rest = match[2] ?? "";
862
+ // Failed rows stay uniformly error-colored; settled rows use Pi's native
863
+ // split — toolTitle for the name, toolOutput for the call content.
864
+ const nameColor = color === "muted" ? "toolTitle" : color;
865
+ const restColor = color === "muted" ? "toolOutput" : color;
796
866
  return (
797
- theme.fg(color, theme.bold(match[1] ?? "")) +
798
- (rest ? theme.fg(color, `:${rest}`) : "")
867
+ theme.fg(nameColor, theme.bold(match[1] ?? "")) +
868
+ (rest ? theme.fg(restColor, `:${rest}`) : "")
799
869
  );
800
870
  }
801
871
 
@@ -832,7 +902,11 @@ function collapsedOutcome(
832
902
  const elapsed = liveElapsedText(row);
833
903
  return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
834
904
  }
835
- if (row.getRenderShell?.() === "self") return renderedGenericOutcome(theme);
905
+ if (row.getRenderShell?.() === "self") {
906
+ const editStat = renderedEditDiffStat(row);
907
+ if (editStat) return theme.fg("toolOutput", `→ ${editStat}`);
908
+ return renderedGenericOutcome(theme);
909
+ }
836
910
  return renderedOutcome(row, theme);
837
911
  }
838
912
 
@@ -844,7 +918,10 @@ function collapsedHeadline(
844
918
  // Failed rows render entirely in error so the row reads as the one that
845
919
  // failed, not just its outcome tail. Only the tool name is bold.
846
920
  const color = rowHasFailed(row) ? "error" : "muted";
847
- const marker = `${theme.fg(color, GROUP_MARKER)} `;
921
+ const marker = `${theme.fg(
922
+ color,
923
+ row.toolName === "subagent" ? SUBAGENT_MARKER : GROUP_MARKER,
924
+ )} `;
848
925
  const budget = Math.max(1, width - visibleWidth(marker));
849
926
  const label = collapsedCallLabel(row, budget, theme, color);
850
927
  const outcome = collapsedOutcome(row, budget, theme);
@@ -980,6 +1057,33 @@ function subagentStepPreview(task: string): string {
980
1057
  return clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
981
1058
  }
982
1059
 
1060
+ // Partial and final subagent results both carry per-task usage in details
1061
+ // (the subagent extension streams makeDetails snapshots through onUpdate), so
1062
+ // this works live and settled. Surface the smallest useful progress — total
1063
+ // turns, and the model when every task resolved to the same one.
1064
+ function subagentProgressText(row: ToolExecutionRow): string | undefined {
1065
+ const results = row.result?.details?.results;
1066
+ if (!Array.isArray(results) || results.length === 0) return undefined;
1067
+ let turns = 0;
1068
+ const models = new Set<string>();
1069
+ for (const result of results) {
1070
+ if (!isRecord(result)) continue;
1071
+ const usage = result.usage;
1072
+ const turnCount = isRecord(usage) ? usage.turns : undefined;
1073
+ if (typeof turnCount === "number" && Number.isFinite(turnCount)) {
1074
+ turns += turnCount;
1075
+ }
1076
+ const model = result.model ?? result.requestedModel;
1077
+ if (typeof model === "string" && model.trim().length > 0) {
1078
+ models.add(model.trim());
1079
+ }
1080
+ }
1081
+ const parts: string[] = [];
1082
+ if (turns > 0) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
1083
+ if (models.size === 1) parts.push(sanitizeInline([...models][0]!));
1084
+ return parts.length > 0 ? parts.join(" · ") : undefined;
1085
+ }
1086
+
983
1087
  // Subagents render as an ordinary unboxed tool block: a `%` heading with the
984
1088
  // plan kind/count/scope, then the numbered chain steps or parallel tasks with
985
1089
  // agent names in accent. Everything else stays muted (or error on failure),
@@ -1001,20 +1105,22 @@ function renderSubagentPlan(
1001
1105
  // other collapsed-row text so control bytes cannot reach the terminal.
1002
1106
  const displayOf = (agent: string) =>
1003
1107
  theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
1108
+ const detailColor = failed ? "error" : "toolOutput";
1004
1109
  const detailOf = (step: SubagentStep) =>
1005
1110
  theme.fg(
1006
- color,
1111
+ detailColor,
1007
1112
  `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
1008
1113
  );
1009
1114
 
1010
- const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
1115
+ const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
1011
1116
  const budget = Math.max(1, width - visibleWidth(marker));
1117
+ const progress = subagentProgressText(row);
1012
1118
  const outcome = failed
1013
1119
  ? collapsedOutcome(row, budget, theme)
1014
1120
  : isLiveRow(row)
1015
- ? collapsedOutcome(row, budget, theme)
1121
+ ? theme.fg("warning", progress ? `→ ${progress}` : "…")
1016
1122
  : row.result
1017
- ? renderedGenericOutcome(theme)
1123
+ ? theme.fg("toolOutput", progress ? `→ ${progress}` : "→ done")
1018
1124
  : theme.fg("warning", "…");
1019
1125
  const headline = (label: string) =>
1020
1126
  marker +
@@ -1026,7 +1132,7 @@ function renderSubagentPlan(
1026
1132
  const step = plan.steps[0]!;
1027
1133
  return [
1028
1134
  headline(
1029
- theme.fg(color, theme.bold("subagent")) +
1135
+ theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1030
1136
  " " +
1031
1137
  displayOf(step.agent) +
1032
1138
  detailOf(step),
@@ -1040,10 +1146,10 @@ function renderSubagentPlan(
1040
1146
  : `parallel (${plan.steps.length} tasks)`;
1041
1147
  const lines = [
1042
1148
  headline(
1043
- theme.fg(color, theme.bold("subagent")) +
1149
+ theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1044
1150
  " " +
1045
- theme.fg(color, kindLabel) +
1046
- theme.fg(color, ` [${plan.scope}]`),
1151
+ theme.fg(failed ? "error" : "toolOutput", kindLabel) +
1152
+ theme.fg(failed ? "error" : "toolOutput", ` [${plan.scope}]`),
1047
1153
  ),
1048
1154
  ];
1049
1155
  const shown = plan.steps.slice(0, 3);
@@ -1102,22 +1208,71 @@ function renderCollapsedToolRow(
1102
1208
  ? renderSubagentPlan(row, layout.contentWidth, theme)
1103
1209
  : undefined;
1104
1210
  const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1211
+ if (row.toolName === "edit") {
1212
+ body.push(...renderedEditDiffLines(row, theme));
1213
+ }
1105
1214
  body.push(...imageResultLines(row, layout.contentWidth, theme));
1106
1215
  return ["", ...insetLines(body, width)];
1107
1216
  }
1108
1217
 
1109
- function groupedCallComponent(
1218
+ function groupedChildLabel(
1219
+ row: ToolExecutionRow,
1220
+ width: number,
1221
+ theme: ThemeLike,
1222
+ color: string = "muted",
1223
+ ): string {
1224
+ const label = collapsedCallLabel(row, width, theme, color);
1225
+ const plain = stripAnsi(label);
1226
+ const toolName = row.toolName ?? "tool";
1227
+ let prefix = "";
1228
+ if (plain === toolName) {
1229
+ prefix = toolName;
1230
+ } else if (plain.startsWith(`${toolName}:`)) {
1231
+ const whitespace = /^\s*/.exec(plain.slice(toolName.length + 1))?.[0] ?? "";
1232
+ prefix = `${toolName}:${whitespace}`;
1233
+ }
1234
+ const prefixWidth = visibleWidth(prefix);
1235
+ const child = sliceByColumn(
1236
+ label,
1237
+ prefixWidth,
1238
+ Math.max(0, visibleWidth(label) - prefixWidth),
1239
+ true,
1240
+ );
1241
+ return child || theme.fg(color, "(no arguments)");
1242
+ }
1243
+
1244
+ function renderGroupedCallLines(
1110
1245
  rows: ToolExecutionRow[],
1246
+ width: number,
1111
1247
  theme: ThemeLike,
1112
- ): ComponentLike {
1113
- return {
1114
- render(width: number): string[] {
1115
- // Every member renders like a singleton — `% tool: call → outcome` per
1116
- // line with the tool name bolded — no bullets or internal blanks.
1117
- return rows.map((row) => collapsedHeadline(row, width, theme));
1118
- },
1119
- invalidate() {},
1120
- };
1248
+ ): string[] {
1249
+ const lines: string[] = [];
1250
+ let previousToolName: string | undefined;
1251
+ for (const row of rows) {
1252
+ const toolName = row.toolName ?? "tool";
1253
+ if (toolName !== previousToolName) {
1254
+ lines.push(
1255
+ `${theme.fg("muted", GROUP_MARKER)} ${theme.fg("toolTitle", theme.bold(toolName))}`,
1256
+ );
1257
+ previousToolName = toolName;
1258
+ }
1259
+
1260
+ const color = rowHasFailed(row) ? "error" : "muted";
1261
+ const prefix = ` ${theme.fg(color, "•")} `;
1262
+ const budget = Math.max(1, width - visibleWidth(prefix));
1263
+ const label = groupedChildLabel(row, budget, theme, color);
1264
+ const outcome = collapsedOutcome(row, budget, theme);
1265
+ const suffix = theme.fg(color, "…");
1266
+ lines.push(
1267
+ prefix +
1268
+ (outcome
1269
+ ? fitSummaryTail(label, outcome, budget, suffix)
1270
+ : fitSummary(label, budget, suffix)),
1271
+ );
1272
+ if (row.toolName === "edit")
1273
+ lines.push(...renderedEditDiffLines(row, theme));
1274
+ }
1275
+ return lines;
1121
1276
  }
1122
1277
 
1123
1278
  function sameMembers(
@@ -1166,8 +1321,8 @@ function renderGroupedToolRows(
1166
1321
  }
1167
1322
 
1168
1323
  const layout = insetLayout(width);
1169
- const summary = groupedCallComponent(rows, theme);
1170
- const lines = ["", ...insetLines(summary.render(layout.contentWidth), width)];
1324
+ const body = renderGroupedCallLines(rows, layout.contentWidth, theme);
1325
+ const lines = ["", ...insetLines(body, width)];
1171
1326
  if (hasLiveMembers) {
1172
1327
  state.groupCache.delete(row);
1173
1328
  } else {
@@ -1266,12 +1421,14 @@ function installGroupingPatch(
1266
1421
 
1267
1422
  const existing = proto[GROUPING_PATCHED];
1268
1423
  if (existing) {
1424
+ existing.owners++;
1269
1425
  existing.presentation = presentation;
1270
1426
  existing.disabled = false;
1271
1427
  return existing;
1272
1428
  }
1273
1429
 
1274
1430
  const state: GroupingPatchState = {
1431
+ owners: 1,
1275
1432
  presentation,
1276
1433
  originalRender: proto.render,
1277
1434
  };
@@ -1319,10 +1476,12 @@ function installGroupingPatch(
1319
1476
  }
1320
1477
 
1321
1478
  function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
1322
- if (!state) return;
1479
+ if (!state || state.owners <= 0) return;
1480
+ state.owners--;
1481
+ if (state.owners > 0) return;
1323
1482
  // Even when another extension's wrapper sits on top of ours, grouping must
1324
- // not outlive its owner: the wrapper delegates once disabled, and a later
1325
- // reinstall re-enables it.
1483
+ // not outlive its final owner: the wrapper delegates once disabled, and a
1484
+ // later reinstall re-enables it.
1326
1485
  state.disabled = true;
1327
1486
  const proto = Container?.prototype as unknown as ComponentContainer & {
1328
1487
  [GROUPING_PATCHED]?: GroupingPatchState;
@@ -1346,7 +1505,10 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1346
1505
  if (!proto) return undefined;
1347
1506
 
1348
1507
  const existing = proto[PRESENTATION_PATCHED];
1349
- if (existing) return existing;
1508
+ if (existing) {
1509
+ existing.owners++;
1510
+ return existing;
1511
+ }
1350
1512
  const legacy = proto[LEGACY_PRESENTATION_PATCHED];
1351
1513
  if (legacy) {
1352
1514
  proto.render = legacy.originalRender;
@@ -1359,30 +1521,24 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1359
1521
  return undefined;
1360
1522
 
1361
1523
  const state: PresentationPatchState = {
1524
+ owners: 1,
1362
1525
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1363
1526
  groupCache: new WeakMap(),
1364
1527
  collapsedCache: new WeakMap(),
1365
1528
  rowVersions: new WeakMap(),
1366
1529
  rowGroups: new WeakMap(),
1367
- rowSignatures: new WeakMap(),
1368
1530
  originalRender: proto.render,
1369
1531
  originalUpdateDisplay: proto.updateDisplay,
1370
1532
  };
1371
1533
  const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
1372
1534
  this: ToolExecutionRow,
1373
1535
  ): void {
1374
- // Group members always bump so their leader's cache refreshes; other
1375
- // rows only bump on state transitions, so bash's per-second invalidate
1376
- // ticks and resize invalidations stop busting caches.
1377
- const signature = rowSignatureOf(this);
1378
- if (
1379
- state.rowGroups.has(this) ||
1380
- state.rowSignatures.get(this) !== signature
1381
- ) {
1382
- state.rowSignatures.set(this, signature);
1383
- state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
1384
- state.groupCache.delete(this);
1385
- }
1536
+ // Transcript rerenders reuse settled caches, but a real component
1537
+ // display update can change args, results, renderer output, or images
1538
+ // without changing the row's shape signature.
1539
+ state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
1540
+ state.groupCache.delete(this);
1541
+ state.collapsedCache.delete(this);
1386
1542
  state.originalUpdateDisplay.call(this);
1387
1543
  };
1388
1544
  const patchedRender = function renderWithToolPresentation(
@@ -1407,12 +1563,11 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1407
1563
  // it touches. On a long session (~1,000 messages, hundreds of tool
1408
1564
  // rows) that measured at ~80 ms per keystroke — visible input lag.
1409
1565
  //
1410
- // Settled rows (not partial, not expanded) have fully static render
1411
- // output, so their collapsed lines are cached keyed by width +
1412
- // rowSignatureOf + rowVersions + theme sample. updateDisplay bumps
1413
- // rowVersions on every real state transition, and partial rows
1414
- // bypass the cache entirely because their content streams. This
1415
- // mirrors the existing groupCache discipline; keep them in sync.
1566
+ // Settled rows (not partial, not expanded) have static output between
1567
+ // component display updates, so their collapsed lines are cached keyed
1568
+ // by width + rowSignatureOf + rowVersions + theme sample. Every real
1569
+ // updateDisplay call bumps rowVersions and clears both caches; partial
1570
+ // rows bypass the cache because their content streams.
1416
1571
  // ================================================================
1417
1572
  if (!this.isPartial) {
1418
1573
  const signature = rowSignatureOf(this);
@@ -1484,7 +1639,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1484
1639
  function uninstallPresentationPatch(
1485
1640
  state: PresentationPatchState | undefined,
1486
1641
  ): void {
1487
- if (!state) return;
1642
+ if (!state || state.owners <= 0) return;
1643
+ state.owners--;
1644
+ if (state.owners > 0) return;
1488
1645
  const proto =
1489
1646
  ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
1490
1647
  [PRESENTATION_PATCHED]?: PresentationPatchState;
@@ -1506,14 +1663,21 @@ function uninstallPresentationPatch(
1506
1663
  export default function (pi: ExtensionAPI) {
1507
1664
  const patch = installPresentationPatch();
1508
1665
  const grouping = patch ? installGroupingPatch(patch) : undefined;
1666
+ const bashPatch = installBashBlockPatch();
1667
+ installInfoVisibility(pi);
1668
+ let released = false;
1509
1669
 
1510
1670
  pi.on("session_start", (_event, ctx) => {
1511
1671
  if (patch) patch.theme = ctx.ui.theme;
1672
+ if (bashPatch) bashPatch.theme = ctx.ui.theme;
1512
1673
  ctx.ui.setToolsExpanded(false);
1513
1674
  });
1514
1675
 
1515
1676
  pi.on("session_shutdown", () => {
1677
+ if (released) return;
1678
+ released = true;
1516
1679
  uninstallGroupingPatch(grouping);
1517
1680
  uninstallPresentationPatch(patch);
1681
+ uninstallBashBlockPatch(bashPatch);
1518
1682
  });
1519
1683
  }
@@ -0,0 +1,22 @@
1
+ // Shared toggle state for /toggle-info. Pi loads extension entrypoints through
2
+ // jiti with moduleCache disabled, so each entrypoint's relative-import chain
3
+ // gets its OWN module instances — a plain module-level boolean here would be
4
+ // duplicated between the main entrypoint (the /toggle-info command) and the
5
+ // thinking-block-merger entrypoint (the strip). globalThis keeps it singular.
6
+
7
+ type InfoVisibilityState = { hidden: boolean };
8
+
9
+ const STATE_KEY = Symbol.for("kg.pi.toolCallMarkers.infoVisibility.v1");
10
+
11
+ function infoVisibilityState(): InfoVisibilityState {
12
+ const root = globalThis as Record<symbol, InfoVisibilityState | undefined>;
13
+ return (root[STATE_KEY] ??= { hidden: false });
14
+ }
15
+
16
+ export function infoVisibilityHidden(): boolean {
17
+ return infoVisibilityState().hidden;
18
+ }
19
+
20
+ export function setInfoVisibilityHidden(hidden: boolean): void {
21
+ infoVisibilityState().hidden = hidden;
22
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ BashExecutionComponent,
3
+ type ExtensionAPI,
4
+ ToolExecutionComponent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { chatContainerHooks } from "./container-hooks.ts";
7
+ import {
8
+ infoVisibilityHidden,
9
+ setInfoVisibilityHidden,
10
+ } from "./info-visibility-state.ts";
11
+ import { refreshThinkingVisibility } from "./thinking-block-merger.ts";
12
+
13
+ function isExecutionRow(child: unknown): boolean {
14
+ return (
15
+ child instanceof ToolExecutionComponent ||
16
+ child instanceof BashExecutionComponent
17
+ );
18
+ }
19
+
20
+ // Chat-container hook: while hidden, tool and user-bash rows are lifted out of
21
+ // the child list for the render pass and restored afterwards, so grouping and
22
+ // insets run on prose only. Nested invocations are naturally idempotent: a
23
+ // second pass finds nothing left to remove.
24
+ function infoFilterHook(
25
+ _container: object,
26
+ children: unknown[],
27
+ ): (() => void) | undefined {
28
+ if (!infoVisibilityHidden()) return undefined;
29
+ const removed: Array<[number, unknown]> = [];
30
+ for (let index = children.length - 1; index >= 0; index--) {
31
+ if (isExecutionRow(children[index])) {
32
+ removed.push([index, children[index]!]);
33
+ children.splice(index, 1);
34
+ }
35
+ }
36
+ if (removed.length === 0) return undefined;
37
+ return () => {
38
+ for (const [index, child] of removed.reverse()) {
39
+ children.splice(index, 0, child);
40
+ }
41
+ };
42
+ }
43
+
44
+ export function installInfoVisibility(pi: ExtensionAPI): void {
45
+ // The hook registry is process-global; dedupe by name so hot reloads swap
46
+ // rather than stack.
47
+ const hooks = chatContainerHooks();
48
+ for (const hook of [...hooks]) {
49
+ if (hook.name === infoFilterHook.name) hooks.delete(hook);
50
+ }
51
+ hooks.add(infoFilterHook);
52
+
53
+ pi.registerCommand("toggle-info", {
54
+ description: "Hide tool calls and thinking, or restore them collapsed",
55
+ handler: async (_args, ctx) => {
56
+ const hidden = !infoVisibilityHidden();
57
+ setInfoVisibilityHidden(hidden);
58
+ // Thinking labels/blocks only rebuild on content updates, so replay the
59
+ // last update per assistant row to apply the new state immediately.
60
+ refreshThinkingVisibility();
61
+ ctx.ui.notify(
62
+ hidden
63
+ ? "Tool calls and thinking hidden — /toggle-info restores them"
64
+ : "Tool calls and thinking visible",
65
+ "info",
66
+ );
67
+ },
68
+ });
69
+
70
+ // Every session starts in the default collapsed-but-visible state. No
71
+ // shutdown reset: extra shutdown handlers would sit between this package's
72
+ // owner-counted patch releases.
73
+ pi.on("session_start", () => setInfoVisibilityHidden(false));
74
+ }