@pi-kaush/pi-tool-call-markers 0.3.5 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,62 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Read colors from the live palette instead of caching them against the
6
+ theme object. Pi swaps the palette behind a stable theme Proxy, so every
7
+ identity-keyed cache survived a theme switch: collapsed rows, the `+ Thought`
8
+ label, and user `!` blocks kept the old colors until their content changed.
9
+ Probes are map lookups, so the render caches now key on the resolved colors
10
+ (`paletteSample`, including the bash-block line cache) and the label
11
+ resolves its style per call.
12
+
13
+ - Stop grouping from resurrecting a tool that opted out of the transcript. A
14
+ self-rendered tool that draws no lines stays hidden whether it renders alone
15
+ or beside another call: the singleton and grouped paths share one
16
+ draws-nothing rule.
17
+
18
+ - Go inert at the final owner's shutdown. Every prototype wrapper — tool
19
+ presentation, container grouping, user bash blocks, thinking — delegates to
20
+ the original when another extension's wrapper has buried it and removal is
21
+ impossible, so collapsed rows and restyled labels cannot outlive their owner.
22
+ A later install re-enables the same wrappers.
23
+
24
+ - Roll back a container hook that mutates the child list and then throws.
25
+ Its edit used to stick, dropping those rows from every later render. Both
26
+ copies of the shared hook contract changed together.
27
+
28
+ - Own and release `/toggle-info` with the rest of the extension: the
29
+ process-global filter hook and the hidden flag now leave with the last owner
30
+ instead of filtering a transcript this package no longer renders.
31
+
32
+ - Track thinking rows for the `/toggle-info` replay instead of every assistant
33
+ row, capped at 400 entries, so a long session or an abandoned branch cannot
34
+ pin every message the transcript ever showed.
35
+
36
+ - Delete state nothing reads (the row→group map) and the duplicate
37
+ `session_start` handler that applied the collapsed default twice.
38
+
39
+ - Drop the colon after every collapsed-row anchor: glyph rows read
40
+ `│ ● src/a.ts` and bash rows `│ $ npm test`. The anchor runs bold with a
41
+ single space before the call text, so the anchor alone separates the two.
42
+ The whole seam — glyph map, `$` prompt, subagent fallback heading, and the
43
+ tool-token drop — now lives in one function that both the singleton and
44
+ grouped render paths call, and self-rendered labels no longer leak their
45
+ tool name into the row under themes that tag instead of color.
46
+
47
+ - Drop the italics Pi puts on thinking. The hidden `+ Thought` label renders
48
+ plain in every theme — the live spinner keeps its level tint, the settled
49
+ row its muted tone, and a theme that resolves no color still loses the
50
+ italics — and the visible trace stays italic only while it streams, then
51
+ renders as ordinary transcript text once the row settles. The live/settled
52
+ rule is shared with the label, so un-flagged row rebuilds (resize, theme
53
+ switch) keep a live trace italic and a finished one plain.
54
+ `PI_TOOL_CALL_MARKERS_THOUGHT_COLOR=inherit` remains the only opt-out that
55
+ keeps Pi's native italic styling.
56
+
57
+ - Fail open when a theme rejects a thinking token. Pi's Theme throws on
58
+ tokens it does not know, so the label probe now falls back to the plain
59
+ treatment instead of throwing out of the row's update pass.
60
+
5
61
  - Restore Pi 0.85's click-to-expand under the custom collapsed rows. Pi routes
6
62
  mouse clicks by per-child rendered line heights, and the grouping render
7
63
  bypassed the native render that refreshes that cache, so clicks on anything
package/README.md CHANGED
@@ -15,13 +15,13 @@ Restart Pi or run `/reload`.
15
15
  Collapsed tool rows use semantic theme colors with no gear, background fill, box padding, or filled blank rows:
16
16
 
17
17
  ```text
18
- │ ●: src/a.ts 42 lines
19
- │ ●: src/b.ts 18 lines
20
- │ $: npm test → done
18
+ │ ● src/a.ts 42 lines
19
+ │ ● src/b.ts 18 lines
20
+ │ $ npm test → done
21
21
  ```
22
22
 
23
23
  - **Two-column outer inset.** Tool markers and image output align with an inset conversation surface. Very narrow terminals reduce the decoration before useful content.
24
- - **`│` tool-call rail.** Every collapsed call anchors on a glyph instead of its tool name: `●` read, `+` write, `±` edit, `○` local search, `≡` ls, `↗` web search and fetch, and `$` bash; unmapped tools fall back to `*`. Grouped rows flow directly across tool types.
24
+ - **`│` tool-call rail.** Every collapsed call anchors on a bold glyph instead of its tool name — `●` read, `+` write, `±` edit, `○` local search, `≡` ls, `↗` web search and fetch, and `$` bash; unmapped tools fall back to `*` — with a single space between the anchor and the call text, no joining colon. Grouped rows flow directly across tool types.
25
25
  - **Semantic, low-contrast status.** The anchor and the call content share the collapsed mute, pending state is warning-colored, and failures remain error-colored. Ordinary tool states have no background.
26
26
  - **Width-safe outcome tails.** Long summaries truncate before useful tails such as `→ done`, `→ 42 lines`, `→ +2/-1`, or a `bash` duration.
27
27
  - **Stable running groups.** Adjacent calls group as they appear, including settled failures, which stay error-colored inside the group. Pending state and elapsed `bash` time settle into the final outcome without changing the row count.
@@ -78,7 +78,9 @@ When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoni
78
78
  + Thought · 2.5s
79
79
  ```
80
80
 
81
- The live label samples Pi's native braille spinner sequence from the content updates Pi already renders; it does not add a timer. The adapter stores the first local streaming timestamp per assistant row in a `WeakMap`. A restored message or an older runtime with no streaming argument uses `+ Thought`. Visible-thinking mode remains native. There is no interval, timeout, render request, model call, or network work.
81
+ The live label samples Pi's native braille spinner sequence from the content updates Pi already renders; it does not add a timer. The adapter stores the first local streaming timestamp per assistant row in a `WeakMap`. A restored message or an older runtime with no streaming argument uses `+ Thought`. There is no interval, timeout, render request, model call, or network work.
82
+
83
+ Pi renders thinking labels and traces italic. This package drops those italics: both labels read as plain collapsed rows (the live spinner keeps its thinking-level tint, the settled row the muted tone, and a theme that resolves no color still loses the italics), and a visible trace stays italic only while it streams, then settles into ordinary transcript text. `PI_TOOL_CALL_MARKERS_THOUGHT_COLOR=inherit` is the opt-out that keeps Pi's native italic `thinkingText` styling.
82
84
 
83
85
  ## Local development
84
86
 
@@ -131,10 +133,14 @@ Pi has no public hook for native tool rows, transcript grouping, or per-message
131
133
 
132
134
  - `ToolExecutionComponent` for collapsed presentation;
133
135
  - `Container` for adjacent grouping; and
134
- - `AssistantMessageComponent.updateContent` for display-only thinking merging and lifecycle labels.
136
+ - `AssistantMessageComponent.updateContent` for display-only thinking merging, lifecycle labels, and the settled trace's italics.
135
137
 
136
138
  Each adapter feature-detects the fields and methods it needs, keeps the original method, uses an idempotency symbol, catches cosmetic failures, and restores the original on `session_shutdown` when it still owns the patch. Unsupported shapes fail open to Pi's native rendering. The thinking adapter continues adjacent merging even when the private label shape is unavailable.
137
139
 
140
+ Teardown is owner-counted and inert: when another extension's wrapper sits above one of these, removal is impossible, so the buried wrapper delegates to the original instead of leaving collapsed rows or restyled labels behind after its owner shuts down. A later install re-enables it. The shared chat-container hook contract applies the same fail-open rule to hooks: a hook that throws after editing the child list has its edit rolled back, so a failed hook cannot drop rows from the transcript.
141
+
142
+ Colors are read live from the theme, so a mid-session theme switch repaints collapsed rows, labels, and `!` blocks; cached render output keys on the resolved palette rather than on the theme object, which Pi keeps stable across a switch.
143
+
138
144
  Expanded tools always use Pi's native renderer. The collapsed tool shell owns its two-column inset directly; transcript layout extensions should leave tool rows unchanged, preventing load-order-dependent double padding.
139
145
 
140
146
  > TODO: migrate these adapters to public Pi transcript and tool-rendering APIs when available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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/bash-block.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BashExecutionComponent } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { paletteSample } from "./muted.ts";
3
4
 
4
5
  type ThemeLike = {
5
6
  bold(text: string): string;
@@ -199,8 +200,15 @@ type BashRow = { status?: unknown };
199
200
 
200
201
  type BashBlockPatchState = {
201
202
  owners: number;
203
+ // Set at the final owner's shutdown. A wrapper another extension has buried
204
+ // cannot be uninstalled, so it delegates instead of outliving its owner; a
205
+ // later install re-enables it.
206
+ disabled?: boolean;
202
207
  theme?: ThemeLike;
203
- cache: WeakMap<object, { width: number; src: string[]; lines: string[] }>;
208
+ cache: WeakMap<
209
+ object,
210
+ { width: number; src: string[]; lines: string[]; palette: string }
211
+ >;
204
212
  originalRender: (width: number) => string[];
205
213
  patchedRender?: (width: number) => string[];
206
214
  };
@@ -213,6 +221,7 @@ type BashBlockPatchState = {
213
221
  // TODO: Replace prototype patching with a public Pi rendering API when available.
214
222
  function installBashBlockPatch(): BashBlockPatchState | undefined {
215
223
  try {
224
+ console.error("BB new state");
216
225
  const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
217
226
  [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
218
227
  render?: (width: number) => string[];
@@ -222,6 +231,8 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
222
231
  const existing = proto[BASH_BLOCK_PATCHED];
223
232
  if (existing) {
224
233
  existing.owners++;
234
+ console.error("BB reuse owners", existing.owners);
235
+ existing.disabled = false;
225
236
  return existing;
226
237
  }
227
238
 
@@ -235,17 +246,24 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
235
246
  width: number,
236
247
  ): string[] {
237
248
  const theme = state.theme;
238
- if (!theme) return state.originalRender.call(this, width);
249
+ if (state.disabled || !theme) {
250
+ return state.originalRender.call(this, width);
251
+ }
239
252
  const inset = bashBlockInset(width);
240
253
  const bodyWidth = width - inset * 2 - visibleWidth(PROMPT_RAIL);
241
254
  if (inset === 0 || bodyWidth < 8) {
242
255
  return state.originalRender.call(this, width);
243
256
  }
244
257
  const lines = state.originalRender.call(this, Math.max(1, bodyWidth));
258
+ // Decorated lines carry resolved colors, so the cache keys on the
259
+ // palette too: Pi swaps the colors behind a stable theme Proxy, which a
260
+ // theme-identity check would never notice.
261
+ const palette = paletteSample(theme);
245
262
  const entry = state.cache.get(this);
246
263
  if (
247
264
  entry &&
248
265
  entry.width === width &&
266
+ entry.palette === palette &&
249
267
  entry.src.length === lines.length &&
250
268
  entry.src.every((line, index) => line === lines[index])
251
269
  ) {
@@ -259,7 +277,7 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
259
277
  typeof status === "string" ? status : undefined,
260
278
  inset,
261
279
  );
262
- state.cache.set(this, { width, src: lines, lines: decorated });
280
+ state.cache.set(this, { width, src: lines, lines: decorated, palette });
263
281
  return decorated;
264
282
  };
265
283
 
@@ -285,6 +303,7 @@ function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
285
303
  if (!state || state.owners <= 0) return;
286
304
  state.owners--;
287
305
  if (state.owners > 0) return;
306
+ state.disabled = true;
288
307
  const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
289
308
  [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
290
309
  render?: (width: number) => string[];
@@ -37,11 +37,17 @@ export function runChatContainerHooks(
37
37
  const restores: Array<() => void> = [];
38
38
  if (Array.isArray(children)) {
39
39
  for (const hook of chatContainerHooks()) {
40
+ // A hook rewrites `children` and hands back its own undo. Snapshot first:
41
+ // a hook that throws after splicing rows out would otherwise leave them
42
+ // missing from every later render, with nothing left to restore them.
43
+ const before = [...children];
40
44
  try {
41
45
  const restore = hook(container, children, width);
42
46
  if (typeof restore === "function") restores.push(restore);
43
47
  } catch {
44
48
  // Hooks are cosmetic; one failing hook must not break rendering.
49
+ children.length = 0;
50
+ children.push(...before);
45
51
  }
46
52
  }
47
53
  }
package/src/index.ts CHANGED
@@ -15,15 +15,19 @@ 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 = "│";
24
27
  // Collapsed rows anchor with a glyph instead of repeating the tool name;
25
28
  // tools that read as one family share a glyph, and unmapped tools fall back
26
- // to `*`.
29
+ // to `*`. A single space separates the glyph from the call content: the
30
+ // anchor is punctuation enough without a joining colon.
27
31
  const TOOL_CALL_GLYPHS: ReadonlyMap<string, string> = new Map([
28
32
  ["read", "●"],
29
33
  ["write", "+"],
@@ -87,13 +91,16 @@ type ToolExecutionRow = {
87
91
 
88
92
  type PresentationPatchState = {
89
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;
90
98
  theme?: ThemeLike;
91
99
  collapseParallel: boolean;
92
100
  expandedTools: ReadonlySet<string>;
93
101
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
94
102
  collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
95
103
  rowVersions: WeakMap<ToolExecutionRow, number>;
96
- rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
97
104
  originalRender: (width: number) => string[];
98
105
  originalUpdateDisplay: () => void;
99
106
  originalHandleMouse?: (this: ToolExecutionRow, event: unknown) => unknown;
@@ -182,22 +189,12 @@ function rowSignatureOf(row: ToolExecutionRow): string {
182
189
  return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
183
190
  }
184
191
 
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>();
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.
189
196
  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;
197
+ return paletteSample(theme);
201
198
  }
202
199
 
203
200
  type InsetLayout = {
@@ -461,10 +458,6 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
461
458
  return title ?? token;
462
459
  }
463
460
 
464
- function selfRenderedSummary(row: ToolExecutionRow, width: number): string {
465
- return fitSummary(selfRenderedCallLabel(row), width);
466
- }
467
-
468
461
  function isToolExecutionRow(
469
462
  component: unknown,
470
463
  ): component is ToolExecutionRow & ComponentLike {
@@ -558,6 +551,16 @@ function hasToolSiblingInAssistantBatch(
558
551
  return false;
559
552
  }
560
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
+
561
564
  function isGroupableToolRow(
562
565
  row: ToolExecutionRow,
563
566
  children: unknown[],
@@ -566,6 +569,7 @@ function isGroupableToolRow(
566
569
  state: PresentationPatchState,
567
570
  ): boolean {
568
571
  if (row.toolName === "subagent") return false;
572
+ if (drawsNoRowLines(row, renderAt(index))) return false;
569
573
  if (
570
574
  !isLiveRow(row) &&
571
575
  !isCollapsibleSuccess(row) &&
@@ -796,11 +800,6 @@ function renderedCallSummary(
796
800
  width: number,
797
801
  theme: ThemeLike,
798
802
  ): 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
803
  let component = row.callRendererComponent;
805
804
  if (!component && Array.isArray(row.contentBox?.children)) {
806
805
  component = row.contentBox.children[0] as ComponentLike | undefined;
@@ -851,56 +850,60 @@ function renderedCallSummary(
851
850
  : fgCollapsed(theme, "muted", "(no arguments)");
852
851
  }
853
852
 
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(
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(
873
859
  row: ToolExecutionRow,
874
860
  width: number,
875
861
  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);
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
+ };
887
898
  }
888
899
 
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
- );
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*/, "");
904
907
  }
905
908
 
906
909
  function collapsedOutcome(
@@ -927,31 +930,28 @@ function collapsedOutcome(
927
930
  return renderedOutcome(row, theme);
928
931
  }
929
932
 
930
- 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(
931
936
  row: ToolExecutionRow,
932
937
  width: number,
933
938
  theme: ThemeLike,
934
939
  ): string {
935
940
  // Failed rows render entirely in error so the row reads as the one that
936
941
  // 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);
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));
945
945
  const outcome = collapsedOutcome(row, budget, theme);
946
946
  // The truncation suffix inherits the row tone; pi-tui's truncation resets
947
947
  // around a plain suffix, which would render it in the terminal default
948
948
  // foreground instead of the row color.
949
- const suffix = fgCollapsed(theme, tone, "…");
949
+ const suffix = fgCollapsed(theme, color, "…");
950
950
  return (
951
- marker +
951
+ seam.anchor +
952
952
  (outcome
953
- ? fitSummaryTail(label, outcome, budget, suffix)
954
- : fitSummary(label, budget, suffix))
953
+ ? fitSummaryTail(seam.content, outcome, budget, suffix)
954
+ : fitSummary(seam.content, budget, suffix))
955
955
  );
956
956
  }
957
957
 
@@ -1251,40 +1251,11 @@ function renderCollapsedToolRow(
1251
1251
  row.toolName === "subagent"
1252
1252
  ? renderSubagentPlan(row, layout.contentWidth, theme)
1253
1253
  : undefined;
1254
- const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1254
+ const body = plan ?? [collapsedRowLine(row, layout.contentWidth, theme)];
1255
1255
  body.push(...imageResultLines(row, layout.contentWidth, theme));
1256
1256
  return ["", ...insetLines(body, width)];
1257
1257
  }
1258
1258
 
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
1259
  function renderGroupedCallLines(
1289
1260
  rows: ToolExecutionRow[],
1290
1261
  width: number,
@@ -1292,26 +1263,7 @@ function renderGroupedCallLines(
1292
1263
  ): Array<{ row: ToolExecutionRow; lines: string[] }> {
1293
1264
  const segments: Array<{ row: ToolExecutionRow; lines: string[] }> = [];
1294
1265
  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 });
1266
+ segments.push({ row, lines: [collapsedRowLine(row, width, theme)] });
1315
1267
  }
1316
1268
  return segments;
1317
1269
  }
@@ -1471,7 +1423,6 @@ function renderContainerWithToolGroups(
1471
1423
  continue;
1472
1424
  }
1473
1425
 
1474
- for (const member of group) presentation.rowGroups.set(member, group);
1475
1426
  // Use a live member while any call is pending, then the first member once
1476
1427
  // settled. Either way, the grouped row keeps the same number of lines.
1477
1428
  const shellRow = group.find(isLiveRow) ?? child;
@@ -1592,6 +1543,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1592
1543
  const existing = proto[PRESENTATION_PATCHED];
1593
1544
  if (existing) {
1594
1545
  existing.owners++;
1546
+ existing.disabled = false;
1595
1547
  return existing;
1596
1548
  }
1597
1549
  const legacy = proto[LEGACY_PRESENTATION_PATCHED];
@@ -1612,13 +1564,16 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1612
1564
  groupCache: new WeakMap(),
1613
1565
  collapsedCache: new WeakMap(),
1614
1566
  rowVersions: new WeakMap(),
1615
- rowGroups: new WeakMap(),
1616
1567
  originalRender: proto.render,
1617
1568
  originalUpdateDisplay: proto.updateDisplay,
1618
1569
  };
1619
1570
  const patchedUpdateDisplay = function updateDisplayWithToolPresentation(
1620
1571
  this: ToolExecutionRow,
1621
1572
  ): void {
1573
+ if (state.disabled) {
1574
+ state.originalUpdateDisplay.call(this);
1575
+ return;
1576
+ }
1622
1577
  // Transcript rerenders reuse settled caches, but a real component
1623
1578
  // display update can change args, results, renderer output, or images
1624
1579
  // without changing the row's shape signature.
@@ -1651,6 +1606,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1651
1606
  event: unknown,
1652
1607
  ): unknown {
1653
1608
  const mouseEvent = event as { type?: unknown; button?: unknown };
1609
+ if (state.disabled) {
1610
+ return state.originalHandleMouse?.call(this, event);
1611
+ }
1654
1612
  if (
1655
1613
  state.theme &&
1656
1614
  this.expanded === false &&
@@ -1672,6 +1630,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1672
1630
  ): string[] {
1673
1631
  const theme = state.theme;
1674
1632
  if (
1633
+ state.disabled ||
1675
1634
  typeof this.expanded !== "boolean" ||
1676
1635
  typeof this.isPartial !== "boolean" ||
1677
1636
  this.expanded ||
@@ -1709,7 +1668,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1709
1668
  return cached.lines;
1710
1669
  }
1711
1670
  const lines = state.originalRender.call(this, width);
1712
- if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1671
+ if (drawsNoRowLines(this, lines)) {
1713
1672
  return lines;
1714
1673
  }
1715
1674
  try {
@@ -1730,7 +1689,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1730
1689
  // Partial (streaming) rows keep the collapsed presentation but are
1731
1690
  // never cached: their content changes with every streamed chunk.
1732
1691
  const lines = state.originalRender.call(this, width);
1733
- if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1692
+ if (drawsNoRowLines(this, lines)) {
1734
1693
  return lines;
1735
1694
  }
1736
1695
  try {
@@ -1767,6 +1726,7 @@ function uninstallPresentationPatch(
1767
1726
  if (!state || state.owners <= 0) return;
1768
1727
  state.owners--;
1769
1728
  if (state.owners > 0) return;
1729
+ state.disabled = true;
1770
1730
  const proto =
1771
1731
  ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
1772
1732
  [PRESENTATION_PATCHED]?: PresentationPatchState;
@@ -1817,17 +1777,12 @@ export default function (pi: ExtensionAPI) {
1817
1777
  ctx.ui.setToolsExpanded(false);
1818
1778
  });
1819
1779
 
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
1780
  pi.on("session_shutdown", () => {
1827
1781
  if (released) return;
1828
1782
  released = true;
1829
1783
  uninstallGroupingPatch(grouping);
1830
1784
  uninstallPresentationPatch(patch);
1831
1785
  uninstallBashBlockPatch(bashPatch);
1786
+ uninstallInfoVisibility();
1832
1787
  });
1833
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
+ }
package/src/muted.ts CHANGED
@@ -19,27 +19,36 @@ 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
+ ] as const;
46
+
47
+ // Revision key for rendered output that bakes resolved colors in. Compares
48
+ // the colors themselves, so a mid-session theme switch is visible to every
49
+ // cache that keys on it.
50
+ export function paletteSample(theme: CollapsedTheme): string {
51
+ return PALETTE_TOKENS.map((token) => tokenAnsi(theme, token) ?? "").join(",");
43
52
  }
44
53
 
45
54
  // Override token when defined, else the theme's syntaxComment color, else
@@ -49,56 +49,60 @@ type ThemeDetail = {
49
49
  // styles current across mid-session theme switches, which fire no event.
50
50
  let themeProvider: (() => ThemeDetail | undefined) | undefined;
51
51
  let activeLevel: (() => string) | undefined;
52
- // Theme object the current label styles were computed from.
53
- let stylesTheme: ThemeDetail | undefined;
52
+ // Pi builds both thinking labels italic. The default treatment replaces that
53
+ // with a plain row: italic-off always leads — the TUI diff renderer can skip
54
+ // bytes shared with the previous italic frame, which would leave the terminal
55
+ // italic — plus the resolved color when the theme supplies one. The `inherit`
56
+ // variant is the only choice that keeps Pi's native italic styling.
57
+ const ITALIC_OFF = "\x1b[23m";
54
58
 
55
59
  type LabelStyle = { prefix: string; suffix: string };
56
- let liveLabelStyle: LabelStyle | undefined;
57
- let settledLabelStyle: LabelStyle | undefined;
60
+
61
+ // The package styles labels only inside a session it can read the theme from.
62
+ // `inherit` is the other native case: it keeps Pi's italic labels.
63
+ function stylesThinkingLabels(): boolean {
64
+ return themeProvider !== undefined && thoughtLabelColorChoice() !== "inherit";
65
+ }
58
66
 
59
67
  function styledWith(
60
68
  token: string,
61
- theme: ThemeDetail | undefined = themeProvider?.(),
69
+ theme: ThemeDetail | undefined,
62
70
  ): LabelStyle | undefined {
63
- const ansi = theme?.getFgAnsi?.(token);
71
+ // Pi's Theme throws on a token it does not know, so probe the way muted.ts
72
+ // does: an unresolvable color costs the label its tint, never the row.
73
+ let ansi: string | undefined;
74
+ try {
75
+ const resolved = theme?.getFgAnsi?.(token);
76
+ if (typeof resolved === "string") ansi = resolved;
77
+ } catch {
78
+ ansi = undefined;
79
+ }
64
80
  if (!ansi) return undefined;
65
- // The styled label replaces Pi's italicized Text node wholesale (see
66
- // restyleHiddenThinkingLabel). The leading italic-off still matters: the
67
- // TUI's diff renderer can skip bytes shared with the previously drawn
68
- // italic line, leaving the terminal in italic state otherwise.
69
- return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
81
+ return { prefix: `${ITALIC_OFF}${ansi}`, suffix: "\x1b[39m" };
70
82
  }
71
83
 
72
84
  // The theme's collapsedThinkingCall override when defined, else its comment
73
85
  // color, else the muted token.
74
- function settledMutedStyle(
75
- theme: ThemeDetail | undefined,
76
- ): LabelStyle | undefined {
77
- const ansi = theme ? collapsedThinkingAnsi(theme) : null;
78
- if (ansi) return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
86
+ function settledMutedStyle(theme: ThemeDetail): LabelStyle | undefined {
87
+ const ansi = collapsedThinkingAnsi(theme);
88
+ if (ansi) return { prefix: `${ITALIC_OFF}${ansi}`, suffix: "\x1b[39m" };
79
89
  return styledWith("muted", theme);
80
90
  }
81
- function updateThoughtLabelStyle(): void {
82
- liveLabelStyle = undefined;
83
- settledLabelStyle = undefined;
84
- const theme = themeProvider?.();
85
- stylesTheme = theme;
86
- const choice = thoughtLabelColorChoice();
87
- if (choice === "inherit" || !theme) return;
88
91
 
92
+ // Resolved per label, never cached: Pi swaps the colors behind a stable theme
93
+ // Proxy, so a cached style would survive a theme switch and repaint the
94
+ // transcript in the old palette.
95
+ function labelStyle(
96
+ settled: boolean,
97
+ theme: ThemeDetail,
98
+ ): LabelStyle | undefined {
99
+ const choice = thoughtLabelColorChoice();
100
+ if (settled && choice !== "mdheading") return settledMutedStyle(theme);
89
101
  const token =
90
102
  choice === "mdheading"
91
103
  ? "mdHeading"
92
104
  : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
93
- liveLabelStyle = styledWith(token, theme);
94
- settledLabelStyle =
95
- choice === "mdheading" ? liveLabelStyle : settledMutedStyle(theme);
96
- }
97
-
98
- // Recompute label styles whenever the theme object has swapped (theme switch
99
- // or /reload); those transitions fire no extension event.
100
- function ensureLabelStyles(): void {
101
- if (themeProvider?.() !== stylesTheme) updateThoughtLabelStyle();
105
+ return styledWith(token, theme);
102
106
  }
103
107
 
104
108
  // The settled label is the finalized "+ Thought" row; every other label is
@@ -108,13 +112,14 @@ function isSettledThoughtLabel(label: string): boolean {
108
112
  }
109
113
 
110
114
  export function visibleThoughtLabel(label: string): string {
111
- ensureLabelStyles();
112
- const style = isSettledThoughtLabel(label)
113
- ? settledLabelStyle
114
- : liveLabelStyle;
115
- if (!style) return label;
115
+ if (!stylesThinkingLabels()) return label;
116
116
  const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
117
- return `${style.prefix}${raw}${style.suffix}`;
117
+ const theme = themeProvider?.();
118
+ const style = theme
119
+ ? labelStyle(isSettledThoughtLabel(label), theme)
120
+ : undefined;
121
+ // A theme that cannot resolve a color still drops Pi's italics.
122
+ return style ? `${style.prefix}${raw}${style.suffix}` : `${ITALIC_OFF}${raw}`;
118
123
  }
119
124
 
120
125
  type AssistantMessageLike = {
@@ -133,6 +138,28 @@ type TextLikeChild = {
133
138
  setText?(text: string): void;
134
139
  };
135
140
 
141
+ type MarkdownLikeChild = {
142
+ defaultTextStyle?: { italic?: boolean };
143
+ invalidate?(): void;
144
+ };
145
+
146
+ // Pi renders a visible thinking trace as italic markdown. Italic is the live
147
+ // cue — a settled trace drops it and reads as ordinary transcript text — and
148
+ // the italic default style is what identifies the trace: Pi passes it for
149
+ // thinking content and for nothing else. Callers gate on the row actually
150
+ // carrying thinking, so no other italic block is touched.
151
+ function unitalicizeSettledThinking(row: AssistantMessageRow): void {
152
+ const children = row.contentContainer?.children;
153
+ if (!Array.isArray(children)) return;
154
+ for (const child of children) {
155
+ const markdown = child as MarkdownLikeChild | undefined;
156
+ const style = markdown?.defaultTextStyle;
157
+ if (style?.italic !== true) continue;
158
+ style.italic = false;
159
+ markdown?.invalidate?.();
160
+ }
161
+ }
162
+
136
163
  // Pi renders the hidden-thinking label as an italic Text node built from the
137
164
  // plain label field. Embedding style codes in the field itself is not
138
165
  // enough: the TUI diff renderer reuses the byte prefix shared with the
@@ -149,7 +176,7 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
149
176
  const label = row.hiddenThinkingLabel;
150
177
  const children = row.contentContainer?.children;
151
178
  if (!Array.isArray(children)) return;
152
- if (!liveLabelStyle && !settledLabelStyle) return;
179
+ if (!stylesThinkingLabels()) return;
153
180
  const styled = visibleThoughtLabel(label);
154
181
  for (const child of children) {
155
182
  const textChild = child as TextLikeChild | undefined;
@@ -171,6 +198,10 @@ type ThinkingTiming = {
171
198
 
172
199
  type ThinkingGroupingPatchState = {
173
200
  owners: number;
201
+ // Set at the final owner's shutdown. A wrapper another extension has buried
202
+ // cannot be uninstalled, so it delegates instead of outliving its owner; a
203
+ // later install re-enables it.
204
+ disabled?: boolean;
174
205
  originalUpdateContent: (
175
206
  message: AssistantMessageLike,
176
207
  ...args: unknown[]
@@ -180,14 +211,39 @@ type ThinkingGroupingPatchState = {
180
211
  ...args: unknown[]
181
212
  ) => void;
182
213
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
183
- // Last update per row, so /toggle-info can replay rows without waiting for
184
- // new content. Cleared on session start; rows are session-lived anyway.
214
+ // Last update per tracked row, so /toggle-info can replay rows without
215
+ // waiting for new content. Only rows that carry thinking change under the
216
+ // toggle, and the map is capped so a long session or an abandoned branch
217
+ // cannot pin every message the transcript ever showed.
185
218
  rows: Map<
186
219
  AssistantMessageRow,
187
220
  { message: AssistantMessageLike; args: unknown[] }
188
221
  >;
189
222
  };
190
223
 
224
+ const MAX_REPLAY_ROWS = 400;
225
+
226
+ // Bookkeeping only, so it fails open: a message whose content shape throws
227
+ // must not take the row's update pass down with it.
228
+ function trackReplayRow(
229
+ state: ThinkingGroupingPatchState,
230
+ row: AssistantMessageRow,
231
+ message: AssistantMessageLike,
232
+ args: unknown[],
233
+ ): void {
234
+ try {
235
+ if (!hasThinkingContent(message)) return;
236
+ state.rows.set(row, { message, args });
237
+ while (state.rows.size > MAX_REPLAY_ROWS) {
238
+ const oldest = state.rows.keys().next().value;
239
+ if (oldest === undefined) return;
240
+ state.rows.delete(oldest);
241
+ }
242
+ } catch {
243
+ // The row keeps its native rendering; only /toggle-info replay misses it.
244
+ }
245
+ }
246
+
191
247
  type ThinkingContentLike = {
192
248
  type: "thinking";
193
249
  thinking: string;
@@ -246,30 +302,40 @@ function thinkingSpinner(startedAt: number, now: number): string {
246
302
  return PI_SPINNER_FRAMES[frame % PI_SPINNER_FRAMES.length]!;
247
303
  }
248
304
 
305
+ // A row is live while it streams, and stays live through un-flagged rebuilds
306
+ // (resize, theme switch, /reload) until an explicit final update arrives.
307
+ // Rows that never carry the flag — restored history, older runtimes — count
308
+ // as settled.
309
+ function thinkingStillLive(
310
+ streaming: boolean | undefined,
311
+ timing: ThinkingTiming | undefined,
312
+ ): boolean {
313
+ if (streaming === true) return true;
314
+ return (
315
+ streaming === undefined &&
316
+ timing !== undefined &&
317
+ timing.finishedAt === undefined
318
+ );
319
+ }
320
+
249
321
  function lifecycleLabel(
250
322
  row: AssistantMessageRow,
251
323
  streaming: boolean | undefined,
252
324
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>,
253
325
  ): string {
254
326
  const now = Date.now();
255
- let current = timings.get(row);
256
- if (streaming === true) {
257
- if (!current || current.finishedAt !== undefined) {
258
- current = { startedAt: now };
259
- timings.set(row, current);
260
- }
327
+ const previous = timings.get(row);
328
+ if (thinkingStillLive(streaming, previous)) {
329
+ const current =
330
+ previous && previous.finishedAt === undefined
331
+ ? previous
332
+ : { startedAt: now };
333
+ timings.set(row, current);
261
334
  return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
262
335
  }
263
336
 
264
- if (streaming === undefined && current?.finishedAt === undefined && current) {
265
- // Pi can rebuild a live row on resize/theme changes without forwarding
266
- // the optional streaming flag. Keep its active label until an explicit
267
- // final update arrives.
268
- return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
269
- }
270
-
271
- if (streaming === false && current?.finishedAt === undefined) {
272
- if (current) current.finishedAt = now;
337
+ if (streaming === false && previous && previous.finishedAt === undefined) {
338
+ previous.finishedAt = now;
273
339
  }
274
340
 
275
341
  const settled = timings.get(row);
@@ -353,6 +419,7 @@ function installThinkingGroupingPatch():
353
419
  if (existing) {
354
420
  existing.owners++;
355
421
  existing.rows ??= new Map();
422
+ existing.disabled = false;
356
423
  return existing;
357
424
  }
358
425
 
@@ -367,9 +434,14 @@ function installThinkingGroupingPatch():
367
434
  message: AssistantMessageLike,
368
435
  ...args: unknown[]
369
436
  ): void {
370
- // Grouping and labels are cosmetic. Each fails open independently, and
437
+ // Grouping and styling are cosmetic. Each fails open independently, and
371
438
  // the original renderer is still called exactly once with every arg.
372
- state.rows.set(this, { message, args });
439
+ if (state.disabled) {
440
+ Reflect.apply(state.originalUpdateContent, this, [message, ...args]);
441
+ return;
442
+ }
443
+ trackReplayRow(state, this, message, args);
444
+ const streaming = typeof args[0] === "boolean" ? args[0] : undefined;
373
445
  let combined = message;
374
446
  try {
375
447
  // Pi builds the hidden label and the visible block only when thinking
@@ -381,7 +453,6 @@ function installThinkingGroupingPatch():
381
453
  // Preserve the original message intact.
382
454
  }
383
455
  try {
384
- const streaming = typeof args[0] === "boolean" ? args[0] : undefined;
385
456
  applyHiddenThinkingLabel(this, combined, streaming, state.timings);
386
457
  } catch {
387
458
  // Preserve Pi's native label if its private row shape changes.
@@ -392,6 +463,16 @@ function installThinkingGroupingPatch():
392
463
  } catch {
393
464
  // Keep Pi's native label styling if the row shape changes.
394
465
  }
466
+ try {
467
+ if (
468
+ hasThinkingContent(combined) &&
469
+ !thinkingStillLive(streaming, state.timings.get(this))
470
+ ) {
471
+ unitalicizeSettledThinking(this);
472
+ }
473
+ } catch {
474
+ // Keep Pi's native italic trace if the row shape changes.
475
+ }
395
476
  };
396
477
 
397
478
  state.patchedUpdateContent = patchedUpdateContent;
@@ -412,6 +493,7 @@ function uninstallThinkingGroupingPatch(
412
493
  if (!state || state.owners <= 0) return false;
413
494
  state.owners--;
414
495
  if (state.owners > 0) return false;
496
+ state.disabled = true;
415
497
  const proto =
416
498
  AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
417
499
  [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
@@ -453,17 +535,12 @@ export default function (pi: ExtensionAPI) {
453
535
  return "off";
454
536
  }
455
537
  };
456
- updateThoughtLabelStyle();
457
538
  });
458
- pi.on("thinking_level_select", () => updateThoughtLabelStyle());
459
539
  pi.on("session_shutdown", () => {
460
540
  if (released) return;
461
541
  released = true;
462
542
  if (patch && !uninstallThinkingGroupingPatch(patch)) return;
463
543
  themeProvider = undefined;
464
544
  activeLevel = undefined;
465
- stylesTheme = undefined;
466
- liveLabelStyle = undefined;
467
- settledLabelStyle = undefined;
468
545
  });
469
546
  }