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

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,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Color collapsed rows (tool calls, `+ Thought`, subagents) from the
6
+ theme's `syntaxComment` token — which ships with every theme — instead of
7
+ the louder muted/toolTitle/toolOutput split. The bolded tool name and the
8
+ call content share the single tone, so collapsed blocks read as one
9
+ muted unit under any theme without per-theme tuning. Failures stay
10
+ error-colored and live spinners keep their thinking-level tint.
11
+ - Add optional `collapsedToolCall` and `collapsedThinkingCall` theme color
12
+ tokens to override each collapsed kind independently; themes without
13
+ them are unchanged.
14
+ - Fix settled "+ Thought" labels keeping stale colors after a mid-session
15
+ theme switch: label styles now recompute whenever the theme object
16
+ changes instead of only on session start and thinking-level selects.
5
17
  - Add `/toggle-info`: hide tool calls and thinking entirely, or restore them
6
18
  collapsed. Execution rows (including user-run `!` blocks) are lifted out of
7
19
  the render pass via the shared chat-container hook; thinking blocks are
@@ -13,10 +25,11 @@
13
25
  - Adopt Pi's native content-color split in collapsed rows: tool names ride
14
26
  `toolTitle` and call content/outcomes ride `toolOutput` (structural
15
27
  chrome stays muted; failed rows stay uniformly error).
16
- - Tint the collapsed "+ Thought" label with the session's active
17
- thinking-level token (thinkingOffthinkingMax), re-tinting on
28
+ - Quiet the settled "+ Thought" label to the muted tone collapsed tool
29
+ calls use; only the live "⠋ Thinking" spinner keeps the session's active
30
+ thinking-level tint (thinkingOff…thinkingMax), re-tinting on
18
31
  thinking_level_select; the expanded thinking block is untouched. The
19
- PI_TOOL_CALL_MARKERS_THOUGHT_COLOR values are now `level` (default),
32
+ PI_TOOL_CALL_MARKERS_THOUGHT_COLOR values are `level` (default),
20
33
  `mdheading`, and `inherit`; the midpoint `gray` variant and its RGB
21
34
  color math are gone.
22
35
  - Paint user-run `!` bash block surfaces with the theme's
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:
@@ -73,15 +81,9 @@ When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoni
73
81
 
74
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.
75
83
 
76
- ## Install
77
-
78
- ```bash
79
- pi install npm:@pi-kaush/pi-tool-call-markers
80
- ```
81
-
82
- For local development:
84
+ ## Local development
83
85
 
84
- ```bash
86
+ ```fish
85
87
  pi \
86
88
  -e ./extensions/pi-tool-call-markers/src/index.ts \
87
89
  -e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
@@ -98,6 +100,21 @@ pi
98
100
 
99
101
  `0`, `false`, `no`, and `off` disable parallel grouping. `1`, `true`, `yes`, and `on` enable it. The value is read when the extension loads.
100
102
 
103
+ ### Collapsed-row colors
104
+
105
+ Collapsed rows (tool calls, `+ Thought`, subagents) default to the theme's `syntaxComment` color — it ships with every Pi theme and reads as a muted tone — with the bolded tool name and the call content sharing it. Failures stay error-colored and live spinners keep their existing tints.
106
+
107
+ A theme can override each collapsed kind independently with optional color tokens:
108
+
109
+ ```json
110
+ {
111
+ "colors": {
112
+ "collapsedToolCall": "#6272a4",
113
+ "collapsedThinkingCall": "#6272a4"
114
+ }
115
+ }
116
+ ```
117
+
101
118
  ## Compatibility and fallback policy
102
119
 
103
120
  **Compatible Pi version:** `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` `>=0.80.6`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
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/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./bash-block.ts";
17
17
  import { runChatContainerHooks } from "./container-hooks.ts";
18
18
  import { installInfoVisibility } from "./info-visibility.ts";
19
+ import { fgCollapsed } from "./muted.ts";
19
20
 
20
21
  const OUTER_INSET = 2;
21
22
  const GROUP_MARKER = "%";
@@ -29,6 +30,8 @@ const COLLAPSE_PARALLEL_ENV = "PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL";
29
30
  type ThemeLike = {
30
31
  bold(text: string): string;
31
32
  fg(color: string, text: string): string;
33
+ getFgAnsi?(color: string): string;
34
+ getColorMode?(): "truecolor" | "256color" | string;
32
35
  };
33
36
 
34
37
  type ComponentLike = {
@@ -252,7 +255,7 @@ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
252
255
  }
253
256
 
254
257
  function renderedGenericOutcome(theme: ThemeLike): string {
255
- return theme.fg("toolOutput", "→ done");
258
+ return fgCollapsed(theme, "toolOutput", "→ done");
256
259
  }
257
260
 
258
261
  // The MCP adapter stashes its call's first line in renderer state before
@@ -637,7 +640,7 @@ function renderedEditDiffLines(
637
640
  for (const line of raw.slice(0, MAX_EDIT_DIFF_LINES)) {
638
641
  const clean = sanitizeInline(line).trimEnd();
639
642
  if (clean.trim() === "...") {
640
- lines.push(theme.fg("muted", " ..."));
643
+ lines.push(fgCollapsed(theme, "muted", " ..."));
641
644
  continue;
642
645
  }
643
646
  const match = EDIT_DIFF_LINE_RE.exec(clean);
@@ -652,7 +655,11 @@ function renderedEditDiffLines(
652
655
  }
653
656
  if (raw.length > MAX_EDIT_DIFF_LINES) {
654
657
  lines.push(
655
- theme.fg("muted", ` ... +${raw.length - MAX_EDIT_DIFF_LINES} more`),
658
+ fgCollapsed(
659
+ theme,
660
+ "muted",
661
+ ` ... +${raw.length - MAX_EDIT_DIFF_LINES} more`,
662
+ ),
656
663
  );
657
664
  }
658
665
  return lines;
@@ -706,7 +713,7 @@ function renderedOutcome(
706
713
  ): string | undefined {
707
714
  const summary = outcomeSummary(row);
708
715
  if (!summary) return undefined;
709
- return theme.fg("toolOutput", `→ ${summary}`);
716
+ return fgCollapsed(theme, "toolOutput", `→ ${summary}`);
710
717
  }
711
718
 
712
719
  function renderedGroupedOutcome(
@@ -835,10 +842,10 @@ function renderedCallSummary(
835
842
  .map(trimRenderedLine)
836
843
  .filter(hasVisibleContent);
837
844
  if (visibleLines.length > rendered.length)
838
- continuations.push(theme.fg("muted", "…"));
845
+ continuations.push(fgCollapsed(theme, "muted", "…"));
839
846
  const compact = [firstSummary, ...continuations]
840
847
  .filter(hasVisibleContent)
841
- .join(theme.fg("muted", " · "));
848
+ .join(fgCollapsed(theme, "muted", " · "));
842
849
  if (hasVisibleContent(compact)) return compact;
843
850
  }
844
851
  }
@@ -846,8 +853,8 @@ function renderedCallSummary(
846
853
 
847
854
  const fallback = compactArgs(row.args);
848
855
  return fallback
849
- ? theme.fg("toolOutput", fallback)
850
- : theme.fg("muted", "(no arguments)");
856
+ ? fgCollapsed(theme, "toolOutput", fallback)
857
+ : fgCollapsed(theme, "muted", "(no arguments)");
851
858
  }
852
859
 
853
860
  function styledCallLabel(
@@ -857,15 +864,14 @@ function styledCallLabel(
857
864
  ): string {
858
865
  const plain = sanitizeInline(stripAnsi(label)).trim();
859
866
  const match = /^(\S+)(.*)$/s.exec(plain);
860
- if (!match) return theme.fg(color, plain);
867
+ if (!match) return fgCollapsed(theme, color, plain);
861
868
  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;
869
+ // The colon joins the bolded name to its content. Failed rows stay
870
+ // uniformly error-colored; settled rows use the collapsed mute same
871
+ // color for the bolded name and the call content.
866
872
  return (
867
- theme.fg(nameColor, theme.bold(match[1] ?? "")) +
868
- (rest ? theme.fg(restColor, `:${rest}`) : "")
873
+ fgCollapsed(theme, color, match[1] ?? "", true) +
874
+ (rest ? fgCollapsed(theme, color, `:${rest}`) : "")
869
875
  );
870
876
  }
871
877
 
@@ -904,7 +910,7 @@ function collapsedOutcome(
904
910
  }
905
911
  if (row.getRenderShell?.() === "self") {
906
912
  const editStat = renderedEditDiffStat(row);
907
- if (editStat) return theme.fg("toolOutput", `→ ${editStat}`);
913
+ if (editStat) return fgCollapsed(theme, "toolOutput", `→ ${editStat}`);
908
914
  return renderedGenericOutcome(theme);
909
915
  }
910
916
  return renderedOutcome(row, theme);
@@ -917,18 +923,19 @@ function collapsedHeadline(
917
923
  ): string {
918
924
  // Failed rows render entirely in error so the row reads as the one that
919
925
  // failed, not just its outcome tail. Only the tool name is bold.
920
- const color = rowHasFailed(row) ? "error" : "muted";
921
- const marker = `${theme.fg(
922
- color,
926
+ const tone = rowHasFailed(row) ? "error" : "muted";
927
+ const marker = `${fgCollapsed(
928
+ theme,
929
+ tone,
923
930
  row.toolName === "subagent" ? SUBAGENT_MARKER : GROUP_MARKER,
924
931
  )} `;
925
932
  const budget = Math.max(1, width - visibleWidth(marker));
926
- const label = collapsedCallLabel(row, budget, theme, color);
933
+ const label = collapsedCallLabel(row, budget, theme, tone);
927
934
  const outcome = collapsedOutcome(row, budget, theme);
928
935
  // The truncation suffix inherits the row tone; pi-tui's truncation resets
929
936
  // around a plain suffix, which would render it in the terminal default
930
937
  // foreground instead of the row color.
931
- const suffix = theme.fg(color, "…");
938
+ const suffix = fgCollapsed(theme, tone, "…");
932
939
  return (
933
940
  marker +
934
941
  (outcome
@@ -1099,20 +1106,25 @@ function renderSubagentPlan(
1099
1106
  const failed = rowHasFailed(row);
1100
1107
  const color = failed ? "error" : "muted";
1101
1108
  const nameColor = failed ? "error" : "accent";
1102
- const suffix = theme.fg(color, "…");
1109
+ const suffix = fgCollapsed(theme, color, "…");
1103
1110
  const displayNames = scrapeSubagentDisplayNames(row);
1104
1111
  // Agent/profile/task values are model-supplied; sanitize them like every
1105
1112
  // other collapsed-row text so control bytes cannot reach the terminal.
1106
1113
  const displayOf = (agent: string) =>
1107
- theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
1114
+ fgCollapsed(
1115
+ theme,
1116
+ nameColor,
1117
+ displayNames.get(agent) ?? sanitizeInline(agent),
1118
+ );
1108
1119
  const detailColor = failed ? "error" : "toolOutput";
1109
1120
  const detailOf = (step: SubagentStep) =>
1110
- theme.fg(
1121
+ fgCollapsed(
1122
+ theme,
1111
1123
  detailColor,
1112
1124
  `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
1113
1125
  );
1114
1126
 
1115
- const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
1127
+ const marker = `${fgCollapsed(theme, color, SUBAGENT_MARKER, true)} `;
1116
1128
  const budget = Math.max(1, width - visibleWidth(marker));
1117
1129
  const progress = subagentProgressText(row);
1118
1130
  const outcome = failed
@@ -1120,7 +1132,11 @@ function renderSubagentPlan(
1120
1132
  : isLiveRow(row)
1121
1133
  ? theme.fg("warning", progress ? `→ ${progress}` : "…")
1122
1134
  : row.result
1123
- ? theme.fg("toolOutput", progress ? `→ ${progress}` : "→ done")
1135
+ ? fgCollapsed(
1136
+ theme,
1137
+ "toolOutput",
1138
+ progress ? `→ ${progress}` : "→ done",
1139
+ )
1124
1140
  : theme.fg("warning", "…");
1125
1141
  const headline = (label: string) =>
1126
1142
  marker +
@@ -1132,7 +1148,7 @@ function renderSubagentPlan(
1132
1148
  const step = plan.steps[0]!;
1133
1149
  return [
1134
1150
  headline(
1135
- theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1151
+ fgCollapsed(theme, failed ? "error" : "toolTitle", "subagent", true) +
1136
1152
  " " +
1137
1153
  displayOf(step.agent) +
1138
1154
  detailOf(step),
@@ -1146,22 +1162,24 @@ function renderSubagentPlan(
1146
1162
  : `parallel (${plan.steps.length} tasks)`;
1147
1163
  const lines = [
1148
1164
  headline(
1149
- theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1165
+ fgCollapsed(theme, failed ? "error" : "toolTitle", "subagent", true) +
1150
1166
  " " +
1151
- theme.fg(failed ? "error" : "toolOutput", kindLabel) +
1152
- theme.fg(failed ? "error" : "toolOutput", ` [${plan.scope}]`),
1167
+ fgCollapsed(theme, failed ? "error" : "toolOutput", kindLabel) +
1168
+ fgCollapsed(theme, failed ? "error" : "toolOutput", ` [${plan.scope}]`),
1153
1169
  ),
1154
1170
  ];
1155
1171
  const shown = plan.steps.slice(0, 3);
1156
1172
  for (let index = 0; index < shown.length; index++) {
1157
1173
  const step = shown[index]!;
1158
1174
  const number =
1159
- plan.kind === "chain" ? `${theme.fg(color, `${index + 1}.`)} ` : "";
1175
+ plan.kind === "chain"
1176
+ ? `${fgCollapsed(theme, color, `${index + 1}.`)} `
1177
+ : "";
1160
1178
  lines.push(` ${number}${displayOf(step.agent)}${detailOf(step)}`);
1161
1179
  }
1162
1180
  if (plan.steps.length > shown.length) {
1163
1181
  lines.push(
1164
- ` ${theme.fg(color, `... +${plan.steps.length - shown.length} more`)}`,
1182
+ ` ${fgCollapsed(theme, color, `... +${plan.steps.length - shown.length} more`)}`,
1165
1183
  );
1166
1184
  }
1167
1185
  return lines;
@@ -1182,7 +1200,7 @@ function imageResultLines(
1182
1200
  .join("\n");
1183
1201
  lines.push(
1184
1202
  ...wrapTextWithAnsi(
1185
- theme.fg("toolOutput", sanitized),
1203
+ fgCollapsed(theme, "toolOutput", sanitized),
1186
1204
  Math.max(1, width),
1187
1205
  ),
1188
1206
  );
@@ -1238,7 +1256,7 @@ function groupedChildLabel(
1238
1256
  Math.max(0, visibleWidth(label) - prefixWidth),
1239
1257
  true,
1240
1258
  );
1241
- return child || theme.fg(color, "(no arguments)");
1259
+ return child || fgCollapsed(theme, color, "(no arguments)");
1242
1260
  }
1243
1261
 
1244
1262
  function renderGroupedCallLines(
@@ -1252,17 +1270,17 @@ function renderGroupedCallLines(
1252
1270
  const toolName = row.toolName ?? "tool";
1253
1271
  if (toolName !== previousToolName) {
1254
1272
  lines.push(
1255
- `${theme.fg("muted", GROUP_MARKER)} ${theme.fg("toolTitle", theme.bold(toolName))}`,
1273
+ `${fgCollapsed(theme, "muted", GROUP_MARKER)} ${fgCollapsed(theme, "toolTitle", toolName, true)}`,
1256
1274
  );
1257
1275
  previousToolName = toolName;
1258
1276
  }
1259
1277
 
1260
1278
  const color = rowHasFailed(row) ? "error" : "muted";
1261
- const prefix = ` ${theme.fg(color, "•")} `;
1279
+ const prefix = ` ${fgCollapsed(theme, color, "•")} `;
1262
1280
  const budget = Math.max(1, width - visibleWidth(prefix));
1263
1281
  const label = groupedChildLabel(row, budget, theme, color);
1264
1282
  const outcome = collapsedOutcome(row, budget, theme);
1265
- const suffix = theme.fg(color, "…");
1283
+ const suffix = fgCollapsed(theme, color, "…");
1266
1284
  lines.push(
1267
1285
  prefix +
1268
1286
  (outcome
@@ -1660,6 +1678,14 @@ function uninstallPresentationPatch(
1660
1678
  delete proto[PRESENTATION_PATCHED];
1661
1679
  }
1662
1680
 
1681
+ // TODO(compaction cards): native/failed compaction cards in the transcript
1682
+ // are currently rendered by the pi-verbatim-compaction extension. Its
1683
+ // src/chat-log.ts is deliberately self-contained (public Pi imports only), so
1684
+ // porting here is: drop in that file, registerEntryRenderer(COMPACTION_LOG_TYPE),
1685
+ // and wire pi.on("session_compact") / pi.on("session_compact_failed") filtered
1686
+ // to fromExtension === false. Do this only if pi-verbatim-compaction is
1687
+ // removed or native cards should exist without it; while both are installed,
1688
+ // rendering here too would double-render every native compaction.
1663
1689
  export default function (pi: ExtensionAPI) {
1664
1690
  const patch = installPresentationPatch();
1665
1691
  const grouping = patch ? installGroupingPatch(patch) : undefined;
@@ -1673,6 +1699,12 @@ export default function (pi: ExtensionAPI) {
1673
1699
  ctx.ui.setToolsExpanded(false);
1674
1700
  });
1675
1701
 
1702
+ pi.on("session_start", (_event, ctx) => {
1703
+ if (patch) patch.theme = ctx.ui.theme;
1704
+ if (bashPatch) bashPatch.theme = ctx.ui.theme;
1705
+ ctx.ui.setToolsExpanded(false);
1706
+ });
1707
+
1676
1708
  pi.on("session_shutdown", () => {
1677
1709
  if (released) return;
1678
1710
  released = true;
package/src/muted.ts ADDED
@@ -0,0 +1,80 @@
1
+ // Collapsed-row color policy.
2
+ //
3
+ // Collapsed blocks (tool calls, settled "+ Thought" labels, subagent rows)
4
+ // default to the theme's `syntaxComment` token — it ships with every Pi
5
+ // theme and reads as a muted tone — instead of the louder
6
+ // muted/toolTitle/toolOutput split. A theme can override each collapsed
7
+ // kind independently with a `collapsedToolCall` or `collapsedThinkingCall`
8
+ // color token.
9
+ //
10
+ // Failures always fall back to the historical tokens.
11
+
12
+ const TOOL_OVERRIDE_TOKEN = "collapsedToolCall";
13
+ const THINKING_OVERRIDE_TOKEN = "collapsedThinkingCall";
14
+ const DEFAULT_TOKEN = "syntaxComment";
15
+
16
+ type CollapsedTheme = {
17
+ fg(color: string, text: string): string;
18
+ bold?(text: string): string;
19
+ getFgAnsi?(color: string): string;
20
+ };
21
+
22
+ // Resolved per theme object (Pi's Theme throws on unknown tokens; themes
23
+ // without getFgAnsi have no overrides), alongside index.ts's other
24
+ // theme-identity-keyed caches.
25
+ const ansiCache = new WeakMap<object, Map<string, string | null>>();
26
+
27
+ function tokenAnsi(theme: CollapsedTheme, token: string): string | null {
28
+ let cache = ansiCache.get(theme);
29
+ if (!cache) {
30
+ cache = new Map();
31
+ ansiCache.set(theme, cache);
32
+ }
33
+ if (cache.has(token)) return cache.get(token)!;
34
+ let ansi: string | null = null;
35
+ try {
36
+ const resolved = theme.getFgAnsi?.(token);
37
+ if (typeof resolved === "string") ansi = resolved;
38
+ } catch {
39
+ ansi = null;
40
+ }
41
+ cache.set(token, ansi);
42
+ return ansi;
43
+ }
44
+
45
+ // Override token when defined, else the theme's syntaxComment color, else
46
+ // null (caller falls back to the historical tokens).
47
+ function collapsedAnsi(
48
+ theme: CollapsedTheme,
49
+ overrideToken: string,
50
+ ): string | null {
51
+ return tokenAnsi(theme, overrideToken) ?? tokenAnsi(theme, DEFAULT_TOKEN);
52
+ }
53
+
54
+ export function collapsedToolAnsi(theme: CollapsedTheme): string | null {
55
+ return collapsedAnsi(theme, TOOL_OVERRIDE_TOKEN);
56
+ }
57
+
58
+ export function collapsedThinkingAnsi(theme: CollapsedTheme): string | null {
59
+ return collapsedAnsi(theme, THINKING_OVERRIDE_TOKEN);
60
+ }
61
+
62
+ // One styling entry point for collapsed-row text. `color` is the historical
63
+ // token role ("muted", "toolOutput", "toolTitle", "accent", "error"); error
64
+ // keeps its semantic color in all paths.
65
+ export function fgCollapsed(
66
+ theme: CollapsedTheme,
67
+ color: string,
68
+ text: string,
69
+ bold = false,
70
+ ): string {
71
+ if (color !== "error") {
72
+ const ansi = collapsedToolAnsi(theme);
73
+ if (ansi) {
74
+ return bold
75
+ ? `${ansi}\x1b[1m${text}\x1b[22m\x1b[39m`
76
+ : `${ansi}${text}\x1b[39m`;
77
+ }
78
+ }
79
+ return theme.fg(color, bold && theme.bold ? theme.bold(text) : text);
80
+ }
@@ -1,18 +1,22 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
3
3
  import { infoVisibilityHidden } from "./info-visibility-state.ts";
4
+ import { collapsedThinkingAnsi } from "./muted.ts";
4
5
 
5
6
  const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v2");
6
7
  const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
8
  const SPINNER_INTERVAL_MS = 80;
8
9
 
9
- // Visual experiment for the settled "+ Thought" label. "inherit" keeps Pi's
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.
10
+ // Visual treatment for the collapsed thinking label. The live "Thinking…"
11
+ // spinner tints with the session's active thinking-level color
12
+ // (thinkingOff…thinkingMax), so progress reads as activity; the settled
13
+ // "+ Thought" row drops to the muted token collapsed tool calls use, so
14
+ // finished reasoning stops advertising the level. "inherit" keeps Pi's
15
+ // native styling (italic + thinkingText) for both states, and "mdheading"
16
+ // rides the theme's mdHeading token for both. The env var overrides the
17
+ // default so variants can be compared without republishing. Only the
18
+ // collapsed label is restyled — the expanded thinking block keeps Pi's
19
+ // thinkingText.
16
20
  const THOUGHT_LABEL_COLOR_ENV = "PI_TOOL_CALL_MARKERS_THOUGHT_COLOR";
17
21
  type ThoughtLabelColor = "inherit" | "level" | "mdheading";
18
22
  const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "level";
@@ -41,38 +45,76 @@ type ThemeDetail = {
41
45
  getColorMode?(): "truecolor" | "256color" | string;
42
46
  };
43
47
 
44
- let activeTheme: ThemeDetail | undefined;
48
+ // Live reference into the UI context: reading .theme per check keeps label
49
+ // styles current across mid-session theme switches, which fire no event.
50
+ let themeProvider: (() => ThemeDetail | undefined) | undefined;
45
51
  let activeLevel: (() => string) | undefined;
52
+ // Theme object the current label styles were computed from.
53
+ let stylesTheme: ThemeDetail | undefined;
54
+
55
+ type LabelStyle = { prefix: string; suffix: string };
56
+ let liveLabelStyle: LabelStyle | undefined;
57
+ let settledLabelStyle: LabelStyle | undefined;
58
+
59
+ function styledWith(
60
+ token: string,
61
+ theme: ThemeDetail | undefined = themeProvider?.(),
62
+ ): LabelStyle | undefined {
63
+ const ansi = theme?.getFgAnsi?.(token);
64
+ if (!ansi) return undefined;
65
+ // The styled label replaces Pi's italicized Text node wholesale (see
66
+ // restyleHiddenThinkingLabel). The leading italic-off still matters: the
67
+ // TUI's diff renderer can skip bytes shared with the previously drawn
68
+ // italic line, leaving the terminal in italic state otherwise.
69
+ return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
70
+ }
46
71
 
47
- let thoughtLabelPrefix = "";
48
- let thoughtLabelSuffix = "";
49
-
72
+ // The theme's collapsedThinkingCall override when defined, else its comment
73
+ // color, else the muted token.
74
+ function settledMutedStyle(
75
+ theme: ThemeDetail | undefined,
76
+ ): LabelStyle | undefined {
77
+ const ansi = theme ? collapsedThinkingAnsi(theme) : null;
78
+ if (ansi) return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
79
+ return styledWith("muted", theme);
80
+ }
50
81
  function updateThoughtLabelStyle(): void {
51
- thoughtLabelPrefix = "";
52
- thoughtLabelSuffix = "";
82
+ liveLabelStyle = undefined;
83
+ settledLabelStyle = undefined;
84
+ const theme = themeProvider?.();
85
+ stylesTheme = theme;
53
86
  const choice = thoughtLabelColorChoice();
54
- if (choice === "inherit" || !activeTheme) return;
87
+ if (choice === "inherit" || !theme) return;
55
88
 
56
- let ansi: string | undefined;
57
89
  const token =
58
90
  choice === "mdheading"
59
91
  ? "mdHeading"
60
92
  : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
61
- ansi = activeTheme.getFgAnsi?.(token);
62
- if (!ansi) return;
93
+ liveLabelStyle = styledWith(token, theme);
94
+ settledLabelStyle =
95
+ choice === "mdheading" ? liveLabelStyle : settledMutedStyle(theme);
96
+ }
63
97
 
64
- // The styled label replaces Pi's italicized Text node wholesale (see
65
- // restyleHiddenThinkingLabel). The leading italic-off still matters: the
66
- // TUI's diff renderer can skip bytes shared with the previously drawn
67
- // italic line, leaving the terminal in italic state otherwise.
68
- thoughtLabelPrefix = `\x1b[23m${ansi}`;
69
- thoughtLabelSuffix = "\x1b[39m";
98
+ // Recompute label styles whenever the theme object has swapped (theme switch
99
+ // or /reload); those transitions fire no extension event.
100
+ function ensureLabelStyles(): void {
101
+ if (themeProvider?.() !== stylesTheme) updateThoughtLabelStyle();
102
+ }
103
+
104
+ // The settled label is the finalized "+ Thought" row; every other label is
105
+ // the live spinner.
106
+ function isSettledThoughtLabel(label: string): boolean {
107
+ return label.startsWith("+ Thought");
70
108
  }
71
109
 
72
110
  export function visibleThoughtLabel(label: string): string {
73
- if (!thoughtLabelPrefix) return label;
111
+ ensureLabelStyles();
112
+ const style = isSettledThoughtLabel(label)
113
+ ? settledLabelStyle
114
+ : liveLabelStyle;
115
+ if (!style) return label;
74
116
  const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
75
- return `${thoughtLabelPrefix}${raw}${thoughtLabelSuffix}`;
117
+ return `${style.prefix}${raw}${style.suffix}`;
76
118
  }
77
119
 
78
120
  type AssistantMessageLike = {
@@ -98,7 +140,6 @@ type TextLikeChild = {
98
140
  // terminal. Swap the node's text for a self-contained styled version after
99
141
  // each native render pass instead.
100
142
  function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
101
- if (!thoughtLabelPrefix) return;
102
143
  if (
103
144
  row.hideThinkingBlock !== true ||
104
145
  typeof row.hiddenThinkingLabel !== "string"
@@ -108,6 +149,7 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
108
149
  const label = row.hiddenThinkingLabel;
109
150
  const children = row.contentContainer?.children;
110
151
  if (!Array.isArray(children)) return;
152
+ if (!liveLabelStyle && !settledLabelStyle) return;
111
153
  const styled = visibleThoughtLabel(label);
112
154
  for (const child of children) {
113
155
  const textChild = child as TextLikeChild | undefined;
@@ -402,7 +444,8 @@ export default function (pi: ExtensionAPI) {
402
444
  } catch {
403
445
  // Best effort; stale rows would only linger until the process exits.
404
446
  }
405
- activeTheme = ctx.ui.theme as unknown as ThemeDetail;
447
+ const uiCtx = ctx;
448
+ themeProvider = () => uiCtx.ui?.theme as unknown as ThemeDetail;
406
449
  activeLevel = () => {
407
450
  try {
408
451
  return pi.getThinkingLevel();
@@ -417,9 +460,10 @@ export default function (pi: ExtensionAPI) {
417
460
  if (released) return;
418
461
  released = true;
419
462
  if (patch && !uninstallThinkingGroupingPatch(patch)) return;
420
- activeTheme = undefined;
463
+ themeProvider = undefined;
421
464
  activeLevel = undefined;
422
- thoughtLabelPrefix = "";
423
- thoughtLabelSuffix = "";
465
+ stylesTheme = undefined;
466
+ liveLabelStyle = undefined;
467
+ settledLabelStyle = undefined;
424
468
  });
425
469
  }