@pi-kaush/pi-tool-call-markers 0.3.5 → 0.3.7

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,15 +15,21 @@ 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";
23
+ import { isQuestionToolCall, renderQuestionBlock } from "./question-block.ts";
24
+ import { sanitizeInline } from "./sanitize.ts";
20
25
 
21
26
  const OUTER_INSET = 2;
22
27
  const SUBAGENT_MARKER = "↪";
23
28
  const GROUP_CALL_MARKER = "│";
24
29
  // Collapsed rows anchor with a glyph instead of repeating the tool name;
25
30
  // tools that read as one family share a glyph, and unmapped tools fall back
26
- // to `*`.
31
+ // to `*`. A single space separates the glyph from the call content: the
32
+ // anchor is punctuation enough without a joining colon.
27
33
  const TOOL_CALL_GLYPHS: ReadonlyMap<string, string> = new Map([
28
34
  ["read", "●"],
29
35
  ["write", "+"],
@@ -87,13 +93,16 @@ type ToolExecutionRow = {
87
93
 
88
94
  type PresentationPatchState = {
89
95
  owners: number;
96
+ // Set at the final owner's shutdown. A wrapper that another extension has
97
+ // buried cannot be uninstalled, so it delegates instead of outliving its
98
+ // owner; a later install re-enables it.
99
+ disabled?: boolean;
90
100
  theme?: ThemeLike;
91
101
  collapseParallel: boolean;
92
102
  expandedTools: ReadonlySet<string>;
93
103
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
94
104
  collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
95
105
  rowVersions: WeakMap<ToolExecutionRow, number>;
96
- rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
97
106
  originalRender: (width: number) => string[];
98
107
  originalUpdateDisplay: () => void;
99
108
  originalHandleMouse?: (this: ToolExecutionRow, event: unknown) => unknown;
@@ -153,24 +162,6 @@ function stripAnsi(text: string): string {
153
162
  return text.replace(ANSI_RE, "");
154
163
  }
155
164
 
156
- // Command output can carry cursor-moving control bytes, raw terminal
157
- // sequences, line feeds, and tabs. Collapsed rows are single-line, so result
158
- // and scraped text is stripped of display sequences and all C0 controls before
159
- // it can reach a row; otherwise control bytes could split or overwrite it.
160
- const INLINE_CONTROL_RE = /[\x00-\x1f\x7f]/g;
161
- // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
162
- // leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
163
- // hyperlinks, titles) end at their first BEL/ST terminator; a payload match
164
- // must stop there too, because the visible text sits BETWEEN two OSC
165
- // sequences — Pi wraps read paths as `ESC]8;;url ESC\ <path> ESC]8;; ESC\`,
166
- // and a greedy `[^\x07]*` would swallow that path along with the sequences.
167
- const DISPLAY_ANSI_RE =
168
- /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
169
-
170
- function sanitizeInline(text: string): string {
171
- return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
172
- }
173
-
174
165
  function hasVisibleContent(line: string): boolean {
175
166
  return stripAnsi(line).trim().length > 0;
176
167
  }
@@ -182,22 +173,12 @@ function rowSignatureOf(row: ToolExecutionRow): string {
182
173
  return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
183
174
  }
184
175
 
185
- // Theme samples are five theme.fg calls; computing them per row per frame
186
- // would reintroduce the per-keystroke cost the render caches remove, so
187
- // cache by theme object identity (a theme switch swaps the object).
188
- const themeSamples = new WeakMap<ThemeLike, string>();
176
+ // Render caches hold lines with resolved colors baked in, so they key on the
177
+ // palette's values rather than the theme object: Pi swaps the colors behind a
178
+ // stable Proxy, which made an identity key survive a theme switch. Probing is
179
+ // a handful of map lookups per row per frame.
189
180
  function themeSampleFor(theme: ThemeLike): string {
190
- let sample = themeSamples.get(theme);
191
- if (!sample) {
192
- sample =
193
- theme.fg("toolTitle", "x") +
194
- theme.fg("muted", "x") +
195
- theme.fg("dim", "x") +
196
- theme.fg("warning", "x") +
197
- theme.fg("error", "x");
198
- themeSamples.set(theme, sample);
199
- }
200
- return sample;
181
+ return paletteSample(theme);
201
182
  }
202
183
 
203
184
  type InsetLayout = {
@@ -461,10 +442,6 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
461
442
  return title ?? token;
462
443
  }
463
444
 
464
- function selfRenderedSummary(row: ToolExecutionRow, width: number): string {
465
- return fitSummary(selfRenderedCallLabel(row), width);
466
- }
467
-
468
445
  function isToolExecutionRow(
469
446
  component: unknown,
470
447
  ): component is ToolExecutionRow & ComponentLike {
@@ -558,6 +535,16 @@ function hasToolSiblingInAssistantBatch(
558
535
  return false;
559
536
  }
560
537
 
538
+ // A self-rendered tool opts out of the transcript by drawing no lines. The
539
+ // singleton and grouped paths must agree on that, or grouping resurrects a
540
+ // row the tool deliberately hid.
541
+ function drawsNoRowLines(
542
+ row: ToolExecutionRow,
543
+ nativeLines: string[],
544
+ ): boolean {
545
+ return nativeLines.length === 0 && (row.imageComponents?.length ?? 0) === 0;
546
+ }
547
+
561
548
  function isGroupableToolRow(
562
549
  row: ToolExecutionRow,
563
550
  children: unknown[],
@@ -566,6 +553,8 @@ function isGroupableToolRow(
566
553
  state: PresentationPatchState,
567
554
  ): boolean {
568
555
  if (row.toolName === "subagent") return false;
556
+ if (isQuestionToolCall(row)) return false;
557
+ if (drawsNoRowLines(row, renderAt(index))) return false;
569
558
  if (
570
559
  !isLiveRow(row) &&
571
560
  !isCollapsibleSuccess(row) &&
@@ -796,11 +785,6 @@ function renderedCallSummary(
796
785
  width: number,
797
786
  theme: ThemeLike,
798
787
  ): string {
799
- // Self-rendered call components change shape across the live/settled
800
- // boundary (the adapter's call disappears once settled), so always build
801
- // the summary from the stable args label instead of scraping the preview.
802
- if (row.getRenderShell?.() === "self") return selfRenderedSummary(row, width);
803
-
804
788
  let component = row.callRendererComponent;
805
789
  if (!component && Array.isArray(row.contentBox?.children)) {
806
790
  component = row.contentBox.children[0] as ComponentLike | undefined;
@@ -851,56 +835,60 @@ function renderedCallSummary(
851
835
  : fgCollapsed(theme, "muted", "(no arguments)");
852
836
  }
853
837
 
854
- function styledCallLabel(
855
- label: string,
856
- theme: ThemeLike,
857
- color = "muted",
858
- ): string {
859
- const plain = sanitizeInline(stripAnsi(label)).trim();
860
- const match = /^(\S+)(.*)$/s.exec(plain);
861
- if (!match) return fgCollapsed(theme, color, plain);
862
- const rest = match[2] ?? "";
863
- // The colon joins the bolded name to its content. Failed rows stay
864
- // uniformly error-colored; settled rows use the collapsed mute — same
865
- // color for the bolded name and the call content.
866
- return (
867
- fgCollapsed(theme, color, match[1] ?? "", true) +
868
- (rest ? fgCollapsed(theme, color, `:${rest}`) : "")
869
- );
870
- }
871
-
872
- function collapsedCallLabel(
838
+ // The collapsed-row seam lives here and nowhere else: which anchor a row
839
+ // takes, how that anchor is styled, and the call text that follows it.
840
+ // Anchors always render bold and take no joining colon — `│ ● src/a.ts`,
841
+ // `│ $ npm test` — and subagent fallbacks keep their `↪ subagent …`
842
+ // reading instead of a glyph. Callers append the outcome tail.
843
+ function collapsedSeam(
873
844
  row: ToolExecutionRow,
874
845
  width: number,
875
846
  theme: ThemeLike,
876
- color = "muted",
877
- ): string {
878
- if (row.getRenderShell?.() === "self") {
879
- return styledCallLabel(selfRenderedCallLabel(row), theme, color);
880
- }
881
-
882
- const token = row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
883
- const summary = renderedCallSummary(row, Math.max(1, width), theme);
884
- const plainSummary = stripAnsi(summary).trim();
885
- const label = plainSummary ? `${token} ${plainSummary}` : token;
886
- return styledCallLabel(label, theme, color);
847
+ color: string,
848
+ ): { anchor: string; content: string } {
849
+ const isBash = row.toolName === "bash";
850
+ const isSubagent = row.toolName === "subagent";
851
+ const glyph = isBash
852
+ ? "$"
853
+ : (TOOL_CALL_GLYPHS.get(row.toolName ?? "") ?? "*");
854
+ const anchor = isSubagent
855
+ ? `${fgCollapsed(theme, color, SUBAGENT_MARKER, true)} `
856
+ : `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} ${fgCollapsed(
857
+ theme,
858
+ color,
859
+ glyph,
860
+ true,
861
+ )} `;
862
+ const budget = Math.max(1, width - visibleWidth(anchor));
863
+ // Self-rendered rows build their own `tool {args}` label — Pi's call title
864
+ // is gone once settled — while ordinary rows scrape the rendered call
865
+ // line, which already drops its heading token.
866
+ const selfRendered = row.getRenderShell?.() === "self";
867
+ const source = selfRendered
868
+ ? selfRenderedCallLabel(row)
869
+ : renderedCallSummary(row, budget, theme);
870
+ const plain = sanitizeInline(stripAnsi(source)).trim();
871
+ // The anchor stands in for the label's leading token. Only self-rendered
872
+ // labels still carry it; a subagent fallback keeps `subagent` as its own
873
+ // heading, which the scrape dropped.
874
+ const text = isSubagent
875
+ ? `subagent${plain ? ` ${plain}` : ""}`
876
+ : selfRendered
877
+ ? dropCallToken(plain, row.toolName ?? "tool")
878
+ : plain;
879
+ return {
880
+ anchor,
881
+ content: fgCollapsed(theme, color, text || "(no arguments)"),
882
+ };
887
883
  }
888
884
 
889
- // Collapsed rows anchor their call text with a glyph instead of repeating
890
- // the tool name, grouped or not. Bash carries `$:` inside its own label and
891
- // the subagent fallback keeps its `↪ subagent` heading, so neither takes a
892
- // glyph.
893
- function collapsedCallMarker(
894
- row: ToolExecutionRow,
895
- theme: ThemeLike,
896
- color: string,
897
- ): string {
898
- if (row.toolName === "bash" || row.toolName === "subagent") return "";
899
- return fgCollapsed(
900
- theme,
901
- color,
902
- `${TOOL_CALL_GLYPHS.get(row.toolName ?? "") ?? "*"}: `,
903
- );
885
+ // Drops the leading token an anchor now stands for, with whichever separator
886
+ // the label joined it with.
887
+ function dropCallToken(plain: string, token: string): string {
888
+ if (!plain.startsWith(token)) return plain;
889
+ const rest = plain.slice(token.length);
890
+ if (!/^:?\s|^:$/.test(rest)) return plain;
891
+ return rest.replace(/^:?\s*/, "");
904
892
  }
905
893
 
906
894
  function collapsedOutcome(
@@ -927,31 +915,28 @@ function collapsedOutcome(
927
915
  return renderedOutcome(row, theme);
928
916
  }
929
917
 
930
- function collapsedHeadline(
918
+ // One collapsed row — head and outcome tail — at the given width. Singleton
919
+ // and grouped rows differ only in what surrounds the line.
920
+ function collapsedRowLine(
931
921
  row: ToolExecutionRow,
932
922
  width: number,
933
923
  theme: ThemeLike,
934
924
  ): string {
935
925
  // Failed rows render entirely in error so the row reads as the one that
936
926
  // failed, not just its outcome tail. Only the row's anchor stays bold.
937
- const tone = rowHasFailed(row) ? "error" : "muted";
938
- const marker =
939
- row.toolName === "subagent"
940
- ? `${fgCollapsed(theme, tone, SUBAGENT_MARKER)} `
941
- : `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} `;
942
- const glyph = collapsedCallMarker(row, theme, tone);
943
- const budget = Math.max(1, width - visibleWidth(marker));
944
- const label = glyph + collapsedChildLabel(row, budget, theme, tone);
927
+ const color = rowHasFailed(row) ? "error" : "muted";
928
+ const seam = collapsedSeam(row, width, theme, color);
929
+ const budget = Math.max(1, width - visibleWidth(seam.anchor));
945
930
  const outcome = collapsedOutcome(row, budget, theme);
946
931
  // The truncation suffix inherits the row tone; pi-tui's truncation resets
947
932
  // around a plain suffix, which would render it in the terminal default
948
933
  // foreground instead of the row color.
949
- const suffix = fgCollapsed(theme, tone, "…");
934
+ const suffix = fgCollapsed(theme, color, "…");
950
935
  return (
951
- marker +
936
+ seam.anchor +
952
937
  (outcome
953
- ? fitSummaryTail(label, outcome, budget, suffix)
954
- : fitSummary(label, budget, suffix))
938
+ ? fitSummaryTail(seam.content, outcome, budget, suffix)
939
+ : fitSummary(seam.content, budget, suffix))
955
940
  );
956
941
  }
957
942
 
@@ -1246,45 +1231,20 @@ function renderCollapsedToolRow(
1246
1231
  width: number,
1247
1232
  theme: ThemeLike,
1248
1233
  ): string[] {
1234
+ // An asked question is a user-input moment, not an execution row: it takes
1235
+ // the submitted-prompt shell, which carries its own inset.
1236
+ const questionBlock = renderQuestionBlock(row, width, theme);
1237
+ if (questionBlock) return ["", ...questionBlock];
1249
1238
  const layout = insetLayout(width);
1250
1239
  const plan =
1251
1240
  row.toolName === "subagent"
1252
1241
  ? renderSubagentPlan(row, layout.contentWidth, theme)
1253
1242
  : undefined;
1254
- const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1243
+ const body = plan ?? [collapsedRowLine(row, layout.contentWidth, theme)];
1255
1244
  body.push(...imageResultLines(row, layout.contentWidth, theme));
1256
1245
  return ["", ...insetLines(body, width)];
1257
1246
  }
1258
1247
 
1259
- // Drops the call label's tool-name token: the row's glyph anchors the call
1260
- // instead. The `↪` subagent fallback keeps its own heading.
1261
- function collapsedChildLabel(
1262
- row: ToolExecutionRow,
1263
- width: number,
1264
- theme: ThemeLike,
1265
- color: string = "muted",
1266
- ): string {
1267
- const label = collapsedCallLabel(row, width, theme, color);
1268
- if (row.toolName === "subagent") return label;
1269
- const plain = stripAnsi(label);
1270
- const toolName = row.toolName ?? "tool";
1271
- let prefix = "";
1272
- if (plain === toolName) {
1273
- prefix = toolName;
1274
- } else if (plain.startsWith(`${toolName}:`)) {
1275
- const whitespace = /^\s*/.exec(plain.slice(toolName.length + 1))?.[0] ?? "";
1276
- prefix = `${toolName}:${whitespace}`;
1277
- }
1278
- const prefixWidth = visibleWidth(prefix);
1279
- const child = sliceByColumn(
1280
- label,
1281
- prefixWidth,
1282
- Math.max(0, visibleWidth(label) - prefixWidth),
1283
- true,
1284
- );
1285
- return child || fgCollapsed(theme, color, "(no arguments)");
1286
- }
1287
-
1288
1248
  function renderGroupedCallLines(
1289
1249
  rows: ToolExecutionRow[],
1290
1250
  width: number,
@@ -1292,26 +1252,7 @@ function renderGroupedCallLines(
1292
1252
  ): Array<{ row: ToolExecutionRow; lines: string[] }> {
1293
1253
  const segments: Array<{ row: ToolExecutionRow; lines: string[] }> = [];
1294
1254
  for (const row of rows) {
1295
- const lines: string[] = [];
1296
-
1297
- const color = rowHasFailed(row) ? "error" : "muted";
1298
- const prefix = `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} `;
1299
- const marker = collapsedCallMarker(row, theme, color);
1300
- const budget = Math.max(
1301
- 1,
1302
- width - visibleWidth(prefix) - visibleWidth(marker),
1303
- );
1304
- const label = collapsedChildLabel(row, budget, theme, color);
1305
- const outcome = collapsedOutcome(row, budget, theme);
1306
- const suffix = fgCollapsed(theme, color, "…");
1307
- lines.push(
1308
- prefix +
1309
- marker +
1310
- (outcome
1311
- ? fitSummaryTail(label, outcome, budget, suffix)
1312
- : fitSummary(label, budget, suffix)),
1313
- );
1314
- segments.push({ row, lines });
1255
+ segments.push({ row, lines: [collapsedRowLine(row, width, theme)] });
1315
1256
  }
1316
1257
  return segments;
1317
1258
  }
@@ -1471,7 +1412,6 @@ function renderContainerWithToolGroups(
1471
1412
  continue;
1472
1413
  }
1473
1414
 
1474
- for (const member of group) presentation.rowGroups.set(member, group);
1475
1415
  // Use a live member while any call is pending, then the first member once
1476
1416
  // settled. Either way, the grouped row keeps the same number of lines.
1477
1417
  const shellRow = group.find(isLiveRow) ?? child;
@@ -1592,6 +1532,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1592
1532
  const existing = proto[PRESENTATION_PATCHED];
1593
1533
  if (existing) {
1594
1534
  existing.owners++;
1535
+ existing.disabled = false;
1595
1536
  return existing;
1596
1537
  }
1597
1538
  const legacy = proto[LEGACY_PRESENTATION_PATCHED];
@@ -1612,13 +1553,16 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1612
1553
  groupCache: new WeakMap(),
1613
1554
  collapsedCache: new WeakMap(),
1614
1555
  rowVersions: new WeakMap(),
1615
- rowGroups: new WeakMap(),
1616
1556
  originalRender: proto.render,
1617
1557
  originalUpdateDisplay: proto.updateDisplay,
1618
1558
  };
1619
1559
  const patchedUpdateDisplay = function updateDisplayWithToolPresentation(
1620
1560
  this: ToolExecutionRow,
1621
1561
  ): void {
1562
+ if (state.disabled) {
1563
+ state.originalUpdateDisplay.call(this);
1564
+ return;
1565
+ }
1622
1566
  // Transcript rerenders reuse settled caches, but a real component
1623
1567
  // display update can change args, results, renderer output, or images
1624
1568
  // without changing the row's shape signature.
@@ -1651,6 +1595,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1651
1595
  event: unknown,
1652
1596
  ): unknown {
1653
1597
  const mouseEvent = event as { type?: unknown; button?: unknown };
1598
+ if (state.disabled) {
1599
+ return state.originalHandleMouse?.call(this, event);
1600
+ }
1654
1601
  if (
1655
1602
  state.theme &&
1656
1603
  this.expanded === false &&
@@ -1672,6 +1619,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1672
1619
  ): string[] {
1673
1620
  const theme = state.theme;
1674
1621
  if (
1622
+ state.disabled ||
1675
1623
  typeof this.expanded !== "boolean" ||
1676
1624
  typeof this.isPartial !== "boolean" ||
1677
1625
  this.expanded ||
@@ -1709,7 +1657,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1709
1657
  return cached.lines;
1710
1658
  }
1711
1659
  const lines = state.originalRender.call(this, width);
1712
- if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1660
+ if (drawsNoRowLines(this, lines)) {
1713
1661
  return lines;
1714
1662
  }
1715
1663
  try {
@@ -1730,7 +1678,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1730
1678
  // Partial (streaming) rows keep the collapsed presentation but are
1731
1679
  // never cached: their content changes with every streamed chunk.
1732
1680
  const lines = state.originalRender.call(this, width);
1733
- if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1681
+ if (drawsNoRowLines(this, lines)) {
1734
1682
  return lines;
1735
1683
  }
1736
1684
  try {
@@ -1767,6 +1715,7 @@ function uninstallPresentationPatch(
1767
1715
  if (!state || state.owners <= 0) return;
1768
1716
  state.owners--;
1769
1717
  if (state.owners > 0) return;
1718
+ state.disabled = true;
1770
1719
  const proto =
1771
1720
  ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
1772
1721
  [PRESENTATION_PATCHED]?: PresentationPatchState;
@@ -1817,17 +1766,12 @@ export default function (pi: ExtensionAPI) {
1817
1766
  ctx.ui.setToolsExpanded(false);
1818
1767
  });
1819
1768
 
1820
- pi.on("session_start", (_event, ctx) => {
1821
- if (patch) patch.theme = ctx.ui.theme;
1822
- if (bashPatch) bashPatch.theme = ctx.ui.theme;
1823
- ctx.ui.setToolsExpanded(false);
1824
- });
1825
-
1826
1769
  pi.on("session_shutdown", () => {
1827
1770
  if (released) return;
1828
1771
  released = true;
1829
1772
  uninstallGroupingPatch(grouping);
1830
1773
  uninstallPresentationPatch(patch);
1831
1774
  uninstallBashBlockPatch(bashPatch);
1775
+ uninstallInfoVisibility();
1832
1776
  });
1833
1777
  }
@@ -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
+ }
package/src/muted.ts CHANGED
@@ -19,27 +19,42 @@ type CollapsedTheme = {
19
19
  getFgAnsi?(color: string): string;
20
20
  };
21
21
 
22
- // Resolved per theme object (Pi's Theme throws on unknown tokens; themes
23
- // without getFgAnsi have no overrides), alongside index.ts's other
24
- // theme-identity-keyed caches.
25
- const ansiCache = new WeakMap<object, Map<string, string | null>>();
26
-
22
+ // Probed live, never cached: Pi swaps the colors behind a stable theme
23
+ // Proxy, so a value cached against the theme object outlives a theme switch.
24
+ // A probe is one map lookup, and the Theme throws on tokens it does not know
25
+ // (themes without getFgAnsi have no overrides at all).
27
26
  function tokenAnsi(theme: CollapsedTheme, token: string): string | null {
28
- let cache = ansiCache.get(theme);
29
- if (!cache) {
30
- cache = new Map();
31
- ansiCache.set(theme, cache);
32
- }
33
- if (cache.has(token)) return cache.get(token)!;
34
- let ansi: string | null = null;
35
27
  try {
36
28
  const resolved = theme.getFgAnsi?.(token);
37
- if (typeof resolved === "string") ansi = resolved;
29
+ return typeof resolved === "string" ? resolved : null;
38
30
  } catch {
39
- ansi = null;
31
+ return null;
40
32
  }
41
- cache.set(token, ansi);
42
- return ansi;
33
+ }
34
+
35
+ // The tokens this package resolves from a palette.
36
+ const PALETTE_TOKENS = [
37
+ TOOL_OVERRIDE_TOKEN,
38
+ THINKING_OVERRIDE_TOKEN,
39
+ DEFAULT_TOKEN,
40
+ "muted",
41
+ "toolOutput",
42
+ "accent",
43
+ "warning",
44
+ "error",
45
+ // Asked-question blocks paint with the prompt-shell tokens: the rail, the
46
+ // question text, and the surface behind both.
47
+ "borderAccent",
48
+ "text",
49
+ "userMessageBg",
50
+ "userMessageText",
51
+ ] as const;
52
+
53
+ // Revision key for rendered output that bakes resolved colors in. Compares
54
+ // the colors themselves, so a mid-session theme switch is visible to every
55
+ // cache that keys on it.
56
+ export function paletteSample(theme: CollapsedTheme): string {
57
+ return PALETTE_TOKENS.map((token) => tokenAnsi(theme, token) ?? "").join(",");
43
58
  }
44
59
 
45
60
  // Override token when defined, else the theme's syntaxComment color, else