@pi-kaush/pi-tool-call-markers 0.2.9 → 0.2.11

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,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Add `/toggle-info`: hide tool calls and thinking entirely, or restore them
6
+ collapsed. Execution rows (including user-run `!` blocks) are lifted out of
7
+ the render pass via the shared chat-container hook; thinking blocks are
8
+ stripped before Pi builds the label/expanded block and replayed on restore.
9
+ Every session starts visible, and Ctrl+O/Ctrl+T behavior is unchanged when
10
+ visible. The toggle state lives on globalThis because Pi loads each package
11
+ entrypoint through jiti with module caching disabled — module-level state
12
+ would otherwise be duplicated between entrypoints.
13
+ - Adopt Pi's native content-color split in collapsed rows: tool names ride
14
+ `toolTitle` and call content/outcomes ride `toolOutput` (structural
15
+ chrome stays muted; failed rows stay uniformly error).
16
+ - Quiet the settled "+ Thought" label to the muted tone collapsed tool
17
+ calls use; only the live "⠋ Thinking…" spinner keeps the session's active
18
+ thinking-level tint (thinkingOff…thinkingMax), re-tinting on
19
+ thinking_level_select; the expanded thinking block is untouched. The
20
+ PI_TOOL_CALL_MARKERS_THOUGHT_COLOR values are `level` (default),
21
+ `mdheading`, and `inherit`; the midpoint `gray` variant and its RGB
22
+ color math are gone.
23
+ - Paint user-run `!` bash block surfaces with the theme's
24
+ `userMessageBg` token instead of a hardcoded hex, matching
25
+ pi-content-layout's prompt surfaces under any theme.
26
+ - Show subagent progress in the collapsed plan headline: while running, the tail reads `→ N turns · provider/model` in the warning tone from streamed result details; settled calls keep the same `→ N turns · provider/model` in muted instead of a bare `→ done` (turns aggregate across tasks, model only when all tasks agree).
27
+ - Source the settled "+ Thought" label color from the theme's mdHeading
28
+ token (orange in cobalt2, amber in Pi's stock themes) instead of a
29
+ hardcoded RGB. The PI_TOOL_CALL_MARKERS_THOUGHT_COLOR experiment values
30
+ are now `mdheading` (default), `gray`, and `inherit`.
5
31
  - Fix: preserve the visible text of hyperlink-wrapped paths in collapsed rows. Pi renders `read` call paths as OSC 8 hyperlinks (`ESC]8;;url ESC\ <path> ESC]8;; ESC\`); the previous greedy OSC strip consumed the path text along with the sequences, collapsing `% read: README.md:1-400` to `% read: :1-400`. OSC payloads now end at their first BEL/ST terminator, so `read`/`write`/`ls` rows show their filenames again.
6
32
  - Show settled `edit` changes inline: the call line gains a `+added/-removed` outcome stat, and the display diff from `result.details.diff` renders as a bounded block underneath (`+` lines in the added tone, `-` in the removed tone, context muted, `...` for folded regions; capped at 12 lines with a `+N more` tail). `Ctrl+O` still returns Pi's full native diff.
7
33
  - Render subagent plans with a `&` marker instead of `%`, so delegated calls read differently from ordinary tool rows (both the parsed plan shape and the generic fallback).
package/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Give Pi's collapsed tool calls a quiet, width-safe transcript shell while preserving native details under `Ctrl+O`. The package also includes a display-only thinking-block adapter.
4
4
 
5
+ ### Install
6
+
7
+ ```fish
8
+ pi install npm:@pi-kaush/pi-tool-call-markers
9
+ ```
10
+
11
+ Restart Pi or run `/reload`.
12
+
5
13
  ## What it changes
6
14
 
7
15
  Collapsed tool rows use semantic theme colors with no gear, background fill, box padding, or filled blank rows:
@@ -36,6 +44,8 @@ A recognized `subagent` call renders as an unboxed plan in the shared tool aesth
36
44
 
37
45
  Single calls stay on one `& subagent <agent> [profile] <task preview>` row; chain calls get a heading with the kind, count, and scope followed by numbered steps (parallel tasks list without numbers). Agent display names (emoji + name) are scraped from the native plan component with an args fallback, and render in `accent`; everything else stays muted, and failed subagents go full red. Subagents never join ordinary tool groups.
38
46
 
47
+ While a subagent runs, the plan headline's tail shows live progress from the streamed result details — `→ 1 turn · provider/model` in the warning tone — and a settled call keeps the same `→ N turns · provider/model` summary in muted (turns aggregate across tasks; the model shows only when every task used the same one).
48
+
39
49
  Malformed, ambiguous, future, or too-narrow shapes fall back to the generic `& subagent …` collapsed row rather than dropping information, and `Ctrl+O` still exposes the native subagent renderer.
40
50
 
41
51
  ## Edit diffs
@@ -50,6 +60,14 @@ This package owns every **execution row** in the transcript: collapsed tool call
50
60
 
51
61
  One shared visual contract crosses the line: `!` blocks align with message text, so their inset mirrors `pi-content-layout`'s message `contentInset` (see `src/bash-block.ts`). Change indentation in both packages together.
52
62
 
63
+ ## Hiding info entirely: /toggle-info
64
+
65
+ The default transcript keeps tool calls and thinking collapsed, and Ctrl+O /
66
+ Ctrl+T expand them as usual. When you want prose only, `/toggle-info` hides
67
+ tool calls (including user-run `!` blocks) and thinking completely; running it
68
+ again restores the collapsed view. The toggle is per-session: every session
69
+ starts visible.
70
+
53
71
  ## Bundled thinking-block extension
54
72
 
55
73
  The second package entrypoint, `src/thinking-block-merger.ts`, combines only directly adjacent `thinking` blocks in a display copy. Tool calls, text, provider blocks, signatures, and stored session messages are unchanged.
@@ -63,15 +81,9 @@ When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoni
63
81
 
64
82
  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.
65
83
 
66
- ## Install
67
-
68
- ```bash
69
- pi install npm:@pi-kaush/pi-tool-call-markers
70
- ```
71
-
72
- For local development:
84
+ ## Local development
73
85
 
74
- ```bash
86
+ ```fish
75
87
  pi \
76
88
  -e ./extensions/pi-tool-call-markers/src/index.ts \
77
89
  -e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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
@@ -4,13 +4,24 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  type ThemeLike = {
5
5
  bold(text: string): string;
6
6
  fg(color: string, text: string): string;
7
+ getBgAnsi?(color: string): string;
7
8
  };
8
9
 
9
10
  const BASH_BLOCK_PATCHED = Symbol.for("kg.pi.bashBlock.v1");
10
11
 
11
- // The prompt surface uses a fixed near-black green tint instead of the
12
- // theme's selectedBg token so every user-input surface stays identical.
13
- const PROMPT_SURFACE_BG = "\x1b[48;2;7;19;18m"; // #071312
12
+ // The prompt surface paints with the theme's userMessageBg token, matching
13
+ // pi-content-layout's surfaces. userMessageBg is a required pi theme token;
14
+ // the legacy hex remains only as a fallback for theme lookalikes without
15
+ // getBgAnsi.
16
+ const PROMPT_SURFACE_BG_FALLBACK = "\x1b[48;2;7;19;18m"; // #071312
17
+
18
+ function promptSurfaceBg(theme: ThemeLike): string {
19
+ try {
20
+ return theme.getBgAnsi?.("userMessageBg") ?? PROMPT_SURFACE_BG_FALLBACK;
21
+ } catch {
22
+ return PROMPT_SURFACE_BG_FALLBACK;
23
+ }
24
+ }
14
25
  const PROMPT_RAIL = "▎";
15
26
 
16
27
  // User-run bash blocks align with the message inset pi-content-layout
@@ -109,16 +120,17 @@ function renderRailedBlockLines(
109
120
  }
110
121
  const bodyWidth = blockWidth - visibleWidth(PROMPT_RAIL);
111
122
  const margin = " ".repeat(inset);
123
+ const surfaceBg = promptSurfaceBg(theme);
112
124
  return lines.map((line) => {
113
125
  const { controls, content } = splitLeadingSemanticControls(line);
114
- const recolored = replaceBackground(content, PROMPT_SURFACE_BG);
126
+ const recolored = replaceBackground(content, surfaceBg);
115
127
  const rail = theme.fg(railColor, PROMPT_RAIL);
116
128
  // One extra leading space inside the body, so submitted text sits two
117
129
  // columns right of the rail instead of one.
118
130
  const body = paintBackground(
119
131
  ` ${fitLine(recolored, Math.max(1, bodyWidth - 1))}`,
120
132
  bodyWidth,
121
- PROMPT_SURFACE_BG,
133
+ surfaceBg,
122
134
  );
123
135
  return `${controls}${margin}${rail}${body}${margin}`;
124
136
  });
@@ -186,6 +198,7 @@ function renderBashBlockLines(
186
198
  type BashRow = { status?: unknown };
187
199
 
188
200
  type BashBlockPatchState = {
201
+ owners: number;
189
202
  theme?: ThemeLike;
190
203
  cache: WeakMap<object, { width: number; src: string[]; lines: string[] }>;
191
204
  originalRender: (width: number) => string[];
@@ -207,9 +220,13 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
207
220
  if (!proto || typeof proto.render !== "function") return undefined;
208
221
 
209
222
  const existing = proto[BASH_BLOCK_PATCHED];
210
- if (existing) return existing;
223
+ if (existing) {
224
+ existing.owners++;
225
+ return existing;
226
+ }
211
227
 
212
228
  const state: BashBlockPatchState = {
229
+ owners: 1,
213
230
  cache: new WeakMap(),
214
231
  originalRender: proto.render,
215
232
  };
@@ -265,7 +282,9 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
265
282
  }
266
283
 
267
284
  function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
268
- if (!state) return;
285
+ if (!state || state.owners <= 0) return;
286
+ state.owners--;
287
+ if (state.owners > 0) return;
269
288
  const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
270
289
  [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
271
290
  render?: (width: number) => string[];
@@ -280,10 +299,5 @@ function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
280
299
  delete proto[BASH_BLOCK_PATCHED];
281
300
  }
282
301
 
283
- export {
284
- PROMPT_RAIL,
285
- PROMPT_SURFACE_BG,
286
- installBashBlockPatch,
287
- uninstallBashBlockPatch,
288
- };
302
+ export { PROMPT_RAIL, installBashBlockPatch, uninstallBashBlockPatch };
289
303
  export type { BashBlockPatchState };
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ 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";
18
19
 
19
20
  const OUTER_INSET = 2;
20
21
  const GROUP_MARKER = "%";
@@ -63,13 +64,13 @@ type ToolExecutionRow = {
63
64
  };
64
65
 
65
66
  type PresentationPatchState = {
67
+ owners: number;
66
68
  theme?: ThemeLike;
67
69
  collapseParallel: boolean;
68
70
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
69
71
  collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
70
72
  rowVersions: WeakMap<ToolExecutionRow, number>;
71
73
  rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
72
- rowSignatures: WeakMap<ToolExecutionRow, string>;
73
74
  originalRender: (width: number) => string[];
74
75
  originalUpdateDisplay: () => void;
75
76
  patchedRender?: (width: number) => string[];
@@ -77,6 +78,7 @@ type PresentationPatchState = {
77
78
  };
78
79
 
79
80
  type GroupingPatchState = {
81
+ owners: number;
80
82
  presentation: PresentationPatchState;
81
83
  originalRender: (width: number) => string[];
82
84
  patchedRender?: (width: number) => string[];
@@ -111,12 +113,11 @@ function stripAnsi(text: string): string {
111
113
  return text.replace(ANSI_RE, "");
112
114
  }
113
115
 
114
- // Command output can carry cursor-moving control bytes (git progress writes
115
- // \r) and raw terminal sequences. Collapsed rows are single-line, so result
116
- // and scraped text is stripped of display sequences and control bytes before
117
- // it can reach a row; a bare \r would otherwise return the cursor to column 0
118
- // and overwrite the row's own marker.
119
- const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
116
+ // Command output can carry cursor-moving control bytes, raw terminal
117
+ // sequences, line feeds, and tabs. Collapsed rows are single-line, so result
118
+ // and scraped text is stripped of display sequences and all C0 controls before
119
+ // it can reach a row; otherwise control bytes could split or overwrite it.
120
+ const INLINE_CONTROL_RE = /[\x00-\x1f\x7f]/g;
120
121
  // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
121
122
  // leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
122
123
  // hyperlinks, titles) end at their first BEL/ST terminator; a payload match
@@ -134,11 +135,9 @@ function hasVisibleContent(line: string): boolean {
134
135
  return stripAnsi(line).trim().length > 0;
135
136
  }
136
137
 
137
- // State-transition fingerprint used by both the updateDisplay version bump
138
- // and the settled-row render cache. It deliberately covers only shape
139
- // transitions (partial/expanded/result/error): while a row is partial its
140
- // content streams and it is never cached, and once settled the rendered
141
- // content is fully determined by this fingerprint + width + theme.
138
+ // Shape fingerprint used in settled-row cache keys. It protects against
139
+ // direct state mutation followed by render without updateDisplay; real
140
+ // component display updates invalidate the cache through rowVersions.
142
141
  function rowSignatureOf(row: ToolExecutionRow): string {
143
142
  return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
144
143
  }
@@ -253,7 +252,7 @@ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
253
252
  }
254
253
 
255
254
  function renderedGenericOutcome(theme: ThemeLike): string {
256
- return theme.fg("muted", "→ done");
255
+ return theme.fg("toolOutput", "→ done");
257
256
  }
258
257
 
259
258
  // The MCP adapter stashes its call's first line in renderer state before
@@ -707,7 +706,7 @@ function renderedOutcome(
707
706
  ): string | undefined {
708
707
  const summary = outcomeSummary(row);
709
708
  if (!summary) return undefined;
710
- return theme.fg("muted", `→ ${summary}`);
709
+ return theme.fg("toolOutput", `→ ${summary}`);
711
710
  }
712
711
 
713
712
  function renderedGroupedOutcome(
@@ -847,7 +846,7 @@ function renderedCallSummary(
847
846
 
848
847
  const fallback = compactArgs(row.args);
849
848
  return fallback
850
- ? theme.fg("muted", fallback)
849
+ ? theme.fg("toolOutput", fallback)
851
850
  : theme.fg("muted", "(no arguments)");
852
851
  }
853
852
 
@@ -860,9 +859,13 @@ function styledCallLabel(
860
859
  const match = /^(\S+)(.*)$/s.exec(plain);
861
860
  if (!match) return theme.fg(color, plain);
862
861
  const rest = match[2] ?? "";
862
+ // Failed rows stay uniformly error-colored; settled rows use Pi's native
863
+ // split — toolTitle for the name, toolOutput for the call content.
864
+ const nameColor = color === "muted" ? "toolTitle" : color;
865
+ const restColor = color === "muted" ? "toolOutput" : color;
863
866
  return (
864
- theme.fg(color, theme.bold(match[1] ?? "")) +
865
- (rest ? theme.fg(color, `:${rest}`) : "")
867
+ theme.fg(nameColor, theme.bold(match[1] ?? "")) +
868
+ (rest ? theme.fg(restColor, `:${rest}`) : "")
866
869
  );
867
870
  }
868
871
 
@@ -901,7 +904,7 @@ function collapsedOutcome(
901
904
  }
902
905
  if (row.getRenderShell?.() === "self") {
903
906
  const editStat = renderedEditDiffStat(row);
904
- if (editStat) return theme.fg("muted", `→ ${editStat}`);
907
+ if (editStat) return theme.fg("toolOutput", `→ ${editStat}`);
905
908
  return renderedGenericOutcome(theme);
906
909
  }
907
910
  return renderedOutcome(row, theme);
@@ -1054,6 +1057,33 @@ function subagentStepPreview(task: string): string {
1054
1057
  return clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
1055
1058
  }
1056
1059
 
1060
+ // Partial and final subagent results both carry per-task usage in details
1061
+ // (the subagent extension streams makeDetails snapshots through onUpdate), so
1062
+ // this works live and settled. Surface the smallest useful progress — total
1063
+ // turns, and the model when every task resolved to the same one.
1064
+ function subagentProgressText(row: ToolExecutionRow): string | undefined {
1065
+ const results = row.result?.details?.results;
1066
+ if (!Array.isArray(results) || results.length === 0) return undefined;
1067
+ let turns = 0;
1068
+ const models = new Set<string>();
1069
+ for (const result of results) {
1070
+ if (!isRecord(result)) continue;
1071
+ const usage = result.usage;
1072
+ const turnCount = isRecord(usage) ? usage.turns : undefined;
1073
+ if (typeof turnCount === "number" && Number.isFinite(turnCount)) {
1074
+ turns += turnCount;
1075
+ }
1076
+ const model = result.model ?? result.requestedModel;
1077
+ if (typeof model === "string" && model.trim().length > 0) {
1078
+ models.add(model.trim());
1079
+ }
1080
+ }
1081
+ const parts: string[] = [];
1082
+ if (turns > 0) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
1083
+ if (models.size === 1) parts.push(sanitizeInline([...models][0]!));
1084
+ return parts.length > 0 ? parts.join(" · ") : undefined;
1085
+ }
1086
+
1057
1087
  // Subagents render as an ordinary unboxed tool block: a `%` heading with the
1058
1088
  // plan kind/count/scope, then the numbered chain steps or parallel tasks with
1059
1089
  // agent names in accent. Everything else stays muted (or error on failure),
@@ -1075,20 +1105,22 @@ function renderSubagentPlan(
1075
1105
  // other collapsed-row text so control bytes cannot reach the terminal.
1076
1106
  const displayOf = (agent: string) =>
1077
1107
  theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
1108
+ const detailColor = failed ? "error" : "toolOutput";
1078
1109
  const detailOf = (step: SubagentStep) =>
1079
1110
  theme.fg(
1080
- color,
1111
+ detailColor,
1081
1112
  `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
1082
1113
  );
1083
1114
 
1084
1115
  const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
1085
1116
  const budget = Math.max(1, width - visibleWidth(marker));
1117
+ const progress = subagentProgressText(row);
1086
1118
  const outcome = failed
1087
1119
  ? collapsedOutcome(row, budget, theme)
1088
1120
  : isLiveRow(row)
1089
- ? collapsedOutcome(row, budget, theme)
1121
+ ? theme.fg("warning", progress ? `→ ${progress}` : "…")
1090
1122
  : row.result
1091
- ? renderedGenericOutcome(theme)
1123
+ ? theme.fg("toolOutput", progress ? `→ ${progress}` : "→ done")
1092
1124
  : theme.fg("warning", "…");
1093
1125
  const headline = (label: string) =>
1094
1126
  marker +
@@ -1100,7 +1132,7 @@ function renderSubagentPlan(
1100
1132
  const step = plan.steps[0]!;
1101
1133
  return [
1102
1134
  headline(
1103
- theme.fg(color, theme.bold("subagent")) +
1135
+ theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1104
1136
  " " +
1105
1137
  displayOf(step.agent) +
1106
1138
  detailOf(step),
@@ -1114,10 +1146,10 @@ function renderSubagentPlan(
1114
1146
  : `parallel (${plan.steps.length} tasks)`;
1115
1147
  const lines = [
1116
1148
  headline(
1117
- theme.fg(color, theme.bold("subagent")) +
1149
+ theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1118
1150
  " " +
1119
- theme.fg(color, kindLabel) +
1120
- theme.fg(color, ` [${plan.scope}]`),
1151
+ theme.fg(failed ? "error" : "toolOutput", kindLabel) +
1152
+ theme.fg(failed ? "error" : "toolOutput", ` [${plan.scope}]`),
1121
1153
  ),
1122
1154
  ];
1123
1155
  const shown = plan.steps.slice(0, 3);
@@ -1183,22 +1215,64 @@ function renderCollapsedToolRow(
1183
1215
  return ["", ...insetLines(body, width)];
1184
1216
  }
1185
1217
 
1186
- function groupedCallComponent(
1218
+ function groupedChildLabel(
1219
+ row: ToolExecutionRow,
1220
+ width: number,
1221
+ theme: ThemeLike,
1222
+ color: string = "muted",
1223
+ ): string {
1224
+ const label = collapsedCallLabel(row, width, theme, color);
1225
+ const plain = stripAnsi(label);
1226
+ const toolName = row.toolName ?? "tool";
1227
+ let prefix = "";
1228
+ if (plain === toolName) {
1229
+ prefix = toolName;
1230
+ } else if (plain.startsWith(`${toolName}:`)) {
1231
+ const whitespace = /^\s*/.exec(plain.slice(toolName.length + 1))?.[0] ?? "";
1232
+ prefix = `${toolName}:${whitespace}`;
1233
+ }
1234
+ const prefixWidth = visibleWidth(prefix);
1235
+ const child = sliceByColumn(
1236
+ label,
1237
+ prefixWidth,
1238
+ Math.max(0, visibleWidth(label) - prefixWidth),
1239
+ true,
1240
+ );
1241
+ return child || theme.fg(color, "(no arguments)");
1242
+ }
1243
+
1244
+ function renderGroupedCallLines(
1187
1245
  rows: ToolExecutionRow[],
1246
+ width: number,
1188
1247
  theme: ThemeLike,
1189
- ): ComponentLike {
1190
- return {
1191
- render(width: number): string[] {
1192
- // Every member renders like a singleton — `% tool: call → outcome` per
1193
- // line with the tool name bolded — no bullets or internal blanks;
1194
- // settled edits keep their change visible with a bounded diff block.
1195
- return rows.flatMap((row) => [
1196
- collapsedHeadline(row, width, theme),
1197
- ...(row.toolName === "edit" ? renderedEditDiffLines(row, theme) : []),
1198
- ]);
1199
- },
1200
- invalidate() {},
1201
- };
1248
+ ): string[] {
1249
+ const lines: string[] = [];
1250
+ let previousToolName: string | undefined;
1251
+ for (const row of rows) {
1252
+ const toolName = row.toolName ?? "tool";
1253
+ if (toolName !== previousToolName) {
1254
+ lines.push(
1255
+ `${theme.fg("muted", GROUP_MARKER)} ${theme.fg("toolTitle", theme.bold(toolName))}`,
1256
+ );
1257
+ previousToolName = toolName;
1258
+ }
1259
+
1260
+ const color = rowHasFailed(row) ? "error" : "muted";
1261
+ const prefix = ` ${theme.fg(color, "•")} `;
1262
+ const budget = Math.max(1, width - visibleWidth(prefix));
1263
+ const label = groupedChildLabel(row, budget, theme, color);
1264
+ const outcome = collapsedOutcome(row, budget, theme);
1265
+ const suffix = theme.fg(color, "…");
1266
+ lines.push(
1267
+ prefix +
1268
+ (outcome
1269
+ ? fitSummaryTail(label, outcome, budget, suffix)
1270
+ : fitSummary(label, budget, suffix)),
1271
+ );
1272
+ if (row.toolName === "edit")
1273
+ lines.push(...renderedEditDiffLines(row, theme));
1274
+ }
1275
+ return lines;
1202
1276
  }
1203
1277
 
1204
1278
  function sameMembers(
@@ -1247,8 +1321,8 @@ function renderGroupedToolRows(
1247
1321
  }
1248
1322
 
1249
1323
  const layout = insetLayout(width);
1250
- const summary = groupedCallComponent(rows, theme);
1251
- const lines = ["", ...insetLines(summary.render(layout.contentWidth), width)];
1324
+ const body = renderGroupedCallLines(rows, layout.contentWidth, theme);
1325
+ const lines = ["", ...insetLines(body, width)];
1252
1326
  if (hasLiveMembers) {
1253
1327
  state.groupCache.delete(row);
1254
1328
  } else {
@@ -1347,12 +1421,14 @@ function installGroupingPatch(
1347
1421
 
1348
1422
  const existing = proto[GROUPING_PATCHED];
1349
1423
  if (existing) {
1424
+ existing.owners++;
1350
1425
  existing.presentation = presentation;
1351
1426
  existing.disabled = false;
1352
1427
  return existing;
1353
1428
  }
1354
1429
 
1355
1430
  const state: GroupingPatchState = {
1431
+ owners: 1,
1356
1432
  presentation,
1357
1433
  originalRender: proto.render,
1358
1434
  };
@@ -1400,10 +1476,12 @@ function installGroupingPatch(
1400
1476
  }
1401
1477
 
1402
1478
  function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
1403
- if (!state) return;
1479
+ if (!state || state.owners <= 0) return;
1480
+ state.owners--;
1481
+ if (state.owners > 0) return;
1404
1482
  // Even when another extension's wrapper sits on top of ours, grouping must
1405
- // not outlive its owner: the wrapper delegates once disabled, and a later
1406
- // reinstall re-enables it.
1483
+ // not outlive its final owner: the wrapper delegates once disabled, and a
1484
+ // later reinstall re-enables it.
1407
1485
  state.disabled = true;
1408
1486
  const proto = Container?.prototype as unknown as ComponentContainer & {
1409
1487
  [GROUPING_PATCHED]?: GroupingPatchState;
@@ -1427,7 +1505,10 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1427
1505
  if (!proto) return undefined;
1428
1506
 
1429
1507
  const existing = proto[PRESENTATION_PATCHED];
1430
- if (existing) return existing;
1508
+ if (existing) {
1509
+ existing.owners++;
1510
+ return existing;
1511
+ }
1431
1512
  const legacy = proto[LEGACY_PRESENTATION_PATCHED];
1432
1513
  if (legacy) {
1433
1514
  proto.render = legacy.originalRender;
@@ -1440,30 +1521,24 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1440
1521
  return undefined;
1441
1522
 
1442
1523
  const state: PresentationPatchState = {
1524
+ owners: 1,
1443
1525
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1444
1526
  groupCache: new WeakMap(),
1445
1527
  collapsedCache: new WeakMap(),
1446
1528
  rowVersions: new WeakMap(),
1447
1529
  rowGroups: new WeakMap(),
1448
- rowSignatures: new WeakMap(),
1449
1530
  originalRender: proto.render,
1450
1531
  originalUpdateDisplay: proto.updateDisplay,
1451
1532
  };
1452
1533
  const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
1453
1534
  this: ToolExecutionRow,
1454
1535
  ): void {
1455
- // Group members always bump so their leader's cache refreshes; other
1456
- // rows only bump on state transitions, so bash's per-second invalidate
1457
- // ticks and resize invalidations stop busting caches.
1458
- const signature = rowSignatureOf(this);
1459
- if (
1460
- state.rowGroups.has(this) ||
1461
- state.rowSignatures.get(this) !== signature
1462
- ) {
1463
- state.rowSignatures.set(this, signature);
1464
- state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
1465
- state.groupCache.delete(this);
1466
- }
1536
+ // Transcript rerenders reuse settled caches, but a real component
1537
+ // display update can change args, results, renderer output, or images
1538
+ // without changing the row's shape signature.
1539
+ state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
1540
+ state.groupCache.delete(this);
1541
+ state.collapsedCache.delete(this);
1467
1542
  state.originalUpdateDisplay.call(this);
1468
1543
  };
1469
1544
  const patchedRender = function renderWithToolPresentation(
@@ -1488,12 +1563,11 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1488
1563
  // it touches. On a long session (~1,000 messages, hundreds of tool
1489
1564
  // rows) that measured at ~80 ms per keystroke — visible input lag.
1490
1565
  //
1491
- // Settled rows (not partial, not expanded) have fully static render
1492
- // output, so their collapsed lines are cached keyed by width +
1493
- // rowSignatureOf + rowVersions + theme sample. updateDisplay bumps
1494
- // rowVersions on every real state transition, and partial rows
1495
- // bypass the cache entirely because their content streams. This
1496
- // mirrors the existing groupCache discipline; keep them in sync.
1566
+ // Settled rows (not partial, not expanded) have static output between
1567
+ // component display updates, so their collapsed lines are cached keyed
1568
+ // by width + rowSignatureOf + rowVersions + theme sample. Every real
1569
+ // updateDisplay call bumps rowVersions and clears both caches; partial
1570
+ // rows bypass the cache because their content streams.
1497
1571
  // ================================================================
1498
1572
  if (!this.isPartial) {
1499
1573
  const signature = rowSignatureOf(this);
@@ -1565,7 +1639,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1565
1639
  function uninstallPresentationPatch(
1566
1640
  state: PresentationPatchState | undefined,
1567
1641
  ): void {
1568
- if (!state) return;
1642
+ if (!state || state.owners <= 0) return;
1643
+ state.owners--;
1644
+ if (state.owners > 0) return;
1569
1645
  const proto =
1570
1646
  ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
1571
1647
  [PRESENTATION_PATCHED]?: PresentationPatchState;
@@ -1584,10 +1660,20 @@ function uninstallPresentationPatch(
1584
1660
  delete proto[PRESENTATION_PATCHED];
1585
1661
  }
1586
1662
 
1663
+ // TODO(compaction cards): native/failed compaction cards in the transcript
1664
+ // are currently rendered by the pi-verbatim-compaction extension. Its
1665
+ // src/chat-log.ts is deliberately self-contained (public Pi imports only), so
1666
+ // porting here is: drop in that file, registerEntryRenderer(COMPACTION_LOG_TYPE),
1667
+ // and wire pi.on("session_compact") / pi.on("session_compact_failed") filtered
1668
+ // to fromExtension === false. Do this only if pi-verbatim-compaction is
1669
+ // removed or native cards should exist without it; while both are installed,
1670
+ // rendering here too would double-render every native compaction.
1587
1671
  export default function (pi: ExtensionAPI) {
1588
1672
  const patch = installPresentationPatch();
1589
1673
  const grouping = patch ? installGroupingPatch(patch) : undefined;
1590
1674
  const bashPatch = installBashBlockPatch();
1675
+ installInfoVisibility(pi);
1676
+ let released = false;
1591
1677
 
1592
1678
  pi.on("session_start", (_event, ctx) => {
1593
1679
  if (patch) patch.theme = ctx.ui.theme;
@@ -1596,6 +1682,8 @@ export default function (pi: ExtensionAPI) {
1596
1682
  });
1597
1683
 
1598
1684
  pi.on("session_shutdown", () => {
1685
+ if (released) return;
1686
+ released = true;
1599
1687
  uninstallGroupingPatch(grouping);
1600
1688
  uninstallPresentationPatch(patch);
1601
1689
  uninstallBashBlockPatch(bashPatch);
@@ -0,0 +1,22 @@
1
+ // Shared toggle state for /toggle-info. Pi loads extension entrypoints through
2
+ // jiti with moduleCache disabled, so each entrypoint's relative-import chain
3
+ // gets its OWN module instances — a plain module-level boolean here would be
4
+ // duplicated between the main entrypoint (the /toggle-info command) and the
5
+ // thinking-block-merger entrypoint (the strip). globalThis keeps it singular.
6
+
7
+ type InfoVisibilityState = { hidden: boolean };
8
+
9
+ const STATE_KEY = Symbol.for("kg.pi.toolCallMarkers.infoVisibility.v1");
10
+
11
+ function infoVisibilityState(): InfoVisibilityState {
12
+ const root = globalThis as Record<symbol, InfoVisibilityState | undefined>;
13
+ return (root[STATE_KEY] ??= { hidden: false });
14
+ }
15
+
16
+ export function infoVisibilityHidden(): boolean {
17
+ return infoVisibilityState().hidden;
18
+ }
19
+
20
+ export function setInfoVisibilityHidden(hidden: boolean): void {
21
+ infoVisibilityState().hidden = hidden;
22
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ BashExecutionComponent,
3
+ type ExtensionAPI,
4
+ ToolExecutionComponent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { chatContainerHooks } from "./container-hooks.ts";
7
+ import {
8
+ infoVisibilityHidden,
9
+ setInfoVisibilityHidden,
10
+ } from "./info-visibility-state.ts";
11
+ import { refreshThinkingVisibility } from "./thinking-block-merger.ts";
12
+
13
+ function isExecutionRow(child: unknown): boolean {
14
+ return (
15
+ child instanceof ToolExecutionComponent ||
16
+ child instanceof BashExecutionComponent
17
+ );
18
+ }
19
+
20
+ // Chat-container hook: while hidden, tool and user-bash rows are lifted out of
21
+ // the child list for the render pass and restored afterwards, so grouping and
22
+ // insets run on prose only. Nested invocations are naturally idempotent: a
23
+ // second pass finds nothing left to remove.
24
+ function infoFilterHook(
25
+ _container: object,
26
+ children: unknown[],
27
+ ): (() => void) | undefined {
28
+ if (!infoVisibilityHidden()) return undefined;
29
+ const removed: Array<[number, unknown]> = [];
30
+ for (let index = children.length - 1; index >= 0; index--) {
31
+ if (isExecutionRow(children[index])) {
32
+ removed.push([index, children[index]!]);
33
+ children.splice(index, 1);
34
+ }
35
+ }
36
+ if (removed.length === 0) return undefined;
37
+ return () => {
38
+ for (const [index, child] of removed.reverse()) {
39
+ children.splice(index, 0, child);
40
+ }
41
+ };
42
+ }
43
+
44
+ export function installInfoVisibility(pi: ExtensionAPI): void {
45
+ // The hook registry is process-global; dedupe by name so hot reloads swap
46
+ // rather than stack.
47
+ const hooks = chatContainerHooks();
48
+ for (const hook of [...hooks]) {
49
+ if (hook.name === infoFilterHook.name) hooks.delete(hook);
50
+ }
51
+ hooks.add(infoFilterHook);
52
+
53
+ pi.registerCommand("toggle-info", {
54
+ description: "Hide tool calls and thinking, or restore them collapsed",
55
+ handler: async (_args, ctx) => {
56
+ const hidden = !infoVisibilityHidden();
57
+ setInfoVisibilityHidden(hidden);
58
+ // Thinking labels/blocks only rebuild on content updates, so replay the
59
+ // last update per assistant row to apply the new state immediately.
60
+ refreshThinkingVisibility();
61
+ ctx.ui.notify(
62
+ hidden
63
+ ? "Tool calls and thinking hidden — /toggle-info restores them"
64
+ : "Tool calls and thinking visible",
65
+ "info",
66
+ );
67
+ },
68
+ });
69
+
70
+ // Every session starts in the default collapsed-but-visible state. No
71
+ // shutdown reset: extra shutdown handlers would sit between this package's
72
+ // owner-counted patch releases.
73
+ pi.on("session_start", () => setInfoVisibilityHidden(false));
74
+ }
@@ -1,48 +1,42 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
3
+ import { infoVisibilityHidden } from "./info-visibility-state.ts";
3
4
 
4
- const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
5
+ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v2");
5
6
  const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
7
  const SPINNER_INTERVAL_MS = 80;
7
8
 
8
- // Visual experiment for the settled "+ Thought" label. "inherit" keeps Pi's
9
- // native styling (italic + thinkingText); "gray" sits halfway between the
10
- // muted and text theme colors. The env var overrides the default so the
11
- // variants can be compared without editing code or republishing.
9
+ // Visual treatment for the collapsed thinking label. The live "Thinking…"
10
+ // spinner tints with the session's active thinking-level color
11
+ // (thinkingOff…thinkingMax), so progress reads as activity; the settled
12
+ // "+ Thought" row drops to the muted token collapsed tool calls use, so
13
+ // finished reasoning stops advertising the level. "inherit" keeps Pi's
14
+ // native styling (italic + thinkingText) for both states, and "mdheading"
15
+ // rides the theme's mdHeading token for both. The env var overrides the
16
+ // default so variants can be compared without republishing. Only the
17
+ // collapsed label is restyled — the expanded thinking block keeps Pi's
18
+ // thinkingText.
12
19
  const THOUGHT_LABEL_COLOR_ENV = "PI_TOOL_CALL_MARKERS_THOUGHT_COLOR";
13
- type ThoughtLabelColor = "inherit" | "orange" | "gray";
14
- const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "orange";
15
- // cobalt2's orange token (#ffb86c).
16
- const THOUGHT_LABEL_ORANGE = { r: 255, g: 184, b: 108 };
20
+ type ThoughtLabelColor = "inherit" | "level" | "mdheading";
21
+ const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "level";
17
22
 
18
23
  function thoughtLabelColorChoice(): ThoughtLabelColor {
19
24
  const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
20
- return raw === "inherit" || raw === "orange" || raw === "gray"
25
+ return raw === "inherit" || raw === "level" || raw === "mdheading"
21
26
  ? raw
22
27
  : DEFAULT_THOUGHT_LABEL_COLOR;
23
28
  }
24
29
 
25
- const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
26
- const BASIC_ANSI_RGB: Array<[number, string] | undefined> = [
27
- [0, "#000000"],
28
- [1, "#800000"],
29
- [2, "#008000"],
30
- [3, "#808000"],
31
- [4, "#000080"],
32
- [5, "#800080"],
33
- [6, "#008080"],
34
- [7, "#c0c0c0"],
35
- [8, "#808080"],
36
- [9, "#ff0000"],
37
- [10, "#00ff00"],
38
- [11, "#ffff00"],
39
- [12, "#0000ff"],
40
- [13, "#ff00ff"],
41
- [14, "#00ffff"],
42
- [15, "#ffffff"],
43
- ];
44
-
45
- type Rgb = { r: number; g: number; b: number };
30
+ // thinkingLevel values map onto theme tokens: "xhigh" → thinkingXhigh etc.
31
+ const LEVEL_TOKEN: Record<string, string> = {
32
+ off: "thinkingOff",
33
+ minimal: "thinkingMinimal",
34
+ low: "thinkingLow",
35
+ medium: "thinkingMedium",
36
+ high: "thinkingHigh",
37
+ xhigh: "thinkingXhigh",
38
+ max: "thinkingMax",
39
+ };
46
40
 
47
41
  type ThemeDetail = {
48
42
  fg(color: string, text: string): string;
@@ -51,128 +45,50 @@ type ThemeDetail = {
51
45
  };
52
46
 
53
47
  let activeTheme: ThemeDetail | undefined;
48
+ let activeLevel: (() => string) | undefined;
54
49
 
55
- function hexToRgb(hex: string): Rgb | undefined {
56
- const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
57
- if (!match) return undefined;
58
- const value = parseInt(match[1]!, 16);
59
- return { r: value >> 16, g: (value >> 8) & 255, b: value & 255 };
60
- }
61
-
62
- // Mirrors Pi's own mapping so a named color can round-trip through a
63
- // 256-color terminal (e.g. when COLORFGBG forces 256color mode).
64
- function ansi256ToRgb(index: number): Rgb | undefined {
65
- if (index < 16) {
66
- const hex = BASIC_ANSI_RGB[index]?.[1];
67
- return hex ? hexToRgb(hex) : undefined;
68
- }
69
- if (index < 232) {
70
- const cube = index - 16;
71
- const channel = (n: number) => (n === 0 ? 0 : 55 + n * 40);
72
- return {
73
- r: channel(Math.floor(cube / 36)),
74
- g: channel(Math.floor((cube % 36) / 6)),
75
- b: channel(cube % 6),
76
- };
77
- }
78
- const gray = 8 + (index - 232) * 10;
79
- return { r: gray, g: gray, b: gray };
80
- }
81
-
82
- function rgbToAnsi256({ r, g, b }: Rgb): number {
83
- const nearest = (value: number) =>
84
- CUBE_VALUES.reduce(
85
- (best, candidate) =>
86
- Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best,
87
- 0,
88
- );
89
- const cubeR = nearest(r);
90
- const cubeG = nearest(g);
91
- const cubeB = nearest(b);
92
- const cubeDistance =
93
- (r - cubeR) ** 2 * 0.299 +
94
- (g - cubeG) ** 2 * 0.587 +
95
- (b - cubeB) ** 2 * 0.114;
96
- const luminance = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
97
- const grayStep = Math.max(0, Math.min(23, Math.round((luminance - 8) / 10)));
98
- const grayValue = 8 + grayStep * 10;
99
- const grayDistance = (luminance - grayValue) ** 2;
100
- const spread = Math.max(r, g, b) - Math.min(r, g, b);
101
- if (spread < 10 && grayDistance < cubeDistance) return 232 + grayStep;
102
- return (
103
- 16 +
104
- 36 * CUBE_VALUES.indexOf(cubeR) +
105
- 6 * CUBE_VALUES.indexOf(cubeG) +
106
- CUBE_VALUES.indexOf(cubeB)
107
- );
108
- }
109
-
110
- function parseAnsiFgRgb(ansi: string): Rgb | undefined {
111
- if (/^\x1b\[39m$/.test(ansi)) return undefined;
112
- const truecolor = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(ansi);
113
- if (truecolor) {
114
- return {
115
- r: Number(truecolor[1]),
116
- g: Number(truecolor[2]),
117
- b: Number(truecolor[3]),
118
- };
119
- }
120
- const indexed = /^\x1b\[38;5;(\d+)m$/.exec(ansi);
121
- if (indexed) return ansi256ToRgb(Number(indexed[1]));
122
- const basic = /^\x1b\[(9[0-7]|3[0-7])m$/.exec(ansi);
123
- if (basic) {
124
- const code = Number(basic[1]);
125
- return ansi256ToRgb(code >= 90 ? code - 90 + 8 : code - 30);
126
- }
127
- return undefined;
128
- }
129
-
130
- function fgRgbAnsi({ r, g, b }: Rgb): string {
131
- if (activeTheme?.getColorMode?.() === "256color") {
132
- return `\x1b[38;5;${rgbToAnsi256({ r, g, b })}m`;
133
- }
134
- return `\x1b[38;2;${r};${g};${b}m`;
135
- }
50
+ type LabelStyle = { prefix: string; suffix: string };
51
+ let liveLabelStyle: LabelStyle | undefined;
52
+ let settledLabelStyle: LabelStyle | undefined;
136
53
 
137
- function midpointRgb(a: Rgb, b: Rgb): Rgb {
138
- return {
139
- r: Math.round((a.r + b.r) / 2),
140
- g: Math.round((a.g + b.g) / 2),
141
- b: Math.round((a.b + b.b) / 2),
142
- };
54
+ function styledWith(token: string): LabelStyle | undefined {
55
+ const ansi = activeTheme?.getFgAnsi?.(token);
56
+ if (!ansi) return undefined;
57
+ // The styled label replaces Pi's italicized Text node wholesale (see
58
+ // restyleHiddenThinkingLabel). The leading italic-off still matters: the
59
+ // TUI's diff renderer can skip bytes shared with the previously drawn
60
+ // italic line, leaving the terminal in italic state otherwise.
61
+ return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
143
62
  }
144
63
 
145
- let thoughtLabelPrefix = "";
146
- let thoughtLabelSuffix = "";
147
-
148
64
  function updateThoughtLabelStyle(): void {
149
- thoughtLabelPrefix = "";
150
- thoughtLabelSuffix = "";
65
+ liveLabelStyle = undefined;
66
+ settledLabelStyle = undefined;
151
67
  const choice = thoughtLabelColorChoice();
152
68
  if (choice === "inherit" || !activeTheme) return;
153
69
 
154
- let rgb: Rgb | undefined;
155
- if (choice === "orange") {
156
- rgb = THOUGHT_LABEL_ORANGE;
157
- } else if (activeTheme.getFgAnsi) {
158
- const muted = parseAnsiFgRgb(activeTheme.getFgAnsi("muted"));
159
- const text = parseAnsiFgRgb(activeTheme.getFgAnsi("text"));
160
- if (muted && text) rgb = midpointRgb(muted, text);
161
- }
162
- if (!rgb) return;
70
+ const token =
71
+ choice === "mdheading"
72
+ ? "mdHeading"
73
+ : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
74
+ liveLabelStyle = styledWith(token);
75
+ settledLabelStyle =
76
+ choice === "mdheading" ? liveLabelStyle : styledWith("muted");
77
+ }
163
78
 
164
- // The styled label replaces Pi's italicized Text node wholesale (see
165
- // restyleHiddenThinkingLabel). The leading italic-off still matters: the
166
- // TUI's diff renderer can skip bytes shared with the previously drawn
167
- // italic line, leaving the terminal in italic state otherwise.
168
- thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
169
- thoughtLabelSuffix = "\x1b[39m";
79
+ // The settled label is the finalized "+ Thought" row; every other label is
80
+ // the live spinner.
81
+ function isSettledThoughtLabel(label: string): boolean {
82
+ return label.startsWith("+ Thought");
170
83
  }
171
84
 
172
85
  export function visibleThoughtLabel(label: string): string {
173
- if (!thoughtLabelPrefix) return label;
86
+ const style = isSettledThoughtLabel(label)
87
+ ? settledLabelStyle
88
+ : liveLabelStyle;
89
+ if (!style) return label;
174
90
  const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
175
- return `${thoughtLabelPrefix}${raw}${thoughtLabelSuffix}`;
91
+ return `${style.prefix}${raw}${style.suffix}`;
176
92
  }
177
93
 
178
94
  type AssistantMessageLike = {
@@ -198,7 +114,6 @@ type TextLikeChild = {
198
114
  // terminal. Swap the node's text for a self-contained styled version after
199
115
  // each native render pass instead.
200
116
  function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
201
- if (!thoughtLabelPrefix) return;
202
117
  if (
203
118
  row.hideThinkingBlock !== true ||
204
119
  typeof row.hiddenThinkingLabel !== "string"
@@ -208,6 +123,7 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
208
123
  const label = row.hiddenThinkingLabel;
209
124
  const children = row.contentContainer?.children;
210
125
  if (!Array.isArray(children)) return;
126
+ if (!liveLabelStyle && !settledLabelStyle) return;
211
127
  const styled = visibleThoughtLabel(label);
212
128
  for (const child of children) {
213
129
  const textChild = child as TextLikeChild | undefined;
@@ -228,6 +144,7 @@ type ThinkingTiming = {
228
144
  };
229
145
 
230
146
  type ThinkingGroupingPatchState = {
147
+ owners: number;
231
148
  originalUpdateContent: (
232
149
  message: AssistantMessageLike,
233
150
  ...args: unknown[]
@@ -237,6 +154,12 @@ type ThinkingGroupingPatchState = {
237
154
  ...args: unknown[]
238
155
  ) => void;
239
156
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
157
+ // Last update per row, so /toggle-info can replay rows without waiting for
158
+ // new content. Cleared on session start; rows are session-lived anyway.
159
+ rows: Map<
160
+ AssistantMessageRow,
161
+ { message: AssistantMessageLike; args: unknown[] }
162
+ >;
240
163
  };
241
164
 
242
165
  type ThinkingContentLike = {
@@ -330,6 +253,40 @@ function lifecycleLabel(
330
253
  return "+ Thought";
331
254
  }
332
255
 
256
+ function stripThinkingBlocks(
257
+ message: AssistantMessageLike,
258
+ ): AssistantMessageLike {
259
+ const content = message.content;
260
+ if (!Array.isArray(content) || !content.some(isThinkingContent)) {
261
+ return message;
262
+ }
263
+ return { ...message, content: content.filter((c) => !isThinkingContent(c)) };
264
+ }
265
+
266
+ // Replays the last update on every tracked assistant row so a /toggle-info
267
+ // flip applies immediately instead of waiting for the next content pass.
268
+ export function refreshThinkingVisibility(): void {
269
+ try {
270
+ const proto = AssistantMessageComponent?.prototype as unknown as {
271
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
272
+ };
273
+ const state = proto?.[THINKING_GROUPING_PATCHED];
274
+ if (!state?.patchedUpdateContent) return;
275
+ for (const [row, tracked] of state.rows) {
276
+ try {
277
+ Reflect.apply(state.patchedUpdateContent, row, [
278
+ tracked.message,
279
+ ...tracked.args,
280
+ ]);
281
+ } catch {
282
+ // A row that changed shape mid-flight keeps its last render.
283
+ }
284
+ }
285
+ } catch {
286
+ // Cosmetic replay; never break the toggle.
287
+ }
288
+ }
289
+
333
290
  function applyHiddenThinkingLabel(
334
291
  row: AssistantMessageRow,
335
292
  message: AssistantMessageLike,
@@ -367,11 +324,17 @@ function installThinkingGroupingPatch():
367
324
  if (!proto || typeof proto.updateContent !== "function") return undefined;
368
325
 
369
326
  const existing = proto[THINKING_GROUPING_PATCHED];
370
- if (existing) return existing;
327
+ if (existing) {
328
+ existing.owners++;
329
+ existing.rows ??= new Map();
330
+ return existing;
331
+ }
371
332
 
372
333
  const state: ThinkingGroupingPatchState = {
334
+ owners: 1,
373
335
  originalUpdateContent: proto.updateContent,
374
336
  timings: new WeakMap(),
337
+ rows: new Map(),
375
338
  };
376
339
  const patchedUpdateContent = function updateContentWithCombinedThinking(
377
340
  this: AssistantMessageRow,
@@ -380,9 +343,14 @@ function installThinkingGroupingPatch():
380
343
  ): void {
381
344
  // Grouping and labels are cosmetic. Each fails open independently, and
382
345
  // the original renderer is still called exactly once with every arg.
346
+ state.rows.set(this, { message, args });
383
347
  let combined = message;
384
348
  try {
385
- combined = combineAdjacentThinking(message);
349
+ // Pi builds the hidden label and the visible block only when thinking
350
+ // blocks exist, so stripping them hides both at once.
351
+ combined = infoVisibilityHidden()
352
+ ? stripThinkingBlocks(message)
353
+ : combineAdjacentThinking(message);
386
354
  } catch {
387
355
  // Preserve the original message intact.
388
356
  }
@@ -414,8 +382,10 @@ function installThinkingGroupingPatch():
414
382
 
415
383
  function uninstallThinkingGroupingPatch(
416
384
  state: ThinkingGroupingPatchState | undefined,
417
- ): void {
418
- if (!state) return;
385
+ ): boolean {
386
+ if (!state || state.owners <= 0) return false;
387
+ state.owners--;
388
+ if (state.owners > 0) return false;
419
389
  const proto =
420
390
  AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
421
391
  [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
@@ -428,23 +398,44 @@ function uninstallThinkingGroupingPatch(
428
398
  proto[THINKING_GROUPING_PATCHED] !== state ||
429
399
  proto.updateContent !== state.patchedUpdateContent
430
400
  ) {
431
- return;
401
+ return true;
432
402
  }
433
403
  proto.updateContent = state.originalUpdateContent;
434
404
  delete proto[THINKING_GROUPING_PATCHED];
405
+ return true;
435
406
  }
436
407
 
437
408
  export default function (pi: ExtensionAPI) {
438
409
  const patch = installThinkingGroupingPatch();
410
+ let released = false;
439
411
  pi.on("session_start", (_event, ctx) => {
440
412
  if (ctx.mode !== "tui") return;
413
+ try {
414
+ const proto = AssistantMessageComponent?.prototype as unknown as {
415
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
416
+ };
417
+ proto?.[THINKING_GROUPING_PATCHED]?.rows.clear();
418
+ } catch {
419
+ // Best effort; stale rows would only linger until the process exits.
420
+ }
441
421
  activeTheme = ctx.ui.theme as unknown as ThemeDetail;
422
+ activeLevel = () => {
423
+ try {
424
+ return pi.getThinkingLevel();
425
+ } catch {
426
+ return "off";
427
+ }
428
+ };
442
429
  updateThoughtLabelStyle();
443
430
  });
431
+ pi.on("thinking_level_select", () => updateThoughtLabelStyle());
444
432
  pi.on("session_shutdown", () => {
433
+ if (released) return;
434
+ released = true;
435
+ if (patch && !uninstallThinkingGroupingPatch(patch)) return;
445
436
  activeTheme = undefined;
446
- thoughtLabelPrefix = "";
447
- thoughtLabelSuffix = "";
448
- uninstallThinkingGroupingPatch(patch);
437
+ activeLevel = undefined;
438
+ liveLabelStyle = undefined;
439
+ settledLabelStyle = undefined;
449
440
  });
450
441
  }