@pi-kaush/pi-tool-call-markers 0.3.4 → 0.3.6

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
@@ -15,17 +15,38 @@ import {
15
15
  uninstallBashBlockPatch,
16
16
  } from "./bash-block.ts";
17
17
  import { runChatContainerHooks } from "./container-hooks.ts";
18
- import { installInfoVisibility } from "./info-visibility.ts";
19
- import { fgCollapsed, fgCollapsedRail } from "./muted.ts";
18
+ import {
19
+ installInfoVisibility,
20
+ uninstallInfoVisibility,
21
+ } from "./info-visibility.ts";
22
+ import { fgCollapsed, fgCollapsedRail, paletteSample } from "./muted.ts";
20
23
 
21
24
  const OUTER_INSET = 2;
22
25
  const SUBAGENT_MARKER = "↪";
23
26
  const GROUP_CALL_MARKER = "│";
27
+ // Collapsed rows anchor with a glyph instead of repeating the tool name;
28
+ // tools that read as one family share a glyph, and unmapped tools fall back
29
+ // to `*`. A single space separates the glyph from the call content: the
30
+ // anchor is punctuation enough without a joining colon.
31
+ const TOOL_CALL_GLYPHS: ReadonlyMap<string, string> = new Map([
32
+ ["read", "●"],
33
+ ["write", "+"],
34
+ ["edit", "±"],
35
+ ["grep", "○"],
36
+ ["ffgrep", "○"],
37
+ ["find", "○"],
38
+ ["fffind", "○"],
39
+ ["ls", "≡"],
40
+ ["web_search", "↗"],
41
+ ["fetch_content", "↗"],
42
+ ["get_search_content", "↗"],
43
+ ]);
24
44
  const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
25
45
  const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
26
46
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
27
47
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
28
48
  const COLLAPSE_PARALLEL_ENV = "PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL";
49
+ const EXPANDED_TOOLS_ENV = "PI_ALWAYS_EXPANDED_TOOL_CALL_MARKERS";
29
50
 
30
51
  type ThemeLike = {
31
52
  bold(text: string): string;
@@ -70,12 +91,16 @@ type ToolExecutionRow = {
70
91
 
71
92
  type PresentationPatchState = {
72
93
  owners: number;
94
+ // Set at the final owner's shutdown. A wrapper that another extension has
95
+ // buried cannot be uninstalled, so it delegates instead of outliving its
96
+ // owner; a later install re-enables it.
97
+ disabled?: boolean;
73
98
  theme?: ThemeLike;
74
99
  collapseParallel: boolean;
100
+ expandedTools: ReadonlySet<string>;
75
101
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
76
102
  collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
77
103
  rowVersions: WeakMap<ToolExecutionRow, number>;
78
- rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
79
104
  originalRender: (width: number) => string[];
80
105
  originalUpdateDisplay: () => void;
81
106
  originalHandleMouse?: (this: ToolExecutionRow, event: unknown) => unknown;
@@ -94,9 +119,7 @@ type GroupingPatchState = {
94
119
 
95
120
  type GroupRenderCache = {
96
121
  lines: string[];
97
- // Per-member click-routing accounting for the group block: the first
98
- // member absorbs the leading blank and the tool-name header, later
99
- // members carry their own line (plus edit diffs).
122
+ // The first member absorbs the leading blank; each member owns its line.
100
123
  heights: Array<{ component: ToolExecutionRow; height: number }>;
101
124
  members: ToolExecutionRow[];
102
125
  memberVersions: number[];
@@ -120,6 +143,19 @@ function envEnabled(name: string, defaultValue: boolean): boolean {
120
143
  return defaultValue;
121
144
  }
122
145
 
146
+ // Comma-separated tool names, e.g. `edit,write`. Blank entries and padding
147
+ // are ignored; an unset or empty variable yields no names.
148
+ function envToolNames(name: string): ReadonlySet<string> {
149
+ const value = process.env[name]?.trim();
150
+ if (!value) return new Set();
151
+ return new Set(
152
+ value
153
+ .split(",")
154
+ .map((entry) => entry.trim())
155
+ .filter(Boolean),
156
+ );
157
+ }
158
+
123
159
  function stripAnsi(text: string): string {
124
160
  return text.replace(ANSI_RE, "");
125
161
  }
@@ -153,22 +189,12 @@ function rowSignatureOf(row: ToolExecutionRow): string {
153
189
  return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
154
190
  }
155
191
 
156
- // Theme samples are five theme.fg calls; computing them per row per frame
157
- // would reintroduce the per-keystroke cost the render caches remove, so
158
- // cache by theme object identity (a theme switch swaps the object).
159
- const themeSamples = new WeakMap<ThemeLike, string>();
192
+ // Render caches hold lines with resolved colors baked in, so they key on the
193
+ // palette's values rather than the theme object: Pi swaps the colors behind a
194
+ // stable Proxy, which made an identity key survive a theme switch. Probing is
195
+ // a handful of map lookups per row per frame.
160
196
  function themeSampleFor(theme: ThemeLike): string {
161
- let sample = themeSamples.get(theme);
162
- if (!sample) {
163
- sample =
164
- theme.fg("toolTitle", "x") +
165
- theme.fg("muted", "x") +
166
- theme.fg("dim", "x") +
167
- theme.fg("warning", "x") +
168
- theme.fg("error", "x");
169
- themeSamples.set(theme, sample);
170
- }
171
- return sample;
197
+ return paletteSample(theme);
172
198
  }
173
199
 
174
200
  type InsetLayout = {
@@ -432,10 +458,6 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
432
458
  return title ?? token;
433
459
  }
434
460
 
435
- function selfRenderedSummary(row: ToolExecutionRow, width: number): string {
436
- return fitSummary(selfRenderedCallLabel(row), width);
437
- }
438
-
439
461
  function isToolExecutionRow(
440
462
  component: unknown,
441
463
  ): component is ToolExecutionRow & ComponentLike {
@@ -497,6 +519,18 @@ function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
497
519
  );
498
520
  }
499
521
 
522
+ // Settled failures join groups like settled successes; the row's error
523
+ // color carries the failure, so the group stays visually unbroken.
524
+ function isCollapsibleFailure(row: ToolExecutionRow): boolean {
525
+ return (
526
+ row.expanded === false &&
527
+ row.isPartial === false &&
528
+ !!row.result &&
529
+ rowHasFailed(row) &&
530
+ !hasImageResult(row)
531
+ );
532
+ }
533
+
500
534
  function hasToolSiblingInAssistantBatch(
501
535
  children: unknown[],
502
536
  index: number,
@@ -517,6 +551,16 @@ function hasToolSiblingInAssistantBatch(
517
551
  return false;
518
552
  }
519
553
 
554
+ // A self-rendered tool opts out of the transcript by drawing no lines. The
555
+ // singleton and grouped paths must agree on that, or grouping resurrects a
556
+ // row the tool deliberately hid.
557
+ function drawsNoRowLines(
558
+ row: ToolExecutionRow,
559
+ nativeLines: string[],
560
+ ): boolean {
561
+ return nativeLines.length === 0 && (row.imageComponents?.length ?? 0) === 0;
562
+ }
563
+
520
564
  function isGroupableToolRow(
521
565
  row: ToolExecutionRow,
522
566
  children: unknown[],
@@ -525,7 +569,13 @@ function isGroupableToolRow(
525
569
  state: PresentationPatchState,
526
570
  ): boolean {
527
571
  if (row.toolName === "subagent") return false;
528
- if (!isLiveRow(row) && !isCollapsibleSuccess(row)) return false;
572
+ if (drawsNoRowLines(row, renderAt(index))) return false;
573
+ if (
574
+ !isLiveRow(row) &&
575
+ !isCollapsibleSuccess(row) &&
576
+ !isCollapsibleFailure(row)
577
+ )
578
+ return false;
529
579
  return (
530
580
  state.collapseParallel ||
531
581
  !hasToolSiblingInAssistantBatch(children, index, renderAt)
@@ -612,15 +662,8 @@ function diffCounts(diff: string): { added: number; removed: number } {
612
662
  }
613
663
 
614
664
  // Pi's edit tool (renderShell "self") stores its display diff in
615
- // result.details.diff — lines like `+27 <content>`, `-27 <content>`,
616
- // ` 28 <context>`, plus `...` for folded regions. Self-rendered rows
617
- // normally keep only their one-line call label; edits additionally show
618
- // the change as a bounded diff block under the summary and a +a/-b stat in
619
- // the outcome tail, so the hunk stays visible without Ctrl+O while
620
- // expanded rows keep Pi's native rendering.
621
- const EDIT_DIFF_LINE_RE = /^([+\-\s])(\s*\d*)(.*)$/;
622
- const MAX_EDIT_DIFF_LINES = 12;
623
-
665
+ // result.details.diff; collapsed rows show only its +a/-b stat, while the
666
+ // full hunk returns with Ctrl+O.
624
667
  function editDiffText(row: ToolExecutionRow): string | undefined {
625
668
  if (row.toolName !== "edit") return undefined;
626
669
  if (!row.result || row.isPartial !== false || rowHasFailed(row))
@@ -636,43 +679,6 @@ function renderedEditDiffStat(row: ToolExecutionRow): string | undefined {
636
679
  return added + removed === 0 ? "applied" : `+${added}/-${removed}`;
637
680
  }
638
681
 
639
- function renderedEditDiffLines(
640
- row: ToolExecutionRow,
641
- theme: ThemeLike,
642
- ): string[] {
643
- const diff = editDiffText(row);
644
- if (diff === undefined) return [];
645
- const raw = diff.split("\n");
646
- if (raw[raw.length - 1] === "") raw.pop();
647
- const lines: string[] = [];
648
- for (const line of raw.slice(0, MAX_EDIT_DIFF_LINES)) {
649
- const clean = sanitizeInline(line).trimEnd();
650
- if (clean.trim() === "...") {
651
- lines.push(fgCollapsed(theme, "muted", " ..."));
652
- continue;
653
- }
654
- const match = EDIT_DIFF_LINE_RE.exec(clean);
655
- if (!match) continue;
656
- const color =
657
- match[1] === "+"
658
- ? "toolDiffAdded"
659
- : match[1] === "-"
660
- ? "toolDiffRemoved"
661
- : "toolDiffContext";
662
- lines.push(theme.fg(color, ` ${match[1]}${match[2]}${match[3]}`));
663
- }
664
- if (raw.length > MAX_EDIT_DIFF_LINES) {
665
- lines.push(
666
- fgCollapsed(
667
- theme,
668
- "muted",
669
- ` ... +${raw.length - MAX_EDIT_DIFF_LINES} more`,
670
- ),
671
- );
672
- }
673
- return lines;
674
- }
675
-
676
682
  function outcomeSummary(row: ToolExecutionRow): string | undefined {
677
683
  // Self-rendered tools own their result framing; the built-in heuristics
678
684
  // (line counts, diff stats) describe default-shell rows only, so their
@@ -724,22 +730,6 @@ function renderedOutcome(
724
730
  return fgCollapsed(theme, "toolOutput", `→ ${summary}`);
725
731
  }
726
732
 
727
- function renderedGroupedOutcome(
728
- row: ToolExecutionRow,
729
- theme: ThemeLike,
730
- ): string | undefined {
731
- const outcome = renderedOutcome(row, theme);
732
- if (outcome) return outcome;
733
- if (isLiveRow(row)) {
734
- const elapsed = liveElapsedText(row);
735
- return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
736
- }
737
- // Self-rendered tools have no per-tool outcome summary; keep the group's
738
- // settled tail consistent with collapsed singletons.
739
- if (row.getRenderShell?.() === "self") return renderedGenericOutcome(theme);
740
- return undefined;
741
- }
742
-
743
733
  // A budget too small for the suffix keeps literal styled text instead, so a
744
734
  // one-column label never collapses into a bare ellipsis or loses its status.
745
735
  function truncateStyled(text: string, width: number, suffix = "…"): string {
@@ -810,11 +800,6 @@ function renderedCallSummary(
810
800
  width: number,
811
801
  theme: ThemeLike,
812
802
  ): string {
813
- // Self-rendered call components change shape across the live/settled
814
- // boundary (the adapter's call disappears once settled), so always build
815
- // the summary from the stable args label instead of scraping the preview.
816
- if (row.getRenderShell?.() === "self") return selfRenderedSummary(row, width);
817
-
818
803
  let component = row.callRendererComponent;
819
804
  if (!component && Array.isArray(row.contentBox?.children)) {
820
805
  component = row.contentBox.children[0] as ComponentLike | undefined;
@@ -865,39 +850,60 @@ function renderedCallSummary(
865
850
  : fgCollapsed(theme, "muted", "(no arguments)");
866
851
  }
867
852
 
868
- function styledCallLabel(
869
- label: string,
870
- theme: ThemeLike,
871
- color = "muted",
872
- ): string {
873
- const plain = sanitizeInline(stripAnsi(label)).trim();
874
- const match = /^(\S+)(.*)$/s.exec(plain);
875
- if (!match) return fgCollapsed(theme, color, plain);
876
- const rest = match[2] ?? "";
877
- // The colon joins the bolded name to its content. Failed rows stay
878
- // uniformly error-colored; settled rows use the collapsed mute — same
879
- // color for the bolded name and the call content.
880
- return (
881
- fgCollapsed(theme, color, match[1] ?? "", true) +
882
- (rest ? fgCollapsed(theme, color, `:${rest}`) : "")
883
- );
884
- }
885
-
886
- function collapsedCallLabel(
853
+ // The collapsed-row seam lives here and nowhere else: which anchor a row
854
+ // takes, how that anchor is styled, and the call text that follows it.
855
+ // Anchors always render bold and take no joining colon — `│ ● src/a.ts`,
856
+ // `│ $ npm test` — and subagent fallbacks keep their `↪ subagent …`
857
+ // reading instead of a glyph. Callers append the outcome tail.
858
+ function collapsedSeam(
887
859
  row: ToolExecutionRow,
888
860
  width: number,
889
861
  theme: ThemeLike,
890
- color = "muted",
891
- ): string {
892
- if (row.getRenderShell?.() === "self") {
893
- return styledCallLabel(selfRenderedCallLabel(row), theme, color);
894
- }
862
+ color: string,
863
+ ): { anchor: string; content: string } {
864
+ const isBash = row.toolName === "bash";
865
+ const isSubagent = row.toolName === "subagent";
866
+ const glyph = isBash
867
+ ? "$"
868
+ : (TOOL_CALL_GLYPHS.get(row.toolName ?? "") ?? "*");
869
+ const anchor = isSubagent
870
+ ? `${fgCollapsed(theme, color, SUBAGENT_MARKER, true)} `
871
+ : `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} ${fgCollapsed(
872
+ theme,
873
+ color,
874
+ glyph,
875
+ true,
876
+ )} `;
877
+ const budget = Math.max(1, width - visibleWidth(anchor));
878
+ // Self-rendered rows build their own `tool {args}` label — Pi's call title
879
+ // is gone once settled — while ordinary rows scrape the rendered call
880
+ // line, which already drops its heading token.
881
+ const selfRendered = row.getRenderShell?.() === "self";
882
+ const source = selfRendered
883
+ ? selfRenderedCallLabel(row)
884
+ : renderedCallSummary(row, budget, theme);
885
+ const plain = sanitizeInline(stripAnsi(source)).trim();
886
+ // The anchor stands in for the label's leading token. Only self-rendered
887
+ // labels still carry it; a subagent fallback keeps `subagent` as its own
888
+ // heading, which the scrape dropped.
889
+ const text = isSubagent
890
+ ? `subagent${plain ? ` ${plain}` : ""}`
891
+ : selfRendered
892
+ ? dropCallToken(plain, row.toolName ?? "tool")
893
+ : plain;
894
+ return {
895
+ anchor,
896
+ content: fgCollapsed(theme, color, text || "(no arguments)"),
897
+ };
898
+ }
895
899
 
896
- const token = row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
897
- const summary = renderedCallSummary(row, Math.max(1, width), theme);
898
- const plainSummary = stripAnsi(summary).trim();
899
- const label = plainSummary ? `${token} ${plainSummary}` : token;
900
- return styledCallLabel(label, theme, color);
900
+ // Drops the leading token an anchor now stands for, with whichever separator
901
+ // the label joined it with.
902
+ function dropCallToken(plain: string, token: string): string {
903
+ if (!plain.startsWith(token)) return plain;
904
+ const rest = plain.slice(token.length);
905
+ if (!/^:?\s|^:$/.test(rest)) return plain;
906
+ return rest.replace(/^:?\s*/, "");
901
907
  }
902
908
 
903
909
  function collapsedOutcome(
@@ -924,30 +930,28 @@ function collapsedOutcome(
924
930
  return renderedOutcome(row, theme);
925
931
  }
926
932
 
927
- function collapsedHeadline(
933
+ // One collapsed row — head and outcome tail — at the given width. Singleton
934
+ // and grouped rows differ only in what surrounds the line.
935
+ function collapsedRowLine(
928
936
  row: ToolExecutionRow,
929
937
  width: number,
930
938
  theme: ThemeLike,
931
939
  ): string {
932
940
  // Failed rows render entirely in error so the row reads as the one that
933
- // failed, not just its outcome tail. Only the tool name is bold.
934
- const tone = rowHasFailed(row) ? "error" : "muted";
935
- const marker =
936
- row.toolName === "subagent"
937
- ? `${fgCollapsed(theme, tone, SUBAGENT_MARKER)} `
938
- : `${fgCollapsedRail(theme, tone, GROUP_CALL_MARKER)} `;
939
- const budget = Math.max(1, width - visibleWidth(marker));
940
- const label = collapsedCallLabel(row, budget, theme, tone);
941
+ // failed, not just its outcome tail. Only the row's anchor stays bold.
942
+ const color = rowHasFailed(row) ? "error" : "muted";
943
+ const seam = collapsedSeam(row, width, theme, color);
944
+ const budget = Math.max(1, width - visibleWidth(seam.anchor));
941
945
  const outcome = collapsedOutcome(row, budget, theme);
942
946
  // The truncation suffix inherits the row tone; pi-tui's truncation resets
943
947
  // around a plain suffix, which would render it in the terminal default
944
948
  // foreground instead of the row color.
945
- const suffix = fgCollapsed(theme, tone, "…");
949
+ const suffix = fgCollapsed(theme, color, "…");
946
950
  return (
947
- marker +
951
+ seam.anchor +
948
952
  (outcome
949
- ? fitSummaryTail(label, outcome, budget, suffix)
950
- : fitSummary(label, budget, suffix))
953
+ ? fitSummaryTail(seam.content, outcome, budget, suffix)
954
+ : fitSummary(seam.content, budget, suffix))
951
955
  );
952
956
  }
953
957
 
@@ -1247,78 +1251,19 @@ function renderCollapsedToolRow(
1247
1251
  row.toolName === "subagent"
1248
1252
  ? renderSubagentPlan(row, layout.contentWidth, theme)
1249
1253
  : undefined;
1250
- const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1251
- if (row.toolName === "edit") {
1252
- body.push(...renderedEditDiffLines(row, theme));
1253
- }
1254
+ const body = plan ?? [collapsedRowLine(row, layout.contentWidth, theme)];
1254
1255
  body.push(...imageResultLines(row, layout.contentWidth, theme));
1255
1256
  return ["", ...insetLines(body, width)];
1256
1257
  }
1257
1258
 
1258
- function groupedChildLabel(
1259
- row: ToolExecutionRow,
1260
- width: number,
1261
- theme: ThemeLike,
1262
- color: string = "muted",
1263
- ): string {
1264
- const label = collapsedCallLabel(row, width, theme, color);
1265
- const plain = stripAnsi(label);
1266
- const toolName = row.toolName ?? "tool";
1267
- let prefix = "";
1268
- if (plain === toolName) {
1269
- prefix = toolName;
1270
- } else if (plain.startsWith(`${toolName}:`)) {
1271
- const whitespace = /^\s*/.exec(plain.slice(toolName.length + 1))?.[0] ?? "";
1272
- prefix = `${toolName}:${whitespace}`;
1273
- }
1274
- const prefixWidth = visibleWidth(prefix);
1275
- const child = sliceByColumn(
1276
- label,
1277
- prefixWidth,
1278
- Math.max(0, visibleWidth(label) - prefixWidth),
1279
- true,
1280
- );
1281
- return child || fgCollapsed(theme, color, "(no arguments)");
1282
- }
1283
-
1284
1259
  function renderGroupedCallLines(
1285
1260
  rows: ToolExecutionRow[],
1286
1261
  width: number,
1287
1262
  theme: ThemeLike,
1288
1263
  ): Array<{ row: ToolExecutionRow; lines: string[] }> {
1289
1264
  const segments: Array<{ row: ToolExecutionRow; lines: string[] }> = [];
1290
- let previousToolName: string | undefined;
1291
1265
  for (const row of rows) {
1292
- const lines: string[] = [];
1293
- const toolName = row.toolName ?? "tool";
1294
- if (toolName !== previousToolName) {
1295
- // Keep the rail unbroken across subgroup spacing and put each bold
1296
- // tool name beside it. The separator belongs to the new row so
1297
- // click-routing heights stay aligned with the rendered lines.
1298
- if (previousToolName !== undefined) {
1299
- lines.push(fgCollapsedRail(theme, "muted", GROUP_CALL_MARKER));
1300
- }
1301
- lines.push(
1302
- `${fgCollapsedRail(theme, "muted", GROUP_CALL_MARKER)} ${fgCollapsed(theme, "toolTitle", toolName, true)}`,
1303
- );
1304
- previousToolName = toolName;
1305
- }
1306
-
1307
- const color = rowHasFailed(row) ? "error" : "muted";
1308
- const prefix = `${fgCollapsedRail(theme, color, GROUP_CALL_MARKER)} `;
1309
- const budget = Math.max(1, width - visibleWidth(prefix));
1310
- const label = groupedChildLabel(row, budget, theme, color);
1311
- const outcome = collapsedOutcome(row, budget, theme);
1312
- const suffix = fgCollapsed(theme, color, "…");
1313
- lines.push(
1314
- prefix +
1315
- (outcome
1316
- ? fitSummaryTail(label, outcome, budget, suffix)
1317
- : fitSummary(label, budget, suffix)),
1318
- );
1319
- if (row.toolName === "edit")
1320
- lines.push(...renderedEditDiffLines(row, theme));
1321
- segments.push({ row, lines });
1266
+ segments.push({ row, lines: [collapsedRowLine(row, width, theme)] });
1322
1267
  }
1323
1268
  return segments;
1324
1269
  }
@@ -1387,8 +1332,7 @@ function renderGroupedToolRows(
1387
1332
  width,
1388
1333
  ),
1389
1334
  ];
1390
- // The leading blank and the first segment (tool-name header + call line)
1391
- // both belong to the first member; every later member owns its lines.
1335
+ // The first member owns the leading blank; every member owns its call line.
1392
1336
  const heights = segments.map((segment, index) => ({
1393
1337
  component: segment.row,
1394
1338
  height: index === 0 ? segment.lines.length + 1 : segment.lines.length,
@@ -1479,7 +1423,6 @@ function renderContainerWithToolGroups(
1479
1423
  continue;
1480
1424
  }
1481
1425
 
1482
- for (const member of group) presentation.rowGroups.set(member, group);
1483
1426
  // Use a live member while any call is pending, then the first member once
1484
1427
  // settled. Either way, the grouped row keeps the same number of lines.
1485
1428
  const shellRow = group.find(isLiveRow) ?? child;
@@ -1600,6 +1543,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1600
1543
  const existing = proto[PRESENTATION_PATCHED];
1601
1544
  if (existing) {
1602
1545
  existing.owners++;
1546
+ existing.disabled = false;
1603
1547
  return existing;
1604
1548
  }
1605
1549
  const legacy = proto[LEGACY_PRESENTATION_PATCHED];
@@ -1616,16 +1560,20 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1616
1560
  const state: PresentationPatchState = {
1617
1561
  owners: 1,
1618
1562
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1563
+ expandedTools: envToolNames(EXPANDED_TOOLS_ENV),
1619
1564
  groupCache: new WeakMap(),
1620
1565
  collapsedCache: new WeakMap(),
1621
1566
  rowVersions: new WeakMap(),
1622
- rowGroups: new WeakMap(),
1623
1567
  originalRender: proto.render,
1624
1568
  originalUpdateDisplay: proto.updateDisplay,
1625
1569
  };
1626
- const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
1570
+ const patchedUpdateDisplay = function updateDisplayWithToolPresentation(
1627
1571
  this: ToolExecutionRow,
1628
1572
  ): void {
1573
+ if (state.disabled) {
1574
+ state.originalUpdateDisplay.call(this);
1575
+ return;
1576
+ }
1629
1577
  // Transcript rerenders reuse settled caches, but a real component
1630
1578
  // display update can change args, results, renderer output, or images
1631
1579
  // without changing the row's shape signature.
@@ -1633,6 +1581,17 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1633
1581
  state.groupCache.delete(this);
1634
1582
  state.collapsedCache.delete(this);
1635
1583
  state.originalUpdateDisplay.call(this);
1584
+ // Rows for an always-expanded tool skip the collapsed presentation.
1585
+ // Forcing the flag here, before grouping decides whether the row is
1586
+ // eligible, keeps Pi's native expanded block authoritative; it also
1587
+ // re-expands the row after a global collapse.
1588
+ if (
1589
+ this.expanded === false &&
1590
+ state.expandedTools.has(this.toolName ?? "") &&
1591
+ typeof this.setExpanded === "function"
1592
+ ) {
1593
+ this.setExpanded(true);
1594
+ }
1636
1595
  };
1637
1596
  // Pi 0.85 wraps settled tool results in a click-to-expand MouseRegion.
1638
1597
  // The custom collapsed row replaces those lines with a single summary, so
@@ -1647,6 +1606,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1647
1606
  event: unknown,
1648
1607
  ): unknown {
1649
1608
  const mouseEvent = event as { type?: unknown; button?: unknown };
1609
+ if (state.disabled) {
1610
+ return state.originalHandleMouse?.call(this, event);
1611
+ }
1650
1612
  if (
1651
1613
  state.theme &&
1652
1614
  this.expanded === false &&
@@ -1668,6 +1630,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1668
1630
  ): string[] {
1669
1631
  const theme = state.theme;
1670
1632
  if (
1633
+ state.disabled ||
1671
1634
  typeof this.expanded !== "boolean" ||
1672
1635
  typeof this.isPartial !== "boolean" ||
1673
1636
  this.expanded ||
@@ -1705,7 +1668,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1705
1668
  return cached.lines;
1706
1669
  }
1707
1670
  const lines = state.originalRender.call(this, width);
1708
- if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1671
+ if (drawsNoRowLines(this, lines)) {
1709
1672
  return lines;
1710
1673
  }
1711
1674
  try {
@@ -1726,7 +1689,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1726
1689
  // Partial (streaming) rows keep the collapsed presentation but are
1727
1690
  // never cached: their content changes with every streamed chunk.
1728
1691
  const lines = state.originalRender.call(this, width);
1729
- if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1692
+ if (drawsNoRowLines(this, lines)) {
1730
1693
  return lines;
1731
1694
  }
1732
1695
  try {
@@ -1763,6 +1726,7 @@ function uninstallPresentationPatch(
1763
1726
  if (!state || state.owners <= 0) return;
1764
1727
  state.owners--;
1765
1728
  if (state.owners > 0) return;
1729
+ state.disabled = true;
1766
1730
  const proto =
1767
1731
  ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
1768
1732
  [PRESENTATION_PATCHED]?: PresentationPatchState;
@@ -1813,17 +1777,12 @@ export default function (pi: ExtensionAPI) {
1813
1777
  ctx.ui.setToolsExpanded(false);
1814
1778
  });
1815
1779
 
1816
- pi.on("session_start", (_event, ctx) => {
1817
- if (patch) patch.theme = ctx.ui.theme;
1818
- if (bashPatch) bashPatch.theme = ctx.ui.theme;
1819
- ctx.ui.setToolsExpanded(false);
1820
- });
1821
-
1822
1780
  pi.on("session_shutdown", () => {
1823
1781
  if (released) return;
1824
1782
  released = true;
1825
1783
  uninstallGroupingPatch(grouping);
1826
1784
  uninstallPresentationPatch(patch);
1827
1785
  uninstallBashBlockPatch(bashPatch);
1786
+ uninstallInfoVisibility();
1828
1787
  });
1829
1788
  }
@@ -4,13 +4,13 @@
4
4
  // duplicated between the main entrypoint (the /toggle-info command) and the
5
5
  // thinking-block-merger entrypoint (the strip). globalThis keeps it singular.
6
6
 
7
- type InfoVisibilityState = { hidden: boolean };
7
+ type InfoVisibilityState = { hidden: boolean; owners: number };
8
8
 
9
9
  const STATE_KEY = Symbol.for("kg.pi.toolCallMarkers.infoVisibility.v1");
10
10
 
11
11
  function infoVisibilityState(): InfoVisibilityState {
12
12
  const root = globalThis as Record<symbol, InfoVisibilityState | undefined>;
13
- return (root[STATE_KEY] ??= { hidden: false });
13
+ return (root[STATE_KEY] ??= { hidden: false, owners: 0 });
14
14
  }
15
15
 
16
16
  export function infoVisibilityHidden(): boolean {
@@ -20,3 +20,17 @@ export function infoVisibilityHidden(): boolean {
20
20
  export function setInfoVisibilityHidden(hidden: boolean): void {
21
21
  infoVisibilityState().hidden = hidden;
22
22
  }
23
+
24
+ // The filter hook lives in a process-global registry, so it is owned like the
25
+ // prototype patches: it outlives every install that loaded this module and
26
+ // disappears with the last one.
27
+ export function retainInfoVisibilityOwner(): void {
28
+ infoVisibilityState().owners += 1;
29
+ }
30
+
31
+ // Returns the owners left after this release.
32
+ export function releaseInfoVisibilityOwner(): number {
33
+ const state = infoVisibilityState();
34
+ state.owners = Math.max(0, state.owners - 1);
35
+ return state.owners;
36
+ }
@@ -6,6 +6,8 @@ import {
6
6
  import { chatContainerHooks } from "./container-hooks.ts";
7
7
  import {
8
8
  infoVisibilityHidden,
9
+ releaseInfoVisibilityOwner,
10
+ retainInfoVisibilityOwner,
9
11
  setInfoVisibilityHidden,
10
12
  } from "./info-visibility-state.ts";
11
13
  import { refreshThinkingVisibility } from "./thinking-block-merger.ts";
@@ -42,6 +44,7 @@ function infoFilterHook(
42
44
  }
43
45
 
44
46
  export function installInfoVisibility(pi: ExtensionAPI): void {
47
+ retainInfoVisibilityOwner();
45
48
  // The hook registry is process-global; dedupe by name so hot reloads swap
46
49
  // rather than stack.
47
50
  const hooks = chatContainerHooks();
@@ -67,8 +70,17 @@ export function installInfoVisibility(pi: ExtensionAPI): void {
67
70
  },
68
71
  });
69
72
 
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
+ // Every session starts in the default collapsed-but-visible state.
73
74
  pi.on("session_start", () => setInfoVisibilityHidden(false));
74
75
  }
76
+
77
+ // Released from the entrypoint's own shutdown handler, so a session still
78
+ // tears down with one handler per install. The registry and the toggle are
79
+ // process-global: while any owner remains, the hook stays; the last owner
80
+ // removes it, so execution rows cannot keep disappearing from a transcript
81
+ // this package no longer renders.
82
+ export function uninstallInfoVisibility(): void {
83
+ if (releaseInfoVisibilityOwner() > 0) return;
84
+ chatContainerHooks().delete(infoFilterHook);
85
+ setInfoVisibilityHidden(false);
86
+ }