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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,48 +1,39 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
3
+ import { infoVisibilityHidden } from "./info-visibility-state.ts";
3
4
 
4
- const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
5
+ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v2");
5
6
  const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
7
  const SPINNER_INTERVAL_MS = 80;
7
8
 
8
9
  // Visual experiment for the settled "+ Thought" label. "inherit" keeps Pi's
9
- // native styling (italic + thinkingText); "gray" sits halfway between the
10
- // muted and text theme colors. The env var overrides the default so the
11
- // variants can be compared without editing code or republishing.
10
+ // native styling (italic + thinkingText); "mdheading" rides the theme's
11
+ // mdHeading token; the default tints the label with the session's active
12
+ // thinking-level color (thinkingOff…thinkingMax), so the collapsed label
13
+ // quietly advertises the level Pi is reasoning at. Only the collapsed label
14
+ // is restyled — the expanded thinking block keeps Pi's thinkingText. The env
15
+ // var overrides the default so variants can be compared without republishing.
12
16
  const THOUGHT_LABEL_COLOR_ENV = "PI_TOOL_CALL_MARKERS_THOUGHT_COLOR";
13
- type ThoughtLabelColor = "inherit" | "orange" | "gray";
14
- const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "orange";
15
- // cobalt2's orange token (#ffb86c).
16
- const THOUGHT_LABEL_ORANGE = { r: 255, g: 184, b: 108 };
17
+ type ThoughtLabelColor = "inherit" | "level" | "mdheading";
18
+ const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "level";
17
19
 
18
20
  function thoughtLabelColorChoice(): ThoughtLabelColor {
19
21
  const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
20
- return raw === "inherit" || raw === "orange" || raw === "gray"
22
+ return raw === "inherit" || raw === "level" || raw === "mdheading"
21
23
  ? raw
22
24
  : DEFAULT_THOUGHT_LABEL_COLOR;
23
25
  }
24
26
 
25
- const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
26
- const BASIC_ANSI_RGB: Array<[number, string] | undefined> = [
27
- [0, "#000000"],
28
- [1, "#800000"],
29
- [2, "#008000"],
30
- [3, "#808000"],
31
- [4, "#000080"],
32
- [5, "#800080"],
33
- [6, "#008080"],
34
- [7, "#c0c0c0"],
35
- [8, "#808080"],
36
- [9, "#ff0000"],
37
- [10, "#00ff00"],
38
- [11, "#ffff00"],
39
- [12, "#0000ff"],
40
- [13, "#ff00ff"],
41
- [14, "#00ffff"],
42
- [15, "#ffffff"],
43
- ];
44
-
45
- type Rgb = { r: number; g: number; b: number };
27
+ // thinkingLevel values map onto theme tokens: "xhigh" → thinkingXhigh etc.
28
+ const LEVEL_TOKEN: Record<string, string> = {
29
+ off: "thinkingOff",
30
+ minimal: "thinkingMinimal",
31
+ low: "thinkingLow",
32
+ medium: "thinkingMedium",
33
+ high: "thinkingHigh",
34
+ xhigh: "thinkingXhigh",
35
+ max: "thinkingMax",
36
+ };
46
37
 
47
38
  type ThemeDetail = {
48
39
  fg(color: string, text: string): string;
@@ -51,96 +42,7 @@ type ThemeDetail = {
51
42
  };
52
43
 
53
44
  let activeTheme: ThemeDetail | undefined;
54
-
55
- function hexToRgb(hex: string): Rgb | undefined {
56
- const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
57
- if (!match) return undefined;
58
- const value = parseInt(match[1]!, 16);
59
- return { r: value >> 16, g: (value >> 8) & 255, b: value & 255 };
60
- }
61
-
62
- // Mirrors Pi's own mapping so a named color can round-trip through a
63
- // 256-color terminal (e.g. when COLORFGBG forces 256color mode).
64
- function ansi256ToRgb(index: number): Rgb | undefined {
65
- if (index < 16) {
66
- const hex = BASIC_ANSI_RGB[index]?.[1];
67
- return hex ? hexToRgb(hex) : undefined;
68
- }
69
- if (index < 232) {
70
- const cube = index - 16;
71
- const channel = (n: number) => (n === 0 ? 0 : 55 + n * 40);
72
- return {
73
- r: channel(Math.floor(cube / 36)),
74
- g: channel(Math.floor((cube % 36) / 6)),
75
- b: channel(cube % 6),
76
- };
77
- }
78
- const gray = 8 + (index - 232) * 10;
79
- return { r: gray, g: gray, b: gray };
80
- }
81
-
82
- function rgbToAnsi256({ r, g, b }: Rgb): number {
83
- const nearest = (value: number) =>
84
- CUBE_VALUES.reduce(
85
- (best, candidate) =>
86
- Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best,
87
- 0,
88
- );
89
- const cubeR = nearest(r);
90
- const cubeG = nearest(g);
91
- const cubeB = nearest(b);
92
- const cubeDistance =
93
- (r - cubeR) ** 2 * 0.299 +
94
- (g - cubeG) ** 2 * 0.587 +
95
- (b - cubeB) ** 2 * 0.114;
96
- const luminance = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
97
- const grayStep = Math.max(0, Math.min(23, Math.round((luminance - 8) / 10)));
98
- const grayValue = 8 + grayStep * 10;
99
- const grayDistance = (luminance - grayValue) ** 2;
100
- const spread = Math.max(r, g, b) - Math.min(r, g, b);
101
- if (spread < 10 && grayDistance < cubeDistance) return 232 + grayStep;
102
- return (
103
- 16 +
104
- 36 * CUBE_VALUES.indexOf(cubeR) +
105
- 6 * CUBE_VALUES.indexOf(cubeG) +
106
- CUBE_VALUES.indexOf(cubeB)
107
- );
108
- }
109
-
110
- function parseAnsiFgRgb(ansi: string): Rgb | undefined {
111
- if (/^\x1b\[39m$/.test(ansi)) return undefined;
112
- const truecolor = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(ansi);
113
- if (truecolor) {
114
- return {
115
- r: Number(truecolor[1]),
116
- g: Number(truecolor[2]),
117
- b: Number(truecolor[3]),
118
- };
119
- }
120
- const indexed = /^\x1b\[38;5;(\d+)m$/.exec(ansi);
121
- if (indexed) return ansi256ToRgb(Number(indexed[1]));
122
- const basic = /^\x1b\[(9[0-7]|3[0-7])m$/.exec(ansi);
123
- if (basic) {
124
- const code = Number(basic[1]);
125
- return ansi256ToRgb(code >= 90 ? code - 90 + 8 : code - 30);
126
- }
127
- return undefined;
128
- }
129
-
130
- function fgRgbAnsi({ r, g, b }: Rgb): string {
131
- if (activeTheme?.getColorMode?.() === "256color") {
132
- return `\x1b[38;5;${rgbToAnsi256({ r, g, b })}m`;
133
- }
134
- return `\x1b[38;2;${r};${g};${b}m`;
135
- }
136
-
137
- function midpointRgb(a: Rgb, b: Rgb): Rgb {
138
- return {
139
- r: Math.round((a.r + b.r) / 2),
140
- g: Math.round((a.g + b.g) / 2),
141
- b: Math.round((a.b + b.b) / 2),
142
- };
143
- }
45
+ let activeLevel: (() => string) | undefined;
144
46
 
145
47
  let thoughtLabelPrefix = "";
146
48
  let thoughtLabelSuffix = "";
@@ -151,21 +53,19 @@ function updateThoughtLabelStyle(): void {
151
53
  const choice = thoughtLabelColorChoice();
152
54
  if (choice === "inherit" || !activeTheme) return;
153
55
 
154
- let rgb: Rgb | undefined;
155
- if (choice === "orange") {
156
- rgb = THOUGHT_LABEL_ORANGE;
157
- } else if (activeTheme.getFgAnsi) {
158
- const muted = parseAnsiFgRgb(activeTheme.getFgAnsi("muted"));
159
- const text = parseAnsiFgRgb(activeTheme.getFgAnsi("text"));
160
- if (muted && text) rgb = midpointRgb(muted, text);
161
- }
162
- if (!rgb) return;
56
+ let ansi: string | undefined;
57
+ const token =
58
+ choice === "mdheading"
59
+ ? "mdHeading"
60
+ : (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
61
+ ansi = activeTheme.getFgAnsi?.(token);
62
+ if (!ansi) return;
163
63
 
164
64
  // The styled label replaces Pi's italicized Text node wholesale (see
165
65
  // restyleHiddenThinkingLabel). The leading italic-off still matters: the
166
66
  // TUI's diff renderer can skip bytes shared with the previously drawn
167
67
  // italic line, leaving the terminal in italic state otherwise.
168
- thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
68
+ thoughtLabelPrefix = `\x1b[23m${ansi}`;
169
69
  thoughtLabelSuffix = "\x1b[39m";
170
70
  }
171
71
 
@@ -228,6 +128,7 @@ type ThinkingTiming = {
228
128
  };
229
129
 
230
130
  type ThinkingGroupingPatchState = {
131
+ owners: number;
231
132
  originalUpdateContent: (
232
133
  message: AssistantMessageLike,
233
134
  ...args: unknown[]
@@ -237,6 +138,12 @@ type ThinkingGroupingPatchState = {
237
138
  ...args: unknown[]
238
139
  ) => void;
239
140
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
141
+ // Last update per row, so /toggle-info can replay rows without waiting for
142
+ // new content. Cleared on session start; rows are session-lived anyway.
143
+ rows: Map<
144
+ AssistantMessageRow,
145
+ { message: AssistantMessageLike; args: unknown[] }
146
+ >;
240
147
  };
241
148
 
242
149
  type ThinkingContentLike = {
@@ -330,6 +237,40 @@ function lifecycleLabel(
330
237
  return "+ Thought";
331
238
  }
332
239
 
240
+ function stripThinkingBlocks(
241
+ message: AssistantMessageLike,
242
+ ): AssistantMessageLike {
243
+ const content = message.content;
244
+ if (!Array.isArray(content) || !content.some(isThinkingContent)) {
245
+ return message;
246
+ }
247
+ return { ...message, content: content.filter((c) => !isThinkingContent(c)) };
248
+ }
249
+
250
+ // Replays the last update on every tracked assistant row so a /toggle-info
251
+ // flip applies immediately instead of waiting for the next content pass.
252
+ export function refreshThinkingVisibility(): void {
253
+ try {
254
+ const proto = AssistantMessageComponent?.prototype as unknown as {
255
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
256
+ };
257
+ const state = proto?.[THINKING_GROUPING_PATCHED];
258
+ if (!state?.patchedUpdateContent) return;
259
+ for (const [row, tracked] of state.rows) {
260
+ try {
261
+ Reflect.apply(state.patchedUpdateContent, row, [
262
+ tracked.message,
263
+ ...tracked.args,
264
+ ]);
265
+ } catch {
266
+ // A row that changed shape mid-flight keeps its last render.
267
+ }
268
+ }
269
+ } catch {
270
+ // Cosmetic replay; never break the toggle.
271
+ }
272
+ }
273
+
333
274
  function applyHiddenThinkingLabel(
334
275
  row: AssistantMessageRow,
335
276
  message: AssistantMessageLike,
@@ -367,11 +308,17 @@ function installThinkingGroupingPatch():
367
308
  if (!proto || typeof proto.updateContent !== "function") return undefined;
368
309
 
369
310
  const existing = proto[THINKING_GROUPING_PATCHED];
370
- if (existing) return existing;
311
+ if (existing) {
312
+ existing.owners++;
313
+ existing.rows ??= new Map();
314
+ return existing;
315
+ }
371
316
 
372
317
  const state: ThinkingGroupingPatchState = {
318
+ owners: 1,
373
319
  originalUpdateContent: proto.updateContent,
374
320
  timings: new WeakMap(),
321
+ rows: new Map(),
375
322
  };
376
323
  const patchedUpdateContent = function updateContentWithCombinedThinking(
377
324
  this: AssistantMessageRow,
@@ -380,9 +327,14 @@ function installThinkingGroupingPatch():
380
327
  ): void {
381
328
  // Grouping and labels are cosmetic. Each fails open independently, and
382
329
  // the original renderer is still called exactly once with every arg.
330
+ state.rows.set(this, { message, args });
383
331
  let combined = message;
384
332
  try {
385
- combined = combineAdjacentThinking(message);
333
+ // Pi builds the hidden label and the visible block only when thinking
334
+ // blocks exist, so stripping them hides both at once.
335
+ combined = infoVisibilityHidden()
336
+ ? stripThinkingBlocks(message)
337
+ : combineAdjacentThinking(message);
386
338
  } catch {
387
339
  // Preserve the original message intact.
388
340
  }
@@ -414,8 +366,10 @@ function installThinkingGroupingPatch():
414
366
 
415
367
  function uninstallThinkingGroupingPatch(
416
368
  state: ThinkingGroupingPatchState | undefined,
417
- ): void {
418
- if (!state) return;
369
+ ): boolean {
370
+ if (!state || state.owners <= 0) return false;
371
+ state.owners--;
372
+ if (state.owners > 0) return false;
419
373
  const proto =
420
374
  AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
421
375
  [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
@@ -428,23 +382,44 @@ function uninstallThinkingGroupingPatch(
428
382
  proto[THINKING_GROUPING_PATCHED] !== state ||
429
383
  proto.updateContent !== state.patchedUpdateContent
430
384
  ) {
431
- return;
385
+ return true;
432
386
  }
433
387
  proto.updateContent = state.originalUpdateContent;
434
388
  delete proto[THINKING_GROUPING_PATCHED];
389
+ return true;
435
390
  }
436
391
 
437
392
  export default function (pi: ExtensionAPI) {
438
393
  const patch = installThinkingGroupingPatch();
394
+ let released = false;
439
395
  pi.on("session_start", (_event, ctx) => {
440
396
  if (ctx.mode !== "tui") return;
397
+ try {
398
+ const proto = AssistantMessageComponent?.prototype as unknown as {
399
+ [THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
400
+ };
401
+ proto?.[THINKING_GROUPING_PATCHED]?.rows.clear();
402
+ } catch {
403
+ // Best effort; stale rows would only linger until the process exits.
404
+ }
441
405
  activeTheme = ctx.ui.theme as unknown as ThemeDetail;
406
+ activeLevel = () => {
407
+ try {
408
+ return pi.getThinkingLevel();
409
+ } catch {
410
+ return "off";
411
+ }
412
+ };
442
413
  updateThoughtLabelStyle();
443
414
  });
415
+ pi.on("thinking_level_select", () => updateThoughtLabelStyle());
444
416
  pi.on("session_shutdown", () => {
417
+ if (released) return;
418
+ released = true;
419
+ if (patch && !uninstallThinkingGroupingPatch(patch)) return;
445
420
  activeTheme = undefined;
421
+ activeLevel = undefined;
446
422
  thoughtLabelPrefix = "";
447
423
  thoughtLabelSuffix = "";
448
- uninstallThinkingGroupingPatch(patch);
449
424
  });
450
425
  }