@pi-kaush/pi-tool-call-markers 0.3.4 → 0.3.6

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/src/muted.ts CHANGED
@@ -19,27 +19,36 @@ type CollapsedTheme = {
19
19
  getFgAnsi?(color: string): string;
20
20
  };
21
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
-
22
+ // Probed live, never cached: Pi swaps the colors behind a stable theme
23
+ // Proxy, so a value cached against the theme object outlives a theme switch.
24
+ // A probe is one map lookup, and the Theme throws on tokens it does not know
25
+ // (themes without getFgAnsi have no overrides at all).
27
26
  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
27
  try {
36
28
  const resolved = theme.getFgAnsi?.(token);
37
- if (typeof resolved === "string") ansi = resolved;
29
+ return typeof resolved === "string" ? resolved : null;
38
30
  } catch {
39
- ansi = null;
31
+ return null;
40
32
  }
41
- cache.set(token, ansi);
42
- return ansi;
33
+ }
34
+
35
+ // The tokens this package resolves from a palette.
36
+ const PALETTE_TOKENS = [
37
+ TOOL_OVERRIDE_TOKEN,
38
+ THINKING_OVERRIDE_TOKEN,
39
+ DEFAULT_TOKEN,
40
+ "muted",
41
+ "toolOutput",
42
+ "accent",
43
+ "warning",
44
+ "error",
45
+ ] as const;
46
+
47
+ // Revision key for rendered output that bakes resolved colors in. Compares
48
+ // the colors themselves, so a mid-session theme switch is visible to every
49
+ // cache that keys on it.
50
+ export function paletteSample(theme: CollapsedTheme): string {
51
+ return PALETTE_TOKENS.map((token) => tokenAnsi(theme, token) ?? "").join(",");
43
52
  }
44
53
 
45
54
  // Override token when defined, else the theme's syntaxComment color, else
@@ -83,12 +92,8 @@ export function fgCollapsed(
83
92
  // guaranteed lighter than the collapsed color (e.g. catppuccin-latte's `dim`
84
93
  // resolves to the same overlay0 as syntaxComment), so apply the terminal's
85
94
  // faint attribute: it lightens toward the background on light themes and
86
- // darkens on dark ones. Error rails keep their full-strength semantic red.
87
- export function fgCollapsedRail(
88
- theme: CollapsedTheme,
89
- color: string,
90
- text: string,
91
- ): string {
92
- const styled = fgCollapsed(theme, color, text);
93
- return color === "error" ? styled : `\x1b[2m${styled}\x1b[22m`;
95
+ // darkens on dark ones. The rail keeps this muted weight even on failed
96
+ // rows, where only the row's text turns error-colored.
97
+ export function fgCollapsedRail(theme: CollapsedTheme, text: string): string {
98
+ return `\x1b[2m${fgCollapsed(theme, "muted", text)}\x1b[22m`;
94
99
  }
@@ -49,56 +49,60 @@ type ThemeDetail = {
49
49
  // styles current across mid-session theme switches, which fire no event.
50
50
  let themeProvider: (() => ThemeDetail | undefined) | undefined;
51
51
  let activeLevel: (() => string) | undefined;
52
- // Theme object the current label styles were computed from.
53
- let stylesTheme: ThemeDetail | undefined;
52
+ // Pi builds both thinking labels italic. The default treatment replaces that
53
+ // with a plain row: italic-off always leads — the TUI diff renderer can skip
54
+ // bytes shared with the previous italic frame, which would leave the terminal
55
+ // italic — plus the resolved color when the theme supplies one. The `inherit`
56
+ // variant is the only choice that keeps Pi's native italic styling.
57
+ const ITALIC_OFF = "\x1b[23m";
54
58
 
55
59
  type LabelStyle = { prefix: string; suffix: string };
56
- let liveLabelStyle: LabelStyle | undefined;
57
- let settledLabelStyle: LabelStyle | undefined;
60
+
61
+ // The package styles labels only inside a session it can read the theme from.
62
+ // `inherit` is the other native case: it keeps Pi's italic labels.
63
+ function stylesThinkingLabels(): boolean {
64
+ return themeProvider !== undefined && thoughtLabelColorChoice() !== "inherit";
65
+ }
58
66
 
59
67
  function styledWith(
60
68
  token: string,
61
- theme: ThemeDetail | undefined = themeProvider?.(),
69
+ theme: ThemeDetail | undefined,
62
70
  ): LabelStyle | undefined {
63
- const ansi = theme?.getFgAnsi?.(token);
71
+ // Pi's Theme throws on a token it does not know, so probe the way muted.ts
72
+ // does: an unresolvable color costs the label its tint, never the row.
73
+ let ansi: string | undefined;
74
+ try {
75
+ const resolved = theme?.getFgAnsi?.(token);
76
+ if (typeof resolved === "string") ansi = resolved;
77
+ } catch {
78
+ ansi = undefined;
79
+ }
64
80
  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" };
81
+ return { prefix: `${ITALIC_OFF}${ansi}`, suffix: "\x1b[39m" };
70
82
  }
71
83
 
72
84
  // The theme's collapsedThinkingCall override when defined, else its comment
73
85
  // 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" };
86
+ function settledMutedStyle(theme: ThemeDetail): LabelStyle | undefined {
87
+ const ansi = collapsedThinkingAnsi(theme);
88
+ if (ansi) return { prefix: `${ITALIC_OFF}${ansi}`, suffix: "\x1b[39m" };
79
89
  return styledWith("muted", theme);
80
90
  }
81
- function updateThoughtLabelStyle(): void {
82
- liveLabelStyle = undefined;
83
- settledLabelStyle = undefined;
84
- const theme = themeProvider?.();
85
- stylesTheme = theme;
86
- const choice = thoughtLabelColorChoice();
87
- if (choice === "inherit" || !theme) return;
88
91
 
92
+ // Resolved per label, never cached: Pi swaps the colors behind a stable theme
93
+ // Proxy, so a cached style would survive a theme switch and repaint the
94
+ // transcript in the old palette.
95
+ function labelStyle(
96
+ settled: boolean,
97
+ theme: ThemeDetail,
98
+ ): LabelStyle | undefined {
99
+ const choice = thoughtLabelColorChoice();
100
+ if (settled && choice !== "mdheading") return settledMutedStyle(theme);
89
101
  const token =
90
102
  choice === "mdheading"
91
103
  ? "mdHeading"
92
104
  : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
93
- liveLabelStyle = styledWith(token, theme);
94
- settledLabelStyle =
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();
105
+ return styledWith(token, theme);
102
106
  }
103
107
 
104
108
  // The settled label is the finalized "+ Thought" row; every other label is
@@ -108,13 +112,14 @@ function isSettledThoughtLabel(label: string): boolean {
108
112
  }
109
113
 
110
114
  export function visibleThoughtLabel(label: string): string {
111
- ensureLabelStyles();
112
- const style = isSettledThoughtLabel(label)
113
- ? settledLabelStyle
114
- : liveLabelStyle;
115
- if (!style) return label;
115
+ if (!stylesThinkingLabels()) return label;
116
116
  const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
117
- return `${style.prefix}${raw}${style.suffix}`;
117
+ const theme = themeProvider?.();
118
+ const style = theme
119
+ ? labelStyle(isSettledThoughtLabel(label), theme)
120
+ : undefined;
121
+ // A theme that cannot resolve a color still drops Pi's italics.
122
+ return style ? `${style.prefix}${raw}${style.suffix}` : `${ITALIC_OFF}${raw}`;
118
123
  }
119
124
 
120
125
  type AssistantMessageLike = {
@@ -133,6 +138,28 @@ type TextLikeChild = {
133
138
  setText?(text: string): void;
134
139
  };
135
140
 
141
+ type MarkdownLikeChild = {
142
+ defaultTextStyle?: { italic?: boolean };
143
+ invalidate?(): void;
144
+ };
145
+
146
+ // Pi renders a visible thinking trace as italic markdown. Italic is the live
147
+ // cue — a settled trace drops it and reads as ordinary transcript text — and
148
+ // the italic default style is what identifies the trace: Pi passes it for
149
+ // thinking content and for nothing else. Callers gate on the row actually
150
+ // carrying thinking, so no other italic block is touched.
151
+ function unitalicizeSettledThinking(row: AssistantMessageRow): void {
152
+ const children = row.contentContainer?.children;
153
+ if (!Array.isArray(children)) return;
154
+ for (const child of children) {
155
+ const markdown = child as MarkdownLikeChild | undefined;
156
+ const style = markdown?.defaultTextStyle;
157
+ if (style?.italic !== true) continue;
158
+ style.italic = false;
159
+ markdown?.invalidate?.();
160
+ }
161
+ }
162
+
136
163
  // Pi renders the hidden-thinking label as an italic Text node built from the
137
164
  // plain label field. Embedding style codes in the field itself is not
138
165
  // enough: the TUI diff renderer reuses the byte prefix shared with the
@@ -149,7 +176,7 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
149
176
  const label = row.hiddenThinkingLabel;
150
177
  const children = row.contentContainer?.children;
151
178
  if (!Array.isArray(children)) return;
152
- if (!liveLabelStyle && !settledLabelStyle) return;
179
+ if (!stylesThinkingLabels()) return;
153
180
  const styled = visibleThoughtLabel(label);
154
181
  for (const child of children) {
155
182
  const textChild = child as TextLikeChild | undefined;
@@ -171,6 +198,10 @@ type ThinkingTiming = {
171
198
 
172
199
  type ThinkingGroupingPatchState = {
173
200
  owners: number;
201
+ // Set at the final owner's shutdown. A wrapper another extension has buried
202
+ // cannot be uninstalled, so it delegates instead of outliving its owner; a
203
+ // later install re-enables it.
204
+ disabled?: boolean;
174
205
  originalUpdateContent: (
175
206
  message: AssistantMessageLike,
176
207
  ...args: unknown[]
@@ -180,14 +211,39 @@ type ThinkingGroupingPatchState = {
180
211
  ...args: unknown[]
181
212
  ) => void;
182
213
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
183
- // Last update per row, so /toggle-info can replay rows without waiting for
184
- // new content. Cleared on session start; rows are session-lived anyway.
214
+ // Last update per tracked row, so /toggle-info can replay rows without
215
+ // waiting for new content. Only rows that carry thinking change under the
216
+ // toggle, and the map is capped so a long session or an abandoned branch
217
+ // cannot pin every message the transcript ever showed.
185
218
  rows: Map<
186
219
  AssistantMessageRow,
187
220
  { message: AssistantMessageLike; args: unknown[] }
188
221
  >;
189
222
  };
190
223
 
224
+ const MAX_REPLAY_ROWS = 400;
225
+
226
+ // Bookkeeping only, so it fails open: a message whose content shape throws
227
+ // must not take the row's update pass down with it.
228
+ function trackReplayRow(
229
+ state: ThinkingGroupingPatchState,
230
+ row: AssistantMessageRow,
231
+ message: AssistantMessageLike,
232
+ args: unknown[],
233
+ ): void {
234
+ try {
235
+ if (!hasThinkingContent(message)) return;
236
+ state.rows.set(row, { message, args });
237
+ while (state.rows.size > MAX_REPLAY_ROWS) {
238
+ const oldest = state.rows.keys().next().value;
239
+ if (oldest === undefined) return;
240
+ state.rows.delete(oldest);
241
+ }
242
+ } catch {
243
+ // The row keeps its native rendering; only /toggle-info replay misses it.
244
+ }
245
+ }
246
+
191
247
  type ThinkingContentLike = {
192
248
  type: "thinking";
193
249
  thinking: string;
@@ -246,30 +302,40 @@ function thinkingSpinner(startedAt: number, now: number): string {
246
302
  return PI_SPINNER_FRAMES[frame % PI_SPINNER_FRAMES.length]!;
247
303
  }
248
304
 
305
+ // A row is live while it streams, and stays live through un-flagged rebuilds
306
+ // (resize, theme switch, /reload) until an explicit final update arrives.
307
+ // Rows that never carry the flag — restored history, older runtimes — count
308
+ // as settled.
309
+ function thinkingStillLive(
310
+ streaming: boolean | undefined,
311
+ timing: ThinkingTiming | undefined,
312
+ ): boolean {
313
+ if (streaming === true) return true;
314
+ return (
315
+ streaming === undefined &&
316
+ timing !== undefined &&
317
+ timing.finishedAt === undefined
318
+ );
319
+ }
320
+
249
321
  function lifecycleLabel(
250
322
  row: AssistantMessageRow,
251
323
  streaming: boolean | undefined,
252
324
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>,
253
325
  ): string {
254
326
  const now = Date.now();
255
- let current = timings.get(row);
256
- if (streaming === true) {
257
- if (!current || current.finishedAt !== undefined) {
258
- current = { startedAt: now };
259
- timings.set(row, current);
260
- }
327
+ const previous = timings.get(row);
328
+ if (thinkingStillLive(streaming, previous)) {
329
+ const current =
330
+ previous && previous.finishedAt === undefined
331
+ ? previous
332
+ : { startedAt: now };
333
+ timings.set(row, current);
261
334
  return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
262
335
  }
263
336
 
264
- if (streaming === undefined && current?.finishedAt === undefined && current) {
265
- // Pi can rebuild a live row on resize/theme changes without forwarding
266
- // the optional streaming flag. Keep its active label until an explicit
267
- // final update arrives.
268
- return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
269
- }
270
-
271
- if (streaming === false && current?.finishedAt === undefined) {
272
- if (current) current.finishedAt = now;
337
+ if (streaming === false && previous && previous.finishedAt === undefined) {
338
+ previous.finishedAt = now;
273
339
  }
274
340
 
275
341
  const settled = timings.get(row);
@@ -353,6 +419,7 @@ function installThinkingGroupingPatch():
353
419
  if (existing) {
354
420
  existing.owners++;
355
421
  existing.rows ??= new Map();
422
+ existing.disabled = false;
356
423
  return existing;
357
424
  }
358
425
 
@@ -367,9 +434,14 @@ function installThinkingGroupingPatch():
367
434
  message: AssistantMessageLike,
368
435
  ...args: unknown[]
369
436
  ): void {
370
- // Grouping and labels are cosmetic. Each fails open independently, and
437
+ // Grouping and styling are cosmetic. Each fails open independently, and
371
438
  // the original renderer is still called exactly once with every arg.
372
- state.rows.set(this, { message, args });
439
+ if (state.disabled) {
440
+ Reflect.apply(state.originalUpdateContent, this, [message, ...args]);
441
+ return;
442
+ }
443
+ trackReplayRow(state, this, message, args);
444
+ const streaming = typeof args[0] === "boolean" ? args[0] : undefined;
373
445
  let combined = message;
374
446
  try {
375
447
  // Pi builds the hidden label and the visible block only when thinking
@@ -381,7 +453,6 @@ function installThinkingGroupingPatch():
381
453
  // Preserve the original message intact.
382
454
  }
383
455
  try {
384
- const streaming = typeof args[0] === "boolean" ? args[0] : undefined;
385
456
  applyHiddenThinkingLabel(this, combined, streaming, state.timings);
386
457
  } catch {
387
458
  // Preserve Pi's native label if its private row shape changes.
@@ -392,6 +463,16 @@ function installThinkingGroupingPatch():
392
463
  } catch {
393
464
  // Keep Pi's native label styling if the row shape changes.
394
465
  }
466
+ try {
467
+ if (
468
+ hasThinkingContent(combined) &&
469
+ !thinkingStillLive(streaming, state.timings.get(this))
470
+ ) {
471
+ unitalicizeSettledThinking(this);
472
+ }
473
+ } catch {
474
+ // Keep Pi's native italic trace if the row shape changes.
475
+ }
395
476
  };
396
477
 
397
478
  state.patchedUpdateContent = patchedUpdateContent;
@@ -412,6 +493,7 @@ function uninstallThinkingGroupingPatch(
412
493
  if (!state || state.owners <= 0) return false;
413
494
  state.owners--;
414
495
  if (state.owners > 0) return false;
496
+ state.disabled = true;
415
497
  const proto =
416
498
  AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
417
499
  [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
@@ -453,17 +535,12 @@ export default function (pi: ExtensionAPI) {
453
535
  return "off";
454
536
  }
455
537
  };
456
- updateThoughtLabelStyle();
457
538
  });
458
- pi.on("thinking_level_select", () => updateThoughtLabelStyle());
459
539
  pi.on("session_shutdown", () => {
460
540
  if (released) return;
461
541
  released = true;
462
542
  if (patch && !uninstallThinkingGroupingPatch(patch)) return;
463
543
  themeProvider = undefined;
464
544
  activeLevel = undefined;
465
- stylesTheme = undefined;
466
- liveLabelStyle = undefined;
467
- settledLabelStyle = undefined;
468
545
  });
469
546
  }