@pi-kaush/pi-tool-call-markers 0.2.6 → 0.2.8

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,7 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- - Group consecutive tool blocks under one `%` marker with per-tool sub-headings and nested sub-bullets, dropping the blank lines between tool sections (F25's compact goal, with the hierarchy that makes it readable).
5
+ - Render every collapsed tool call as a `% tool: call → outcome` line with the tool name bolded one marker per call, no bullets, no blank lines between sections; grouped blocks share only their leading blank row (supersedes the nested sub-heading layout).
6
6
  - Render subagent calls as an unboxed plan — `% subagent` heading with chain/parallel counts and numbered steps as they execute, agent names in accent with emojis scraped from the native plan component (args fallback) — replacing the accent-rail card; failed subagents follow the full-red failure tone.
7
7
  - Strip display sequences and control bytes (notably `\r` from progress writers like git) from collapsed-row text so command output cannot return the cursor to column 0 and overwrite the row.
8
8
  - Color the truncation ellipsis to match its row tone (`muted` settled, `error` failed) instead of the terminal default foreground left by pi-tui's truncation reset.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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
@@ -61,6 +61,7 @@ type PresentationPatchState = {
61
61
  theme?: ThemeLike;
62
62
  collapseParallel: boolean;
63
63
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
64
+ collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
64
65
  rowVersions: WeakMap<ToolExecutionRow, number>;
65
66
  rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
66
67
  rowSignatures: WeakMap<ToolExecutionRow, string>;
@@ -85,6 +86,14 @@ type GroupRenderCache = {
85
86
  width: number;
86
87
  };
87
88
 
89
+ type CollapsedRenderCache = {
90
+ lines: string[];
91
+ signature: string;
92
+ version: number;
93
+ themeSample: string;
94
+ width: number;
95
+ };
96
+
88
97
  function envEnabled(name: string, defaultValue: boolean): boolean {
89
98
  const value = process.env[name]?.trim().toLowerCase();
90
99
  if (!value) return defaultValue;
@@ -116,6 +125,33 @@ function hasVisibleContent(line: string): boolean {
116
125
  return stripAnsi(line).trim().length > 0;
117
126
  }
118
127
 
128
+ // State-transition fingerprint used by both the updateDisplay version bump
129
+ // and the settled-row render cache. It deliberately covers only shape
130
+ // transitions (partial/expanded/result/error): while a row is partial its
131
+ // content streams and it is never cached, and once settled the rendered
132
+ // content is fully determined by this fingerprint + width + theme.
133
+ function rowSignatureOf(row: ToolExecutionRow): string {
134
+ return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
135
+ }
136
+
137
+ // Theme samples are five theme.fg calls; computing them per row per frame
138
+ // would reintroduce the per-keystroke cost the render caches remove, so
139
+ // cache by theme object identity (a theme switch swaps the object).
140
+ const themeSamples = new WeakMap<ThemeLike, string>();
141
+ function themeSampleFor(theme: ThemeLike): string {
142
+ let sample = themeSamples.get(theme);
143
+ if (!sample) {
144
+ sample =
145
+ theme.fg("toolTitle", "x") +
146
+ theme.fg("muted", "x") +
147
+ theme.fg("dim", "x") +
148
+ theme.fg("warning", "x") +
149
+ theme.fg("error", "x");
150
+ themeSamples.set(theme, sample);
151
+ }
152
+ return sample;
153
+ }
154
+
119
155
  type InsetLayout = {
120
156
  contentWidth: number;
121
157
  left: number;
@@ -756,9 +792,10 @@ function styledCallLabel(
756
792
  const plain = sanitizeInline(stripAnsi(label)).trim();
757
793
  const match = /^(\S+)(.*)$/s.exec(plain);
758
794
  if (!match) return theme.fg(color, plain);
795
+ const rest = match[2] ?? "";
759
796
  return (
760
797
  theme.fg(color, theme.bold(match[1] ?? "")) +
761
- theme.fg(color, match[2] ?? "")
798
+ (rest ? theme.fg(color, `:${rest}`) : "")
762
799
  );
763
800
  }
764
801
 
@@ -805,9 +842,9 @@ function collapsedHeadline(
805
842
  theme: ThemeLike,
806
843
  ): string {
807
844
  // Failed rows render entirely in error so the row reads as the one that
808
- // failed, not just its outcome tail.
845
+ // failed, not just its outcome tail. Only the tool name is bold.
809
846
  const color = rowHasFailed(row) ? "error" : "muted";
810
- const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
847
+ const marker = `${theme.fg(color, GROUP_MARKER)} `;
811
848
  const budget = Math.max(1, width - visibleWidth(marker));
812
849
  const label = collapsedCallLabel(row, budget, theme, color);
813
850
  const outcome = collapsedOutcome(row, budget, theme);
@@ -1069,73 +1106,15 @@ function renderCollapsedToolRow(
1069
1106
  return ["", ...insetLines(body, width)];
1070
1107
  }
1071
1108
 
1072
- function compactBulletLine(
1073
- summary: string,
1074
- outcome: string | undefined,
1075
- width: number,
1076
- theme: ThemeLike,
1077
- color = "muted",
1078
- indentSpaces = 2,
1079
- ): string {
1080
- const prefix = `${" ".repeat(indentSpaces)}${theme.fg(color, "•")} `;
1081
- const indent = visibleWidth(prefix);
1082
- if (width <= indent) return truncateToWidth(prefix, width, "", false);
1083
- const available = width - indent;
1084
- return (
1085
- prefix +
1086
- (outcome
1087
- ? fitSummaryTail(summary, outcome, available, theme.fg(color, "…"))
1088
- : fitSummary(summary, available, theme.fg(color, "…")))
1089
- );
1090
- }
1091
-
1092
1109
  function groupedCallComponent(
1093
1110
  rows: ToolExecutionRow[],
1094
1111
  theme: ThemeLike,
1095
1112
  ): ComponentLike {
1096
1113
  return {
1097
1114
  render(width: number): string[] {
1098
- // One marker for the whole consecutive block; each tool run gets a
1099
- // sub-heading, and calls nest one level deeper as sub-bullets. No blank
1100
- // lines inside the block — the hierarchy carries the separation.
1101
- const lines: string[] = [
1102
- truncateToWidth(
1103
- theme.fg("muted", theme.bold(GROUP_MARKER)),
1104
- width,
1105
- "",
1106
- false,
1107
- ),
1108
- ];
1109
- let previousToolName: string | undefined;
1110
- for (const row of rows) {
1111
- if (row.toolName !== previousToolName) {
1112
- const token =
1113
- row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
1114
- lines.push(
1115
- truncateToWidth(
1116
- ` ${theme.fg("muted", theme.bold(token))}`,
1117
- width,
1118
- "",
1119
- false,
1120
- ),
1121
- );
1122
- previousToolName = row.toolName;
1123
- }
1124
- const color = rowHasFailed(row) ? "error" : "muted";
1125
- const call = renderedCallSummary(row, Math.max(1, width - 6), theme);
1126
- const outcome = renderedGroupedOutcome(row, theme);
1127
- lines.push(
1128
- compactBulletLine(
1129
- theme.fg(color, stripAnsi(call)),
1130
- outcome,
1131
- width,
1132
- theme,
1133
- color,
1134
- 4,
1135
- ),
1136
- );
1137
- }
1138
- return lines;
1115
+ // Every member renders like a singleton `% tool: call outcome` per
1116
+ // line with the tool name bolded no bullets or internal blanks.
1117
+ return rows.map((row) => collapsedHeadline(row, width, theme));
1139
1118
  },
1140
1119
  invalidate() {},
1141
1120
  };
@@ -1172,12 +1151,7 @@ function renderGroupedToolRows(
1172
1151
  ): string[] {
1173
1152
  const theme = state.theme;
1174
1153
  if (!theme) return state.originalRender.call(row, width);
1175
- const themeSample =
1176
- theme.fg("toolTitle", "x") +
1177
- theme.fg("muted", "x") +
1178
- theme.fg("dim", "x") +
1179
- theme.fg("warning", "x") +
1180
- theme.fg("error", "x");
1154
+ const themeSample = themeSampleFor(theme);
1181
1155
  const cached = state.groupCache.get(row);
1182
1156
  const hasLiveMembers = rows.some(isLiveRow);
1183
1157
  if (
@@ -1387,6 +1361,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1387
1361
  const state: PresentationPatchState = {
1388
1362
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1389
1363
  groupCache: new WeakMap(),
1364
+ collapsedCache: new WeakMap(),
1390
1365
  rowVersions: new WeakMap(),
1391
1366
  rowGroups: new WeakMap(),
1392
1367
  rowSignatures: new WeakMap(),
@@ -1399,7 +1374,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1399
1374
  // Group members always bump so their leader's cache refreshes; other
1400
1375
  // rows only bump on state transitions, so bash's per-second invalidate
1401
1376
  // ticks and resize invalidations stop busting caches.
1402
- const signature = `${this.isPartial}|${this.expanded}|${this.result ? 1 : 0}|${this.result?.isError ? 1 : 0}`;
1377
+ const signature = rowSignatureOf(this);
1403
1378
  if (
1404
1379
  state.rowGroups.has(this) ||
1405
1380
  state.rowSignatures.get(this) !== signature
@@ -1414,18 +1389,72 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1414
1389
  this: ToolExecutionRow,
1415
1390
  width: number,
1416
1391
  ): string[] {
1417
- const lines = state.originalRender.call(this, width);
1392
+ const theme = state.theme;
1418
1393
  if (
1419
1394
  typeof this.expanded !== "boolean" ||
1420
1395
  typeof this.isPartial !== "boolean" ||
1421
1396
  this.expanded ||
1422
- !state.theme ||
1423
- (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
1397
+ !theme
1424
1398
  ) {
1399
+ return state.originalRender.call(this, width);
1400
+ }
1401
+
1402
+ // ================================================================
1403
+ // Settled-row render cache — PLEASE DO NOT REMOVE THIS FAST PATH.
1404
+ // ================================================================
1405
+ // Pi's TUI re-renders the whole transcript on every keystroke, and
1406
+ // the collapsed-row decoration below rebuilds strings for every row
1407
+ // it touches. On a long session (~1,000 messages, hundreds of tool
1408
+ // rows) that measured at ~80 ms per keystroke — visible input lag.
1409
+ //
1410
+ // Settled rows (not partial, not expanded) have fully static render
1411
+ // output, so their collapsed lines are cached keyed by width +
1412
+ // rowSignatureOf + rowVersions + theme sample. updateDisplay bumps
1413
+ // rowVersions on every real state transition, and partial rows
1414
+ // bypass the cache entirely because their content streams. This
1415
+ // mirrors the existing groupCache discipline; keep them in sync.
1416
+ // ================================================================
1417
+ if (!this.isPartial) {
1418
+ const signature = rowSignatureOf(this);
1419
+ const version = state.rowVersions.get(this) ?? 0;
1420
+ const themeSample = themeSampleFor(theme);
1421
+ const cached = state.collapsedCache.get(this);
1422
+ if (
1423
+ cached &&
1424
+ cached.width === width &&
1425
+ cached.signature === signature &&
1426
+ cached.version === version &&
1427
+ cached.themeSample === themeSample
1428
+ ) {
1429
+ return cached.lines;
1430
+ }
1431
+ const lines = state.originalRender.call(this, width);
1432
+ if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1433
+ return lines;
1434
+ }
1435
+ try {
1436
+ const collapsed = renderCollapsedToolRow(this, width, theme);
1437
+ state.collapsedCache.set(this, {
1438
+ lines: collapsed,
1439
+ signature,
1440
+ version,
1441
+ themeSample,
1442
+ width,
1443
+ });
1444
+ return collapsed;
1445
+ } catch {
1446
+ return lines;
1447
+ }
1448
+ }
1449
+
1450
+ // Partial (streaming) rows keep the collapsed presentation but are
1451
+ // never cached: their content changes with every streamed chunk.
1452
+ const lines = state.originalRender.call(this, width);
1453
+ if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1425
1454
  return lines;
1426
1455
  }
1427
1456
  try {
1428
- return renderCollapsedToolRow(this, width, state.theme);
1457
+ return renderCollapsedToolRow(this, width, theme);
1429
1458
  } catch {
1430
1459
  return lines;
1431
1460
  }
@@ -5,6 +5,176 @@ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
5
5
  const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
6
  const SPINNER_INTERVAL_MS = 80;
7
7
 
8
+ // Visual experiment for the settled "+ Thought" label. "inherit" keeps Pi's
9
+ // native styling (italic + thinkingText); "gray" sits halfway between the
10
+ // muted and text theme colors. The env var overrides the default so the
11
+ // variants can be compared without editing code or republishing.
12
+ 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
+
18
+ function thoughtLabelColorChoice(): ThoughtLabelColor {
19
+ const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
20
+ return raw === "inherit" || raw === "orange" || raw === "gray"
21
+ ? raw
22
+ : DEFAULT_THOUGHT_LABEL_COLOR;
23
+ }
24
+
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 };
46
+
47
+ type ThemeDetail = {
48
+ fg(color: string, text: string): string;
49
+ getFgAnsi?(color: string): string;
50
+ getColorMode?(): "truecolor" | "256color" | string;
51
+ };
52
+
53
+ 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
+ }
144
+
145
+ let thoughtLabelPrefix = "";
146
+ let thoughtLabelSuffix = "";
147
+
148
+ function updateThoughtLabelStyle(): void {
149
+ thoughtLabelPrefix = "";
150
+ thoughtLabelSuffix = "";
151
+ const choice = thoughtLabelColorChoice();
152
+ if (choice === "inherit" || !activeTheme) return;
153
+
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;
163
+
164
+ // The styled label replaces Pi's italicized Text node wholesale (see
165
+ // restyleHiddenThinkingLabel). The leading italic-off still matters: the
166
+ // TUI's diff renderer can skip bytes shared with the previously drawn
167
+ // italic line, leaving the terminal in italic state otherwise.
168
+ thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
169
+ thoughtLabelSuffix = "\x1b[39m";
170
+ }
171
+
172
+ export function visibleThoughtLabel(label: string): string {
173
+ if (!thoughtLabelPrefix) return label;
174
+ const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
175
+ return `${thoughtLabelPrefix}${raw}${thoughtLabelSuffix}`;
176
+ }
177
+
8
178
  type AssistantMessageLike = {
9
179
  content?: unknown[];
10
180
  };
@@ -12,9 +182,46 @@ type AssistantMessageLike = {
12
182
  type AssistantMessageRow = {
13
183
  hiddenThinkingLabel?: unknown;
14
184
  hideThinkingBlock?: unknown;
185
+ contentContainer?: { children?: unknown[] };
15
186
  updateContent(message: AssistantMessageLike, ...args: unknown[]): void;
16
187
  };
17
188
 
189
+ type TextLikeChild = {
190
+ text?: unknown;
191
+ setText?(text: string): void;
192
+ };
193
+
194
+ // Pi renders the hidden-thinking label as an italic Text node built from the
195
+ // plain label field. Embedding style codes in the field itself is not
196
+ // enough: the TUI diff renderer reuses the byte prefix shared with the
197
+ // previous (italic) frame, so an in-line italic reset may never reach the
198
+ // terminal. Swap the node's text for a self-contained styled version after
199
+ // each native render pass instead.
200
+ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
201
+ if (!thoughtLabelPrefix) return;
202
+ if (
203
+ row.hideThinkingBlock !== true ||
204
+ typeof row.hiddenThinkingLabel !== "string"
205
+ ) {
206
+ return;
207
+ }
208
+ const label = row.hiddenThinkingLabel;
209
+ const children = row.contentContainer?.children;
210
+ if (!Array.isArray(children)) return;
211
+ const styled = visibleThoughtLabel(label);
212
+ for (const child of children) {
213
+ const textChild = child as TextLikeChild | undefined;
214
+ if (
215
+ typeof textChild?.text !== "string" ||
216
+ typeof textChild.setText !== "function" ||
217
+ !textChild.text.includes(label)
218
+ ) {
219
+ continue;
220
+ }
221
+ textChild.setText(styled);
222
+ }
223
+ }
224
+
18
225
  type ThinkingTiming = {
19
226
  finishedAt?: number;
20
227
  startedAt: number;
@@ -186,6 +393,11 @@ function installThinkingGroupingPatch():
186
393
  // Preserve Pi's native label if its private row shape changes.
187
394
  }
188
395
  Reflect.apply(state.originalUpdateContent, this, [combined, ...args]);
396
+ try {
397
+ restyleHiddenThinkingLabel(this);
398
+ } catch {
399
+ // Keep Pi's native label styling if the row shape changes.
400
+ }
189
401
  };
190
402
 
191
403
  state.patchedUpdateContent = patchedUpdateContent;
@@ -224,5 +436,15 @@ function uninstallThinkingGroupingPatch(
224
436
 
225
437
  export default function (pi: ExtensionAPI) {
226
438
  const patch = installThinkingGroupingPatch();
227
- pi.on("session_shutdown", () => uninstallThinkingGroupingPatch(patch));
439
+ pi.on("session_start", (_event, ctx) => {
440
+ if (ctx.mode !== "tui") return;
441
+ activeTheme = ctx.ui.theme as unknown as ThemeDetail;
442
+ updateThoughtLabelStyle();
443
+ });
444
+ pi.on("session_shutdown", () => {
445
+ activeTheme = undefined;
446
+ thoughtLabelPrefix = "";
447
+ thoughtLabelSuffix = "";
448
+ uninstallThinkingGroupingPatch(patch);
449
+ });
228
450
  }