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

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,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Restyle subagent rows: the subagent marker is now `↪` (was `&`), and single
6
+ calls drop the `subagent` tool label — `↪ 🤖 [coder][c3po] Implement the…` —
7
+ with chain/parallel step lines reordered to emoji, profile badge, name badge
8
+ (`1. 🐝 [workhorse][bee] …`). Single-call headings are now scraped too, so
9
+ agent emojis appear on single rows, not just chain/parallel steps.
10
+
11
+ - Color collapsed rows (tool calls, `+ Thought`, subagents) from the
12
+ theme's `syntaxComment` token — which ships with every theme — instead of
13
+ the louder muted/toolTitle/toolOutput split. The bolded tool name and the
14
+ call content share the single tone, so collapsed blocks read as one
15
+ muted unit under any theme without per-theme tuning. Failures stay
16
+ error-colored and live spinners keep their thinking-level tint.
17
+ - Add optional `collapsedToolCall` and `collapsedThinkingCall` theme color
18
+ tokens to override each collapsed kind independently; themes without
19
+ them are unchanged.
20
+ - Fix settled "+ Thought" labels keeping stale colors after a mid-session
21
+ theme switch: label styles now recompute whenever the theme object
22
+ changes instead of only on session start and thinking-level selects.
5
23
  - Add `/toggle-info`: hide tool calls and thinking entirely, or restore them
6
24
  collapsed. Execution rows (including user-run `!` blocks) are lifted out of
7
25
  the render pass via the shared chat-container hook; thinking blocks are
package/README.md CHANGED
@@ -34,19 +34,19 @@ Collapsed tool rows use semantic theme colors with no gear, background fill, box
34
34
 
35
35
  ## Subagent plans
36
36
 
37
- A recognized `subagent` call renders as an unboxed plan in the shared tool aesthetic, marked with `&` instead of the ordinary `%` tool marker:
37
+ A recognized `subagent` call renders as an unboxed plan in the shared tool aesthetic, marked with `↪` instead of the ordinary `%` tool marker:
38
38
 
39
39
  ```text
40
- & subagent chain (3 steps) [repo-review]
40
+ subagent chain (3 steps) [repo-review]
41
41
  1. 🐝 bee [workhorse] Challenge the compatibility conclusion…
42
42
  2. 🐝 bee …
43
43
  ```
44
44
 
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.
45
+ Single calls stay on one `↪ [<emoji>] [<profile>][<agent>] <task preview>` row — no tool label; the `↪` marker identifies it. Chain calls get a heading with the kind, count, and scope followed by numbered steps (parallel tasks list without numbers), with each step ordered emoji, profile badge, name badge. Agent display names (emoji + name) are scraped from the native plan component — including the single-call heading — with an args fallback, and render in `accent`; everything else stays muted, and failed subagents go full red. Subagents never join ordinary tool groups.
46
46
 
47
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
48
 
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.
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.
50
50
 
51
51
  ## Edit diffs
52
52
 
@@ -100,6 +100,21 @@ pi
100
100
 
101
101
  `0`, `false`, `no`, and `off` disable parallel grouping. `1`, `true`, `yes`, and `on` enable it. The value is read when the extension loads.
102
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
+
103
118
  ## Compatibility and fallback policy
104
119
 
105
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.11",
3
+ "version": "0.3.1",
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,10 +16,11 @@ 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 = "%";
22
- const SUBAGENT_MARKER = "&";
23
+ const SUBAGENT_MARKER = "";
23
24
  const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
24
25
  const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
25
26
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
@@ -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
@@ -1021,6 +1028,19 @@ function parseStepContent(content: string): {
1021
1028
  };
1022
1029
  }
1023
1030
 
1031
+ // Splits a scraped "emoji name" display into its emoji prefix and bare name;
1032
+ // plain names pass through with no emoji.
1033
+ function splitEmojiName(
1034
+ displayName: string,
1035
+ bareName: string,
1036
+ ): { emoji: string; name: string } {
1037
+ const tokens = displayName.trim().split(/\s+/).filter(Boolean);
1038
+ if (tokens.length > 1 && tokens[tokens.length - 1] === bareName) {
1039
+ return { emoji: tokens.slice(0, -1).join(" "), name: bareName };
1040
+ }
1041
+ return { emoji: "", name: bareName };
1042
+ }
1043
+
1024
1044
  // Agent display names (emoji + name) come from the subagent extension's own
1025
1045
  // call component when available; args only carry the bare agent name.
1026
1046
  function scrapeSubagentDisplayNames(
@@ -1035,10 +1055,16 @@ function scrapeSubagentDisplayNames(
1035
1055
  try {
1036
1056
  for (const raw of component.render(120)) {
1037
1057
  const line = sanitizeInline(stripAnsi(raw)).trim();
1038
- if (!line || /^subagent\b/.test(line)) continue;
1039
- const { displayName, bareName } = parseStepContent(
1040
- line.replace(/^\d+\.\s*/, ""),
1041
- );
1058
+ if (!line) continue;
1059
+ // Single calls carry the agent name on their "subagent <name>" heading
1060
+ // line; stripping the heading word (and step numbers) lets both shapes
1061
+ // flow through the same parse. Heading remainders without an emoji
1062
+ // (e.g. "chain (2 steps)") degrade to displayName === bareName and are
1063
+ // skipped by the guard below.
1064
+ const content = line
1065
+ .replace(/^subagent\b\s*/, "")
1066
+ .replace(/^\d+\.\s*/, "");
1067
+ const { displayName, bareName } = parseStepContent(content);
1042
1068
  if (displayName && bareName && displayName !== bareName) {
1043
1069
  names.set(bareName, displayName);
1044
1070
  }
@@ -1084,10 +1110,11 @@ function subagentProgressText(row: ToolExecutionRow): string | undefined {
1084
1110
  return parts.length > 0 ? parts.join(" · ") : undefined;
1085
1111
  }
1086
1112
 
1087
- // Subagents render as an ordinary unboxed tool block: a `%` heading with the
1113
+ // Subagents render as an ordinary unboxed tool block: a `↪` heading with the
1088
1114
  // plan kind/count/scope, then the numbered chain steps or parallel tasks with
1089
- // agent names in accent. Everything else stays muted (or error on failure),
1090
- // matching the shared tool-row aesthetic.
1115
+ // agent identities (emoji, profile badge, name badge) in accent. Single calls
1116
+ // collapse to one row: `↪ <emoji> [<profile>][<name>] <task preview>`. Everything
1117
+ // else stays muted (or error on failure), matching the shared tool-row aesthetic.
1091
1118
  function renderSubagentPlan(
1092
1119
  row: ToolExecutionRow,
1093
1120
  width: number,
@@ -1099,20 +1126,27 @@ function renderSubagentPlan(
1099
1126
  const failed = rowHasFailed(row);
1100
1127
  const color = failed ? "error" : "muted";
1101
1128
  const nameColor = failed ? "error" : "accent";
1102
- const suffix = theme.fg(color, "…");
1129
+ const suffix = fgCollapsed(theme, color, "…");
1103
1130
  const displayNames = scrapeSubagentDisplayNames(row);
1104
1131
  // Agent/profile/task values are model-supplied; sanitize them like every
1105
1132
  // other collapsed-row text so control bytes cannot reach the terminal.
1106
- const displayOf = (agent: string) =>
1107
- theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
1133
+ // Identity order: emoji, then the profile and agent-name badges.
1134
+ const identityOf = (step: SubagentStep) => {
1135
+ const displayName =
1136
+ displayNames.get(step.agent) ?? sanitizeInline(step.agent);
1137
+ const { emoji, name } = splitEmojiName(displayName, step.agent);
1138
+ const accentPart = (text: string) => fgCollapsed(theme, nameColor, text);
1139
+ const emojiPart = emoji ? `${accentPart(emoji)} ` : "";
1140
+ const profilePart = step.profile
1141
+ ? accentPart(`[${sanitizeInline(step.profile)}]`)
1142
+ : "";
1143
+ return `${emojiPart}${profilePart}${accentPart(`[${sanitizeInline(name)}]`)}`;
1144
+ };
1108
1145
  const detailColor = failed ? "error" : "toolOutput";
1109
- const detailOf = (step: SubagentStep) =>
1110
- theme.fg(
1111
- detailColor,
1112
- `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
1113
- );
1146
+ const previewOf = (step: SubagentStep) =>
1147
+ fgCollapsed(theme, detailColor, ` ${subagentStepPreview(step.task)}`);
1114
1148
 
1115
- const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
1149
+ const marker = `${fgCollapsed(theme, color, SUBAGENT_MARKER, true)} `;
1116
1150
  const budget = Math.max(1, width - visibleWidth(marker));
1117
1151
  const progress = subagentProgressText(row);
1118
1152
  const outcome = failed
@@ -1120,7 +1154,11 @@ function renderSubagentPlan(
1120
1154
  : isLiveRow(row)
1121
1155
  ? theme.fg("warning", progress ? `→ ${progress}` : "…")
1122
1156
  : row.result
1123
- ? theme.fg("toolOutput", progress ? `→ ${progress}` : "→ done")
1157
+ ? fgCollapsed(
1158
+ theme,
1159
+ "toolOutput",
1160
+ progress ? `→ ${progress}` : "→ done",
1161
+ )
1124
1162
  : theme.fg("warning", "…");
1125
1163
  const headline = (label: string) =>
1126
1164
  marker +
@@ -1130,14 +1168,7 @@ function renderSubagentPlan(
1130
1168
 
1131
1169
  if (plan.kind === "single") {
1132
1170
  const step = plan.steps[0]!;
1133
- return [
1134
- headline(
1135
- theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1136
- " " +
1137
- displayOf(step.agent) +
1138
- detailOf(step),
1139
- ),
1140
- ];
1171
+ return [headline(identityOf(step) + previewOf(step))];
1141
1172
  }
1142
1173
 
1143
1174
  const kindLabel =
@@ -1146,22 +1177,24 @@ function renderSubagentPlan(
1146
1177
  : `parallel (${plan.steps.length} tasks)`;
1147
1178
  const lines = [
1148
1179
  headline(
1149
- theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
1180
+ fgCollapsed(theme, failed ? "error" : "toolTitle", "subagent", true) +
1150
1181
  " " +
1151
- theme.fg(failed ? "error" : "toolOutput", kindLabel) +
1152
- theme.fg(failed ? "error" : "toolOutput", ` [${plan.scope}]`),
1182
+ fgCollapsed(theme, failed ? "error" : "toolOutput", kindLabel) +
1183
+ fgCollapsed(theme, failed ? "error" : "toolOutput", ` [${plan.scope}]`),
1153
1184
  ),
1154
1185
  ];
1155
1186
  const shown = plan.steps.slice(0, 3);
1156
1187
  for (let index = 0; index < shown.length; index++) {
1157
1188
  const step = shown[index]!;
1158
1189
  const number =
1159
- plan.kind === "chain" ? `${theme.fg(color, `${index + 1}.`)} ` : "";
1160
- lines.push(` ${number}${displayOf(step.agent)}${detailOf(step)}`);
1190
+ plan.kind === "chain"
1191
+ ? `${fgCollapsed(theme, color, `${index + 1}.`)} `
1192
+ : "";
1193
+ lines.push(` ${number}${identityOf(step)}${previewOf(step)}`);
1161
1194
  }
1162
1195
  if (plan.steps.length > shown.length) {
1163
1196
  lines.push(
1164
- ` ${theme.fg(color, `... +${plan.steps.length - shown.length} more`)}`,
1197
+ ` ${fgCollapsed(theme, color, `... +${plan.steps.length - shown.length} more`)}`,
1165
1198
  );
1166
1199
  }
1167
1200
  return lines;
@@ -1182,7 +1215,7 @@ function imageResultLines(
1182
1215
  .join("\n");
1183
1216
  lines.push(
1184
1217
  ...wrapTextWithAnsi(
1185
- theme.fg("toolOutput", sanitized),
1218
+ fgCollapsed(theme, "toolOutput", sanitized),
1186
1219
  Math.max(1, width),
1187
1220
  ),
1188
1221
  );
@@ -1238,7 +1271,7 @@ function groupedChildLabel(
1238
1271
  Math.max(0, visibleWidth(label) - prefixWidth),
1239
1272
  true,
1240
1273
  );
1241
- return child || theme.fg(color, "(no arguments)");
1274
+ return child || fgCollapsed(theme, color, "(no arguments)");
1242
1275
  }
1243
1276
 
1244
1277
  function renderGroupedCallLines(
@@ -1252,17 +1285,17 @@ function renderGroupedCallLines(
1252
1285
  const toolName = row.toolName ?? "tool";
1253
1286
  if (toolName !== previousToolName) {
1254
1287
  lines.push(
1255
- `${theme.fg("muted", GROUP_MARKER)} ${theme.fg("toolTitle", theme.bold(toolName))}`,
1288
+ `${fgCollapsed(theme, "muted", GROUP_MARKER)} ${fgCollapsed(theme, "toolTitle", toolName, true)}`,
1256
1289
  );
1257
1290
  previousToolName = toolName;
1258
1291
  }
1259
1292
 
1260
1293
  const color = rowHasFailed(row) ? "error" : "muted";
1261
- const prefix = ` ${theme.fg(color, "•")} `;
1294
+ const prefix = ` ${fgCollapsed(theme, color, "•")} `;
1262
1295
  const budget = Math.max(1, width - visibleWidth(prefix));
1263
1296
  const label = groupedChildLabel(row, budget, theme, color);
1264
1297
  const outcome = collapsedOutcome(row, budget, theme);
1265
- const suffix = theme.fg(color, "…");
1298
+ const suffix = fgCollapsed(theme, color, "…");
1266
1299
  lines.push(
1267
1300
  prefix +
1268
1301
  (outcome
@@ -1681,6 +1714,12 @@ export default function (pi: ExtensionAPI) {
1681
1714
  ctx.ui.setToolsExpanded(false);
1682
1715
  });
1683
1716
 
1717
+ pi.on("session_start", (_event, ctx) => {
1718
+ if (patch) patch.theme = ctx.ui.theme;
1719
+ if (bashPatch) bashPatch.theme = ctx.ui.theme;
1720
+ ctx.ui.setToolsExpanded(false);
1721
+ });
1722
+
1684
1723
  pi.on("session_shutdown", () => {
1685
1724
  if (released) return;
1686
1725
  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,6 +1,7 @@
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -44,15 +45,22 @@ type ThemeDetail = {
44
45
  getColorMode?(): "truecolor" | "256color" | string;
45
46
  };
46
47
 
47
- 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;
48
51
  let activeLevel: (() => string) | undefined;
52
+ // Theme object the current label styles were computed from.
53
+ let stylesTheme: ThemeDetail | undefined;
49
54
 
50
55
  type LabelStyle = { prefix: string; suffix: string };
51
56
  let liveLabelStyle: LabelStyle | undefined;
52
57
  let settledLabelStyle: LabelStyle | undefined;
53
58
 
54
- function styledWith(token: string): LabelStyle | undefined {
55
- const ansi = activeTheme?.getFgAnsi?.(token);
59
+ function styledWith(
60
+ token: string,
61
+ theme: ThemeDetail | undefined = themeProvider?.(),
62
+ ): LabelStyle | undefined {
63
+ const ansi = theme?.getFgAnsi?.(token);
56
64
  if (!ansi) return undefined;
57
65
  // The styled label replaces Pi's italicized Text node wholesale (see
58
66
  // restyleHiddenThinkingLabel). The leading italic-off still matters: the
@@ -61,19 +69,36 @@ function styledWith(token: string): LabelStyle | undefined {
61
69
  return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
62
70
  }
63
71
 
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
+ }
64
81
  function updateThoughtLabelStyle(): void {
65
82
  liveLabelStyle = undefined;
66
83
  settledLabelStyle = undefined;
84
+ const theme = themeProvider?.();
85
+ stylesTheme = theme;
67
86
  const choice = thoughtLabelColorChoice();
68
- if (choice === "inherit" || !activeTheme) return;
87
+ if (choice === "inherit" || !theme) return;
69
88
 
70
89
  const token =
71
90
  choice === "mdheading"
72
91
  ? "mdHeading"
73
92
  : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
74
- liveLabelStyle = styledWith(token);
93
+ liveLabelStyle = styledWith(token, theme);
75
94
  settledLabelStyle =
76
- choice === "mdheading" ? liveLabelStyle : styledWith("muted");
95
+ choice === "mdheading" ? liveLabelStyle : settledMutedStyle(theme);
96
+ }
97
+
98
+ // Recompute label styles whenever the theme object has swapped (theme switch
99
+ // or /reload); those transitions fire no extension event.
100
+ function ensureLabelStyles(): void {
101
+ if (themeProvider?.() !== stylesTheme) updateThoughtLabelStyle();
77
102
  }
78
103
 
79
104
  // The settled label is the finalized "+ Thought" row; every other label is
@@ -83,6 +108,7 @@ function isSettledThoughtLabel(label: string): boolean {
83
108
  }
84
109
 
85
110
  export function visibleThoughtLabel(label: string): string {
111
+ ensureLabelStyles();
86
112
  const style = isSettledThoughtLabel(label)
87
113
  ? settledLabelStyle
88
114
  : liveLabelStyle;
@@ -418,7 +444,8 @@ export default function (pi: ExtensionAPI) {
418
444
  } catch {
419
445
  // Best effort; stale rows would only linger until the process exits.
420
446
  }
421
- activeTheme = ctx.ui.theme as unknown as ThemeDetail;
447
+ const uiCtx = ctx;
448
+ themeProvider = () => uiCtx.ui?.theme as unknown as ThemeDetail;
422
449
  activeLevel = () => {
423
450
  try {
424
451
  return pi.getThinkingLevel();
@@ -433,8 +460,9 @@ export default function (pi: ExtensionAPI) {
433
460
  if (released) return;
434
461
  released = true;
435
462
  if (patch && !uninstallThinkingGroupingPatch(patch)) return;
436
- activeTheme = undefined;
463
+ themeProvider = undefined;
437
464
  activeLevel = undefined;
465
+ stylesTheme = undefined;
438
466
  liveLabelStyle = undefined;
439
467
  settledLabelStyle = undefined;
440
468
  });