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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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
+ - Tint the collapsed "+ Thought" label with the session's active
17
+ thinking-level token (thinkingOff…thinkingMax), re-tinting on
18
+ thinking_level_select; the expanded thinking block is untouched. The
19
+ PI_TOOL_CALL_MARKERS_THOUGHT_COLOR values are now `level` (default),
20
+ `mdheading`, and `inherit`; the midpoint `gray` variant and its RGB
21
+ color math are gone.
22
+ - Paint user-run `!` bash block surfaces with the theme's
23
+ `userMessageBg` token instead of a hardcoded hex, matching
24
+ pi-content-layout's prompt surfaces under any theme.
25
+ - 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).
26
+ - Source the settled "+ Thought" label color from the theme's mdHeading
27
+ token (orange in cobalt2, amber in Pi's stock themes) instead of a
28
+ hardcoded RGB. The PI_TOOL_CALL_MARKERS_THOUGHT_COLOR experiment values
29
+ are now `mdheading` (default), `gray`, and `inherit`.
5
30
  - 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
31
  - 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
32
  - 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
@@ -36,6 +36,8 @@ A recognized `subagent` call renders as an unboxed plan in the shared tool aesth
36
36
 
37
37
  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
38
 
39
+ 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).
40
+
39
41
  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
42
 
41
43
  ## Edit diffs
@@ -50,6 +52,14 @@ This package owns every **execution row** in the transcript: collapsed tool call
50
52
 
51
53
  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
54
 
55
+ ## Hiding info entirely: /toggle-info
56
+
57
+ The default transcript keeps tool calls and thinking collapsed, and Ctrl+O /
58
+ Ctrl+T expand them as usual. When you want prose only, `/toggle-info` hides
59
+ tool calls (including user-run `!` blocks) and thinking completely; running it
60
+ again restores the collapsed view. The toggle is per-session: every session
61
+ starts visible.
62
+
53
63
  ## Bundled thinking-block extension
54
64
 
55
65
  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.
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.10",
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;
@@ -1588,6 +1664,8 @@ export default function (pi: ExtensionAPI) {
1588
1664
  const patch = installPresentationPatch();
1589
1665
  const grouping = patch ? installGroupingPatch(patch) : undefined;
1590
1666
  const bashPatch = installBashBlockPatch();
1667
+ installInfoVisibility(pi);
1668
+ let released = false;
1591
1669
 
1592
1670
  pi.on("session_start", (_event, ctx) => {
1593
1671
  if (patch) patch.theme = ctx.ui.theme;
@@ -1596,6 +1674,8 @@ export default function (pi: ExtensionAPI) {
1596
1674
  });
1597
1675
 
1598
1676
  pi.on("session_shutdown", () => {
1677
+ if (released) return;
1678
+ released = true;
1599
1679
  uninstallGroupingPatch(grouping);
1600
1680
  uninstallPresentationPatch(patch);
1601
1681
  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,39 @@
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
9
  // 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.
10
+ // native styling (italic + thinkingText); "mdheading" rides the theme's
11
+ // mdHeading token; the default tints the label with the session's active
12
+ // thinking-level color (thinkingOff…thinkingMax), so the collapsed label
13
+ // quietly advertises the level Pi is reasoning at. Only the collapsed label
14
+ // is restyled — the expanded thinking block keeps Pi's thinkingText. The env
15
+ // var overrides the default so variants can be compared without republishing.
12
16
  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 };
17
+ type ThoughtLabelColor = "inherit" | "level" | "mdheading";
18
+ const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "level";
17
19
 
18
20
  function thoughtLabelColorChoice(): ThoughtLabelColor {
19
21
  const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
20
- return raw === "inherit" || raw === "orange" || raw === "gray"
22
+ return raw === "inherit" || raw === "level" || raw === "mdheading"
21
23
  ? raw
22
24
  : DEFAULT_THOUGHT_LABEL_COLOR;
23
25
  }
24
26
 
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 };
27
+ // thinkingLevel values map onto theme tokens: "xhigh" → thinkingXhigh etc.
28
+ const LEVEL_TOKEN: Record<string, string> = {
29
+ off: "thinkingOff",
30
+ minimal: "thinkingMinimal",
31
+ low: "thinkingLow",
32
+ medium: "thinkingMedium",
33
+ high: "thinkingHigh",
34
+ xhigh: "thinkingXhigh",
35
+ max: "thinkingMax",
36
+ };
46
37
 
47
38
  type ThemeDetail = {
48
39
  fg(color: string, text: string): string;
@@ -51,96 +42,7 @@ type ThemeDetail = {
51
42
  };
52
43
 
53
44
  let activeTheme: ThemeDetail | undefined;
54
-
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
- }
136
-
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
- };
143
- }
45
+ let activeLevel: (() => string) | undefined;
144
46
 
145
47
  let thoughtLabelPrefix = "";
146
48
  let thoughtLabelSuffix = "";
@@ -151,21 +53,19 @@ function updateThoughtLabelStyle(): void {
151
53
  const choice = thoughtLabelColorChoice();
152
54
  if (choice === "inherit" || !activeTheme) return;
153
55
 
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;
56
+ let ansi: string | undefined;
57
+ const token =
58
+ choice === "mdheading"
59
+ ? "mdHeading"
60
+ : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
61
+ ansi = activeTheme.getFgAnsi?.(token);
62
+ if (!ansi) return;
163
63
 
164
64
  // The styled label replaces Pi's italicized Text node wholesale (see
165
65
  // restyleHiddenThinkingLabel). The leading italic-off still matters: the
166
66
  // TUI's diff renderer can skip bytes shared with the previously drawn
167
67
  // italic line, leaving the terminal in italic state otherwise.
168
- thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
68
+ thoughtLabelPrefix = `\x1b[23m${ansi}`;
169
69
  thoughtLabelSuffix = "\x1b[39m";
170
70
  }
171
71
 
@@ -228,6 +128,7 @@ type ThinkingTiming = {
228
128
  };
229
129
 
230
130
  type ThinkingGroupingPatchState = {
131
+ owners: number;
231
132
  originalUpdateContent: (
232
133
  message: AssistantMessageLike,
233
134
  ...args: unknown[]
@@ -237,6 +138,12 @@ type ThinkingGroupingPatchState = {
237
138
  ...args: unknown[]
238
139
  ) => void;
239
140
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
141
+ // Last update per row, so /toggle-info can replay rows without waiting for
142
+ // new content. Cleared on session start; rows are session-lived anyway.
143
+ rows: Map<
144
+ AssistantMessageRow,
145
+ { message: AssistantMessageLike; args: unknown[] }
146
+ >;
240
147
  };
241
148
 
242
149
  type ThinkingContentLike = {
@@ -330,6 +237,40 @@ function lifecycleLabel(
330
237
  return "+ Thought";
331
238
  }
332
239
 
240
+ function stripThinkingBlocks(
241
+ message: AssistantMessageLike,
242
+ ): AssistantMessageLike {
243
+ const content = message.content;
244
+ if (!Array.isArray(content) || !content.some(isThinkingContent)) {
245
+ return message;
246
+ }
247
+ return { ...message, content: content.filter((c) => !isThinkingContent(c)) };
248
+ }
249
+
250
+ // Replays the last update on every tracked assistant row so a /toggle-info
251
+ // flip applies immediately instead of waiting for the next content pass.
252
+ export function refreshThinkingVisibility(): void {
253
+ try {
254
+ const proto = AssistantMessageComponent?.prototype as unknown as {
255
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
256
+ };
257
+ const state = proto?.[THINKING_GROUPING_PATCHED];
258
+ if (!state?.patchedUpdateContent) return;
259
+ for (const [row, tracked] of state.rows) {
260
+ try {
261
+ Reflect.apply(state.patchedUpdateContent, row, [
262
+ tracked.message,
263
+ ...tracked.args,
264
+ ]);
265
+ } catch {
266
+ // A row that changed shape mid-flight keeps its last render.
267
+ }
268
+ }
269
+ } catch {
270
+ // Cosmetic replay; never break the toggle.
271
+ }
272
+ }
273
+
333
274
  function applyHiddenThinkingLabel(
334
275
  row: AssistantMessageRow,
335
276
  message: AssistantMessageLike,
@@ -367,11 +308,17 @@ function installThinkingGroupingPatch():
367
308
  if (!proto || typeof proto.updateContent !== "function") return undefined;
368
309
 
369
310
  const existing = proto[THINKING_GROUPING_PATCHED];
370
- if (existing) return existing;
311
+ if (existing) {
312
+ existing.owners++;
313
+ existing.rows ??= new Map();
314
+ return existing;
315
+ }
371
316
 
372
317
  const state: ThinkingGroupingPatchState = {
318
+ owners: 1,
373
319
  originalUpdateContent: proto.updateContent,
374
320
  timings: new WeakMap(),
321
+ rows: new Map(),
375
322
  };
376
323
  const patchedUpdateContent = function updateContentWithCombinedThinking(
377
324
  this: AssistantMessageRow,
@@ -380,9 +327,14 @@ function installThinkingGroupingPatch():
380
327
  ): void {
381
328
  // Grouping and labels are cosmetic. Each fails open independently, and
382
329
  // the original renderer is still called exactly once with every arg.
330
+ state.rows.set(this, { message, args });
383
331
  let combined = message;
384
332
  try {
385
- combined = combineAdjacentThinking(message);
333
+ // Pi builds the hidden label and the visible block only when thinking
334
+ // blocks exist, so stripping them hides both at once.
335
+ combined = infoVisibilityHidden()
336
+ ? stripThinkingBlocks(message)
337
+ : combineAdjacentThinking(message);
386
338
  } catch {
387
339
  // Preserve the original message intact.
388
340
  }
@@ -414,8 +366,10 @@ function installThinkingGroupingPatch():
414
366
 
415
367
  function uninstallThinkingGroupingPatch(
416
368
  state: ThinkingGroupingPatchState | undefined,
417
- ): void {
418
- if (!state) return;
369
+ ): boolean {
370
+ if (!state || state.owners <= 0) return false;
371
+ state.owners--;
372
+ if (state.owners > 0) return false;
419
373
  const proto =
420
374
  AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
421
375
  [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
@@ -428,23 +382,44 @@ function uninstallThinkingGroupingPatch(
428
382
  proto[THINKING_GROUPING_PATCHED] !== state ||
429
383
  proto.updateContent !== state.patchedUpdateContent
430
384
  ) {
431
- return;
385
+ return true;
432
386
  }
433
387
  proto.updateContent = state.originalUpdateContent;
434
388
  delete proto[THINKING_GROUPING_PATCHED];
389
+ return true;
435
390
  }
436
391
 
437
392
  export default function (pi: ExtensionAPI) {
438
393
  const patch = installThinkingGroupingPatch();
394
+ let released = false;
439
395
  pi.on("session_start", (_event, ctx) => {
440
396
  if (ctx.mode !== "tui") return;
397
+ try {
398
+ const proto = AssistantMessageComponent?.prototype as unknown as {
399
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
400
+ };
401
+ proto?.[THINKING_GROUPING_PATCHED]?.rows.clear();
402
+ } catch {
403
+ // Best effort; stale rows would only linger until the process exits.
404
+ }
441
405
  activeTheme = ctx.ui.theme as unknown as ThemeDetail;
406
+ activeLevel = () => {
407
+ try {
408
+ return pi.getThinkingLevel();
409
+ } catch {
410
+ return "off";
411
+ }
412
+ };
442
413
  updateThoughtLabelStyle();
443
414
  });
415
+ pi.on("thinking_level_select", () => updateThoughtLabelStyle());
444
416
  pi.on("session_shutdown", () => {
417
+ if (released) return;
418
+ released = true;
419
+ if (patch && !uninstallThinkingGroupingPatch(patch)) return;
445
420
  activeTheme = undefined;
421
+ activeLevel = undefined;
446
422
  thoughtLabelPrefix = "";
447
423
  thoughtLabelSuffix = "";
448
- uninstallThinkingGroupingPatch(patch);
449
424
  });
450
425
  }