pi-compact-tools 0.12.0 → 0.13.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.
@@ -1,9 +1,9 @@
1
1
  import { UserMessageComponent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { TUI } from "@earendil-works/pi-tui";
3
3
  import { onTick } from "./compact-tools-clock.ts";
4
- import { colorizeRgb, interpolateRgb, type Rgb } from "./compact-tools-color.ts";
4
+ import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
5
5
  import { hookMethod } from "./compact-tools-hook.ts";
6
- import { activityGlow, thinkingLevelColor, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
6
+ import { activityGlow, perPalette, type ThinkingLevel } from "./compact-tools-palette.ts";
7
7
 
8
8
  const WIDGET_KEY = "compact-tools-silent-activity-render";
9
9
  const ACTIVITY_STATE = Symbol.for("pi-compact-tools.silent.activity.state");
@@ -109,26 +109,25 @@ export function frameChanges(frame: number): boolean {
109
109
  return FRAME_CHANGED[frameIndex(frame)]!;
110
110
  }
111
111
 
112
+ type PaintedBreath = readonly (readonly string[])[];
113
+
112
114
  /**
113
- * Resolving a ramp walks the theme's colors, so each level keeps its last one.
114
- * It is kept against the colors it is made from, the level's and the text's, not
115
- * against the theme: Pi's theme is one object whose palette /theme swaps.
115
+ * Every dot of every frame of a breath at a level, painted once per palette. A breath
116
+ * has a fixed number of frames, so each frame the line draws after that is a lookup.
116
117
  */
117
- const glowCache = new Map<ThinkingLevel, { colors: string; glow: ColorRamp | undefined }>();
118
-
119
- function cachedGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
120
- const colors = typeof theme.getFgAnsi === "function"
121
- ? theme.getFgAnsi(thinkingLevelColor(level)) + theme.getFgAnsi("text")
122
- : "";
123
- const cached = glowCache.get(level);
124
- if (cached && cached.colors === colors) return cached.glow;
125
- const glow = activityGlow(theme, level);
126
- glowCache.set(level, { colors, glow });
127
- return glow;
128
- }
129
-
130
- function toneColor(glow: ColorRamp, intensity: number): Rgb {
131
- return interpolateRgb(glow.from, glow.to, intensity ** TONE_GAMMA);
118
+ function paintedBreath(theme: Theme, level: ThinkingLevel): PaintedBreath {
119
+ const byLevel = perPalette(theme, "activity-breath", () => new Map<ThinkingLevel, PaintedBreath>());
120
+ let breath = byLevel.get(level);
121
+ if (!breath) {
122
+ const glow = activityGlow(theme, level);
123
+ const paint = (intensity: number) => glow
124
+ ? colorizeRgb(theme, interpolateRgb(glow.from, glow.to, intensity ** TONE_GAMMA), DOT)
125
+ // Reached only when the theme's colors did not resolve.
126
+ : theme.fg(intensity > 0.5 ? "text" : "dim", DOT);
127
+ breath = FRAMES.map((cells) => cells.map(paint));
128
+ byLevel.set(level, breath);
129
+ }
130
+ return breath;
132
131
  }
133
132
 
134
133
  /** A line the light runs along with a fading afterimage, in the thinking level's color, fitted to the width. */
@@ -142,12 +141,7 @@ export function renderActivityDots(
142
141
  if (width <= 0) return [];
143
142
  const indent = Math.max(0, Math.min(column, width - 1));
144
143
  const fitting = Math.min(DOT_COUNT, width - indent);
145
- const glow = cachedGlow(theme, level);
146
- const dots = FRAMES[frameIndex(frame)]!.slice(0, fitting).map((intensity) => glow
147
- ? colorizeRgb(theme, toneColor(glow, intensity), DOT)
148
- // Reached only when the theme's colors did not resolve.
149
- : theme.fg(intensity > 0.5 ? "text" : "dim", DOT));
150
- return [" ".repeat(indent) + dots.join("")];
144
+ return [" ".repeat(indent) + paintedBreath(theme, level)[frameIndex(frame)]!.slice(0, fitting).join("")];
151
145
  }
152
146
 
153
147
  /** Add the activity row directly beneath the prompt, keeping Pi's padding inside its box. */
@@ -1,72 +1,152 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ // A namespace import: Pi before 0.99 has no color functions, and a named import of one would fail to load.
3
+ import * as tui from "@earendil-works/pi-tui";
2
4
 
5
+ /** Plain channels, 0-255, so the math below stays arithmetic on three numbers. */
3
6
  export type Rgb = { r: number; g: number; b: number };
4
- type ThemeForeground = Parameters<Theme["getFgAnsi"]>[0];
5
7
 
6
- const ANSI_BASIC_RGB: readonly Rgb[] = [
8
+ /**
9
+ * What Pi 0.99 added to its Theme for extensions, and what Pi 0.85 to 0.87 keep on
10
+ * theirs. Each piece is read as optional, and typed here rather than taken from Pi,
11
+ * so the extension builds and runs against every Pi it supports.
12
+ */
13
+ type HostColor = object;
14
+ type HostTheme = {
15
+ colors?: Readonly<Record<string, HostColor | undefined>>;
16
+ appearance?: "dark" | "light";
17
+ fgColors?: unknown;
18
+ };
19
+ const host = (theme: Theme) => theme as unknown as HostTheme;
20
+ const colorToRgb = (tui as unknown as { colorToRgb?: (color: HostColor) => Rgb }).colorToRgb;
21
+
22
+ /**
23
+ * A theme token's concrete color. Pi 0.99 resolves every token, including one set
24
+ * to the terminal's own color and one it renders faint; older Pi only writes an
25
+ * escape sequence, which is read back. A token Pi does not know resolves to
26
+ * nothing rather than throwing, since callers compute names from host state.
27
+ */
28
+ export function themeColorRgb(theme: Theme, color: ThemeColor): Rgb | undefined {
29
+ const { colors } = host(theme);
30
+ if (colors && colorToRgb) {
31
+ const concrete = colors[color];
32
+ if (!concrete) return undefined;
33
+ const { r, g, b } = colorToRgb(concrete);
34
+ // Rounded as Pi rounds a color it writes out, so derived shades start from what the terminal shows.
35
+ return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
36
+ }
37
+ return escapeColorRgb(theme, color);
38
+ }
39
+
40
+ /** Pi before 0.99: the color inside the token's escape sequence. */
41
+ function escapeColorRgb(theme: Theme, color: ThemeColor): Rgb | undefined {
42
+ let ansi: string;
43
+ try {
44
+ ansi = theme.getFgAnsi(color);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ const trueColor = ansi.match(/\[38;2;(\d+);(\d+);(\d+)m/u);
49
+ if (trueColor) return { r: Number(trueColor[1]), g: Number(trueColor[2]), b: Number(trueColor[3]) };
50
+ const indexed = ansi.match(/\[38;5;(\d+)m/u);
51
+ return indexed ? indexedRgb(Number(indexed[1])) : undefined;
52
+ }
53
+
54
+ const BASIC_RGB: readonly Rgb[] = [
7
55
  { r: 0, g: 0, b: 0 }, { r: 128, g: 0, b: 0 }, { r: 0, g: 128, b: 0 }, { r: 128, g: 128, b: 0 },
8
56
  { r: 0, g: 0, b: 128 }, { r: 128, g: 0, b: 128 }, { r: 0, g: 128, b: 128 }, { r: 192, g: 192, b: 192 },
9
57
  { r: 128, g: 128, b: 128 }, { r: 255, g: 0, b: 0 }, { r: 0, g: 255, b: 0 }, { r: 255, g: 255, b: 0 },
10
58
  { r: 0, g: 0, b: 255 }, { r: 255, g: 0, b: 255 }, { r: 0, g: 255, b: 255 }, { r: 255, g: 255, b: 255 },
11
59
  ];
60
+ /** The 256-color cube's channel values and its gray ramp, as xterm and Pi define them. */
61
+ const CUBE = [0, 95, 135, 175, 215, 255];
62
+ const GRAYS = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
12
63
 
13
- function ansi256ToRgb(index: number): Rgb {
14
- if (index < 16) return ANSI_BASIC_RGB[index] ?? { r: 192, g: 192, b: 192 };
64
+ function indexedRgb(index: number): Rgb {
65
+ if (index < 16) return BASIC_RGB[index] ?? BASIC_RGB[7]!;
15
66
  if (index < 232) {
16
- const value = index - 16;
17
- const channel = (part: number) => part === 0 ? 0 : 55 + part * 40;
18
- return {
19
- r: channel(Math.floor(value / 36)),
20
- g: channel(Math.floor((value % 36) / 6)),
21
- b: channel(value % 6),
22
- };
67
+ const cube = index - 16;
68
+ return { r: CUBE[Math.floor(cube / 36)]!, g: CUBE[Math.floor((cube % 36) / 6)]!, b: CUBE[cube % 6]! };
23
69
  }
24
- const gray = 8 + Math.min(23, index - 232) * 10;
70
+ const gray = GRAYS[Math.min(23, index - 232)]!;
25
71
  return { r: gray, g: gray, b: gray };
26
72
  }
27
73
 
28
- const ANSI_256_RGB: readonly Rgb[] = Array.from({ length: 256 }, (_, index) => ansi256ToRgb(index));
74
+ function nearest(values: readonly number[], target: number): number {
75
+ let best = 0;
76
+ for (let index = 1; index < values.length; index++) {
77
+ if (Math.abs(target - values[index]!) < Math.abs(target - values[best]!)) best = index;
78
+ }
79
+ return best;
80
+ }
29
81
 
30
82
  /**
31
- * The nearest-color search runs per character on 256-color terminals, so both the
32
- * palette and the answers are kept. Callers interpolate between a handful of theme
33
- * colors, so the memo holds tens of entries rather than growing with output size.
83
+ * The 256-color entry Pi 0.99 picks for a color: the nearest cube or gray step, never
84
+ * the first sixteen, which are whatever the terminal makes of them. Written here so
85
+ * every Pi this extension runs on draws a derived color the same way.
34
86
  */
35
- const ansi256ByRgb = new Map<number, number>();
36
-
37
- function rgbToAnsi256(rgb: Rgb): number {
38
- const key = (rgb.r << 16) | (rgb.g << 8) | rgb.b;
39
- const memoized = ansi256ByRgb.get(key);
40
- if (memoized !== undefined) return memoized;
41
- let bestIndex = 0;
42
- let bestDistance = Number.POSITIVE_INFINITY;
43
- for (let index = 0; index < 256; index++) {
44
- const candidate = ANSI_256_RGB[index]!;
45
- const distance = (rgb.r - candidate.r) ** 2 + (rgb.g - candidate.g) ** 2 + (rgb.b - candidate.b) ** 2;
46
- if (distance >= bestDistance) continue;
47
- bestIndex = index;
48
- bestDistance = distance;
49
- }
50
- ansi256ByRgb.set(key, bestIndex);
51
- return bestIndex;
87
+ function ansi256(rgb: Rgb): number {
88
+ const [r, g, b] = [nearest(CUBE, rgb.r), nearest(CUBE, rgb.g), nearest(CUBE, rgb.b)];
89
+ const cube = { r: CUBE[r]!, g: CUBE[g]!, b: CUBE[b]! };
90
+ const grayIndex = nearest(GRAYS, Math.round(luminanceRgb(rgb)));
91
+ const gray = GRAYS[grayIndex]!;
92
+ const distance = (to: Rgb) => (rgb.r - to.r) ** 2 * 0.299 + (rgb.g - to.g) ** 2 * 0.587 + (rgb.b - to.b) ** 2 * 0.114;
93
+ const spread = Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
94
+ return spread < 10 && distance({ r: gray, g: gray, b: gray }) < distance(cube) ? 232 + grayIndex : 16 + 36 * r + 6 * g + b;
52
95
  }
53
96
 
54
- export function themeColorRgb(theme: Theme, color: ThemeForeground): Rgb | undefined {
55
- if (typeof theme.getFgAnsi !== "function") return undefined;
56
- // Pi throws on a color it does not know, and callers reach this with names computed from
57
- // host state — a thinking level Pi adds later would otherwise take down a render.
58
- let ansi: string;
59
- try {
60
- ansi = theme.getFgAnsi(color);
61
- } catch {
62
- return undefined;
97
+ /** The opening escape sequence for a color, foreground (38) or background (48), in the terminal's color mode. */
98
+ function openColor(theme: Theme, color: Rgb, set: 38 | 48): string {
99
+ return theme.getColorMode?.() === "256color"
100
+ ? `\x1b[${set};5;${ansi256(color)}m`
101
+ : `\x1b[${set};2;${Math.round(color.r)};${Math.round(color.g)};${Math.round(color.b)}m`;
102
+ }
103
+
104
+ export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
105
+ return `${openColor(theme, color, 38)}${text}\x1b[39m`;
106
+ }
107
+
108
+ export function fillRgb(theme: Theme, color: Rgb, text: string): string {
109
+ return `${openColor(theme, color, 48)}${text}\x1b[49m`;
110
+ }
111
+
112
+ /** A painter for one color, its escape sequence written once for every text it paints. */
113
+ export function rgbPainter(theme: Theme, color: Rgb): (text: string) => string {
114
+ const open = openColor(theme, color, 38);
115
+ return (text) => `${open}${text}\x1b[39m`;
116
+ }
117
+
118
+ /**
119
+ * The palette a theme draws with right now, as an object to key derived colors by.
120
+ * Pi 0.99 hands out a new `theme.colors` whenever the theme or the terminal's colors
121
+ * change. Pi 0.85 to 0.87 give every theme its own map of escape sequences, reached
122
+ * through the same Theme object, which `/theme` points at another theme. A host with
123
+ * neither is told apart by the escape sequences of the colors derived from.
124
+ */
125
+ export function paletteOf(theme: Theme): object {
126
+ const { colors, fgColors } = host(theme);
127
+ if (colors) return colors;
128
+ if (fgColors instanceof Map) return fgColors;
129
+ let key = theme.getColorMode?.() ?? "";
130
+ for (const token of PALETTE_TOKENS) {
131
+ try {
132
+ key += theme.getFgAnsi(token);
133
+ } catch {
134
+ key += "?";
135
+ }
63
136
  }
64
- const trueColor = ansi.match(/\[38;2;(\d+);(\d+);(\d+)m/u);
65
- if (trueColor) return { r: Number(trueColor[1]), g: Number(trueColor[2]), b: Number(trueColor[3]) };
66
- const indexed = ansi.match(/\[38;5;(\d+)m/u);
67
- return indexed ? ansi256ToRgb(Number(indexed[1])) : undefined;
137
+ let palette = escapePalettes.get(key);
138
+ if (!palette) escapePalettes.set(key, palette = {});
139
+ return palette;
68
140
  }
69
141
 
142
+ /** Every token the derived colors are made from. */
143
+ const PALETTE_TOKENS: readonly ThemeColor[] = [
144
+ "text", "dim", "muted", "toolDiffAdded", "toolDiffRemoved",
145
+ "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
146
+ ];
147
+ /** One object per palette seen; a session switches among a handful of themes at most. */
148
+ const escapePalettes = new Map<string, object>();
149
+
70
150
  export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
71
151
  const bounded = Math.max(0, Math.min(1, amount));
72
152
  const channel = (start: number, end: number) => Math.round(start + (end - start) * bounded);
@@ -77,34 +157,23 @@ export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
77
157
  };
78
158
  }
79
159
 
80
- /** Shared by the foreground (38/39) and background (48/49) SGR pairs. */
81
- function paintRgb(theme: Theme, color: Rgb, text: string, set: 38 | 48): string {
82
- const reset = set === 38 ? 39 : 49;
83
- if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
84
- return `\x1b[${set};5;${rgbToAnsi256(color)}m${text}\x1b[${reset}m`;
85
- }
86
- return `\x1b[${set};2;${color.r};${color.g};${color.b}m${text}\x1b[${reset}m`;
87
- }
88
-
89
- export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
90
- return paintRgb(theme, color, text, 38);
91
- }
92
-
93
160
  /** Perceived brightness on the same 0-255 scale as the channels, and linear under interpolation. */
94
161
  function luminanceRgb(rgb: Rgb): number {
95
162
  return rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114;
96
163
  }
97
164
 
98
- function isDarkTheme(theme: Theme): boolean {
99
- const text = themeColorRgb(theme, "text");
100
- if (!text) return true;
101
- return luminanceRgb(text) > 128;
102
- }
103
-
104
- /** Themes never expose their terminal background, so the poles stand in for it. */
165
+ /** The poles stand in for the terminal background: standing out means moving away from the theme's own. */
105
166
  const BLACK: Rgb = { r: 0, g: 0, b: 0 };
106
167
  const WHITE: Rgb = { r: 255, g: 255, b: 255 };
107
168
 
169
+ /** The theme's declared appearance; before Pi 0.99, a theme with light text is a dark one. */
170
+ function isDark(theme: Theme): boolean {
171
+ const { appearance } = host(theme);
172
+ if (appearance) return appearance === "dark";
173
+ const text = themeColorRgb(theme, "text");
174
+ return !text || luminanceRgb(text) > 128;
175
+ }
176
+
108
177
  /** Mix a color toward its own gray so a vivid theme hue cannot turn chrome into a highlight. */
109
178
  export function neutralizeRgb(rgb: Rgb, keepHue: number): Rgb {
110
179
  const gray = Math.round(luminanceRgb(rgb));
@@ -128,40 +197,40 @@ function atBrightness(rgb: Rgb, target: number): Rgb {
128
197
  return reached >= target - 0.5 ? rounded : interpolateRgb(rounded, WHITE, (target - reached) / (255 - reached));
129
198
  }
130
199
 
200
+ /** How far a color stands off the background: 0 for invisible, 1 for the opposite pole. */
201
+ function contrastLevel(dark: boolean, rgb: Rgb): number {
202
+ const brightness = luminanceRgb(rgb) / 255;
203
+ return dark ? brightness : 1 - brightness;
204
+ }
205
+
206
+ function atContrast(dark: boolean, rgb: Rgb, level: number): Rgb {
207
+ return atBrightness(rgb, (dark ? level : 1 - level) * 255);
208
+ }
209
+
131
210
  /** Lift a color part of the way to maximum contrast, keeping its hue for as long as it can. */
132
211
  export function liftContrast(theme: Theme, rgb: Rgb, amount: number): Rgb {
133
- const dark = isDarkTheme(theme);
134
- const brightness = luminanceRgb(rgb) / 255;
135
- const level = dark ? brightness : 1 - brightness;
136
- const target = level + (1 - level) * amount;
137
- return atBrightness(rgb, (dark ? target : 1 - target) * 255);
212
+ const dark = isDark(theme);
213
+ const level = contrastLevel(dark, rgb);
214
+ return atContrast(dark, rgb, level + (1 - level) * amount);
138
215
  }
139
216
 
140
217
  /**
141
- * Move a color until how far it stands off the theme background — 0 for invisible, 1 for
142
- * the opposite pole — lands inside the band. A color already inside is returned untouched,
143
- * so a theme's own choice survives wherever it is legible to begin with.
218
+ * Move a color until its contrast against the background lands inside the band. A color
219
+ * already inside is returned untouched, so a theme's own choice survives wherever it is
220
+ * legible to begin with.
144
221
  */
145
222
  export function withContrast(theme: Theme, rgb: Rgb, minimum: number, maximum: number): Rgb {
146
- const dark = isDarkTheme(theme);
147
- const brightness = luminanceRgb(rgb) / 255;
148
- const level = dark ? brightness : 1 - brightness;
149
- const target = level > maximum ? maximum : level < minimum ? minimum : level;
150
- if (target === level) return rgb;
151
- return atBrightness(rgb, (dark ? target : 1 - target) * 255);
223
+ const dark = isDark(theme);
224
+ const level = contrastLevel(dark, rgb);
225
+ const target = Math.min(maximum, Math.max(minimum, level));
226
+ return target === level ? rgb : atContrast(dark, rgb, target);
152
227
  }
153
228
 
154
229
  /**
155
230
  * Diff rows need a tint that reads on the terminal background without hijacking `toolSuccessBg`
156
231
  * and `toolErrorBg`, which Pi paints across the whole tool row.
157
232
  */
158
- export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.17): Rgb | undefined {
233
+ export function diffTintRgb(theme: Theme, color: ThemeColor, amount = 0.17): Rgb | undefined {
159
234
  const rgb = themeColorRgb(theme, color);
160
- if (!rgb) return undefined;
161
- const base = isDarkTheme(theme) ? BLACK : WHITE;
162
- return interpolateRgb(base, rgb, amount);
163
- }
164
-
165
- export function fillRgb(theme: Theme, color: Rgb, text: string): string {
166
- return paintRgb(theme, color, text, 48);
235
+ return rgb && interpolateRgb(isDark(theme) ? BLACK : WHITE, rgb, amount);
167
236
  }
@@ -1,10 +1,18 @@
1
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
- import { indicatorGlyph, indicatorStrength, indicatorTone, type RowStatus } from "./compact-tools-core.ts";
1
+ import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ indicatorGlyph,
4
+ indicatorStrength,
5
+ indicatorTone,
6
+ RUNNING_INDICATOR_FRAME_COUNT,
7
+ type RowStatus,
8
+ } from "./compact-tools-core.ts";
3
9
  import {
4
10
  colorizeRgb,
5
11
  interpolateRgb,
6
12
  liftContrast,
7
13
  neutralizeRgb,
14
+ paletteOf,
15
+ rgbPainter,
8
16
  themeColorRgb,
9
17
  withContrast,
10
18
  type Rgb,
@@ -24,9 +32,7 @@ import {
24
32
  * background the moves invert, and standing out means going darker.
25
33
  */
26
34
 
27
- type ThemeForeground = Parameters<Theme["getFgAnsi"]>[0];
28
-
29
- type ContrastBand = { color: ThemeForeground; keepHue: number; minimum: number; maximum: number };
35
+ type ContrastBand = { color: ThemeColor; keepHue: number; minimum: number; maximum: number };
30
36
 
31
37
  /** A band with no color attached, for a derivation that supplies its own source. */
32
38
  type ContrastRange = { minimum: number; maximum: number };
@@ -63,32 +69,40 @@ const THINKING_LEVEL_COLOR = {
63
69
  high: "thinkingHigh",
64
70
  xhigh: "thinkingXhigh",
65
71
  max: "thinkingMax",
66
- } as const satisfies Record<ThinkingLevel, ThemeForeground>;
72
+ } as const satisfies Record<ThinkingLevel, ThemeColor>;
67
73
 
68
74
  /** The theme color a thinking level is painted with. */
69
- export function thinkingLevelColor(level: ThinkingLevel): ThemeForeground {
75
+ export function thinkingLevelColor(level: ThinkingLevel): ThemeColor {
70
76
  return THINKING_LEVEL_COLOR[level] ?? "thinkingMedium";
71
77
  }
72
78
 
73
79
  /**
74
- * Derivation is deliberately not cached against the Theme: `/theme` can swap a palette behind
75
- * the same instance, and a stale entry would outlive the switch. Callers that redraw per line
76
- * hold the derived value themselves instead.
80
+ * Derived colors, kept for as long as the palette they came from. `/theme` swaps the
81
+ * palette behind the same Theme, so the palette rather than the Theme is the key: a
82
+ * switch misses the cache by itself, and every frame in between is a lookup.
77
83
  */
84
+ const derivedByPalette = new WeakMap<object, Map<string, unknown>>();
85
+
86
+ export function perPalette<T>(theme: Theme, key: string, derive: () => T): T {
87
+ const palette = paletteOf(theme);
88
+ let cache = derivedByPalette.get(palette);
89
+ if (!cache) derivedByPalette.set(palette, cache = new Map());
90
+ if (!cache.has(key)) cache.set(key, derive());
91
+ return cache.get(key) as T;
92
+ }
93
+
78
94
  function bandedRgb(theme: Theme, band: ContrastBand): Rgb | undefined {
79
95
  const rgb = themeColorRgb(theme, band.color);
80
96
  if (!rgb) return undefined;
81
97
  return withContrast(theme, neutralizeRgb(rgb, band.keepHue), band.minimum, band.maximum);
82
98
  }
83
99
 
84
- /**
85
- * A chrome painter bound to one theme. Deriving the tone costs a parse and a dozen floating
86
- * point operations, and the frame is drawn once per line of tool output or thinking detail,
87
- * so callers in a loop take a painter once and callers with a single rail use `paintChrome`.
88
- */
100
+ /** A painter for the chrome tone, derived once per palette however many rails a frame draws. */
89
101
  export function chromePainter(theme: Theme): (text: string) => string {
90
- const chrome = bandedRgb(theme, CHROME);
91
- return chrome ? (text) => colorizeRgb(theme, chrome, text) : (text) => theme.fg("dim", text);
102
+ return perPalette(theme, "chrome", () => {
103
+ const chrome = bandedRgb(theme, CHROME);
104
+ return chrome ? rgbPainter(theme, chrome) : (text: string) => theme.fg("dim", text);
105
+ });
92
106
  }
93
107
 
94
108
  /** Paint one rail, connector, or status line in the chrome tone. */
@@ -104,18 +118,27 @@ function ramp(theme: Theme, low: ContrastBand, high: ContrastBand): ColorRamp |
104
118
  return from && to ? { from, to } : undefined;
105
119
  }
106
120
 
107
- /** Fade endpoints for the running tool indicator, or undefined when the theme resolves no RGB. */
108
- export function indicatorPulse(theme: Theme): ColorRamp | undefined {
109
- return ramp(theme, PULSE_LOW, PULSE_HIGH);
121
+ /**
122
+ * A row's status dot; a running one pulses between the theme's endpoints as frames advance.
123
+ * Every running row repaints its dot on every frame, so the pulse is painted once per palette.
124
+ */
125
+ export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
126
+ if (status !== "running") return theme.fg(indicatorTone(status), indicatorGlyph(status));
127
+ const pulse = runningPulse(theme);
128
+ return pulse[((frame % pulse.length) + pulse.length) % pulse.length]!;
110
129
  }
111
130
 
112
- /** A row's status dot; a running one pulses between the theme's endpoints as frames advance. */
113
- export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
114
- const glyph = indicatorGlyph(status, frame);
115
- const pulse = status === "running" ? indicatorPulse(theme) : undefined;
116
- return pulse
117
- ? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength(status, frame)), glyph)
118
- : theme.fg(indicatorTone(status, frame), glyph);
131
+ /** Every step of the running dot's pulse, painted once per palette. */
132
+ function runningPulse(theme: Theme): readonly string[] {
133
+ return perPalette(theme, "running-pulse", () => {
134
+ const pulse = ramp(theme, PULSE_LOW, PULSE_HIGH);
135
+ return Array.from({ length: RUNNING_INDICATOR_FRAME_COUNT }, (_, step) => {
136
+ const glyph = indicatorGlyph("running", step);
137
+ return pulse
138
+ ? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength("running", step)), glyph)
139
+ : theme.fg(indicatorTone("running", step), glyph);
140
+ });
141
+ });
119
142
  }
120
143
 
121
144
  /** How far the brightest activity dot moves from the level's color toward the label's crest. */
@@ -129,17 +152,21 @@ const ACTIVITY_UNLIT = 0.05;
129
152
  * label's full crest washes every level toward the same near-white.
130
153
  */
131
154
  export function activityGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
132
- const glow = progressGlow(theme, level);
133
- if (!glow) return undefined;
134
- const lit = interpolateRgb(glow.from, glow.to, ACTIVITY_CREST_MIX);
135
- return { from: withContrast(theme, lit, ACTIVITY_UNLIT, ACTIVITY_UNLIT), to: lit };
155
+ return perPalette(theme, `activity:${level}`, () => {
156
+ const glow = progressGlow(theme, level);
157
+ if (!glow) return undefined;
158
+ const lit = interpolateRgb(glow.from, glow.to, ACTIVITY_CREST_MIX);
159
+ return { from: withContrast(theme, lit, ACTIVITY_UNLIT, ACTIVITY_UNLIT), to: lit };
160
+ });
136
161
  }
137
162
 
138
163
  /** Sweep endpoints for the working label at a thinking level, resting color to crest. */
139
164
  export function progressGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
140
- const raw = themeColorRgb(theme, thinkingLevelColor(level));
141
- if (!raw) return undefined;
142
- const lifted = liftContrast(theme, raw, GLOW_REST_LIFT);
143
- const from = withContrast(theme, lifted, GLOW_FLOOR.minimum, GLOW_FLOOR.maximum);
144
- return { from, to: liftContrast(theme, from, GLOW_CREST_LIFT) };
165
+ return perPalette(theme, `progress:${level}`, () => {
166
+ const raw = themeColorRgb(theme, thinkingLevelColor(level));
167
+ if (!raw) return undefined;
168
+ const lifted = liftContrast(theme, raw, GLOW_REST_LIFT);
169
+ const from = withContrast(theme, lifted, GLOW_FLOOR.minimum, GLOW_FLOOR.maximum);
170
+ return { from, to: liftContrast(theme, from, GLOW_CREST_LIFT) };
171
+ });
145
172
  }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { onTick } from "./compact-tools-clock.ts";
4
- import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
4
+ import { colorizeRgb, interpolateRgb, paletteOf } from "./compact-tools-color.ts";
5
5
  import { progressGlow, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
6
6
  import type { ToolArgs } from "./compact-tools-types.ts";
7
7
 
@@ -88,6 +88,8 @@ export class ProgressController {
88
88
  private readonly activeTools = new Map<string, string>();
89
89
  private message: string | undefined;
90
90
  private frames: string[] = [];
91
+ /** The palette `frames` were painted with. */
92
+ private paintedFor: object | undefined;
91
93
  private frame = 0;
92
94
  /** Stops this label's share of the animation clock. */
93
95
  private stopTicking: (() => void) | undefined;
@@ -134,7 +136,7 @@ export class ProgressController {
134
136
  if (!this.context || event.level === this.level) return;
135
137
  this.level = event.level;
136
138
  // Recolor in place: the label and its animation phase are unchanged.
137
- this.frames = createGlowFrames(this.message ?? "", this.context.ui.theme, this.level);
139
+ this.frames = [];
138
140
  this.renderMessage();
139
141
  });
140
142
  }
@@ -169,7 +171,7 @@ export class ProgressController {
169
171
  private setMessage(message: string): void {
170
172
  if (!this.context || (this.message === message && this.stopTicking)) return;
171
173
  this.message = message;
172
- this.frames = createGlowFrames(message, this.context.ui.theme, this.level);
174
+ this.frames = [];
173
175
  this.frame = 0;
174
176
  this.renderMessage();
175
177
  if (this.stopTicking) return;
@@ -180,7 +182,15 @@ export class ProgressController {
180
182
  }
181
183
 
182
184
  private renderMessage(): void {
183
- if (!this.message || !this.context || this.frames.length === 0) return;
185
+ if (!this.message || !this.context) return;
186
+ // Painted once per label, level, and palette: a theme switch, or the system theme
187
+ // following the terminal from dark to light, repaints on the next frame.
188
+ const theme = this.context.ui.theme;
189
+ const palette = paletteOf(theme);
190
+ if (this.frames.length === 0 || this.paintedFor !== palette) {
191
+ this.frames = createGlowFrames(this.message, theme, this.level);
192
+ this.paintedFor = palette;
193
+ }
184
194
  this.context.ui.setWorkingMessage(this.frames[this.frame % this.frames.length]);
185
195
  }
186
196
 
@@ -44,6 +44,11 @@ export class ToolRuntime {
44
44
  * more than one run's calls.
45
45
  */
46
46
  private readonly currentCalls = new Set<string>();
47
+ /**
48
+ * Calls a tool is making through `ctx.executeTool()`, as codemode scripts do. They
49
+ * never get a row of their own, so nothing is timed or remembered for them.
50
+ */
51
+ private readonly nestedCalls = new Set<string>();
47
52
 
48
53
  constructor(private readonly clock: () => number = () => performance.now()) {
49
54
  const shared = globalThis as SharedState;
@@ -93,7 +98,10 @@ export class ToolRuntime {
93
98
  * running, so a row that never got its result stops pulsing and waits instead.
94
99
  */
95
100
  setBusy(busy: boolean): void {
96
- if (busy && this.busy !== true) this.currentCalls.clear();
101
+ if (busy && this.busy !== true) {
102
+ this.currentCalls.clear();
103
+ this.nestedCalls.clear();
104
+ }
97
105
  this.busy = busy;
98
106
  if (busy) return;
99
107
  const animating = this.indicators.size > 0;
@@ -110,6 +118,12 @@ export class ToolRuntime {
110
118
  this.currentCalls.add(toolCallId);
111
119
  }
112
120
 
121
+ /** A call another tool made began (`running`) or finished; Pi says so with `parentToolCallId`. */
122
+ noteNestedCall(toolCallId: string, running: boolean): void {
123
+ if (running) this.nestedCalls.add(toolCallId);
124
+ else this.nestedCalls.delete(toolCallId);
125
+ }
126
+
113
127
  /**
114
128
  * Whether a call can be running: only one Pi announced in the run going on. A
115
129
  * row restored from the session, one left without a result when its run ended,
@@ -204,7 +218,7 @@ export class ToolRuntime {
204
218
  createTimedExecute(definition: BuiltInDefinition): TimedExecute {
205
219
  const execute = definition.execute as TimedExecute;
206
220
  return async (...args: any[]) => {
207
- const toolCallId = typeof args[0] === "string" ? args[0] : undefined;
221
+ const toolCallId = typeof args[0] === "string" && !this.nestedCalls.has(args[0]) ? args[0] : undefined;
208
222
  const startedAt = (toolCallId ? this.executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
209
223
  if (toolCallId && !this.executionTimings.has(toolCallId)) {
210
224
  this.executionTimings.set(toolCallId, { startedAt });
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  AssistantMessageComponent,
3
+ BranchSummaryMessageComponent,
4
+ CompactionSummaryMessageComponent,
3
5
  CustomMessageComponent,
4
6
  DynamicBorder,
5
7
  ToolExecutionComponent,
@@ -118,12 +120,25 @@ export function renderAnswerOnly(component: AssistantLike, width: number, render
118
120
  }
119
121
  }
120
122
 
123
+ /**
124
+ * A summary Pi writes into the chat of its own accord: the context it compacted
125
+ * away, or the /tree branch it left behind. Both are bookkeeping rather than a
126
+ * prompt or an answer, and compaction arrives mid-turn, where a visible row
127
+ * would come between the animating activity line and the editor below it.
128
+ * Read through `typeof` so an older Pi without the export keeps its own rows.
129
+ */
130
+ function isSummary(child: unknown): boolean {
131
+ return (typeof CompactionSummaryMessageComponent === "function" && isKind(child, CompactionSummaryMessageComponent))
132
+ || (typeof BranchSummaryMessageComponent === "function" && isKind(child, BranchSummaryMessageComponent));
133
+ }
134
+
121
135
  /**
122
136
  * A row Pi (or this extension) adds to the chat directly rather than as a message:
123
- * status lines are Text, and "What's New" blocks add borders and Markdown too.
137
+ * status lines are Text, "What's New" blocks add borders and Markdown too, and
138
+ * compaction and branch summaries come as components of their own.
124
139
  */
125
140
  function isNotice(child: unknown): boolean {
126
- return isKind(child, Text) || isKind(child, DynamicBorder) || isKind(child, Markdown);
141
+ return isKind(child, Text) || isKind(child, DynamicBorder) || isKind(child, Markdown) || isSummary(child);
127
142
  }
128
143
 
129
144
  /**
@@ -503,6 +503,14 @@ function noteCalls(message: unknown): void {
503
503
  }
504
504
  }
505
505
 
506
+ /**
507
+ * Whether another tool made the call through `ctx.executeTool()`, as codemode scripts
508
+ * do. Pi 0.99 marks such calls with `parentToolCallId`; older Pi never makes them.
509
+ */
510
+ function madeByTool(event: object): boolean {
511
+ return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
512
+ }
513
+
506
514
  /** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
507
515
  function bindRenderer(ctx: ExtensionContext): void {
508
516
  ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
@@ -572,10 +580,14 @@ export default function compactTools(pi: ExtensionAPI): void {
572
580
  // their rows, and told to extensions before Pi makes them.
573
581
  pi.on("message_update", (event) => noteCalls(event.message));
574
582
  pi.on("tool_execution_start", (event) => {
583
+ if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, true);
575
584
  runtime.noteCall(event.toolCallId);
576
585
  runtime.noteExecutionStart(event.toolCallId);
577
586
  });
578
- pi.on("tool_execution_end", (event) => runtime.noteExecutionEnd(event.toolCallId));
587
+ pi.on("tool_execution_end", (event) => {
588
+ if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, false);
589
+ runtime.noteExecutionEnd(event.toolCallId);
590
+ });
579
591
  pi.on("session_shutdown", (event) => {
580
592
  thinkingCycle.dispose();
581
593
  progress.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Compact, expandable tool rows for Pi in its own compact style or Claude Code's, with a silent mode and a GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,8 +50,8 @@
50
50
  "@earendil-works/pi-tui": ">=0.85.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-coding-agent": "0.87.0",
54
- "@earendil-works/pi-tui": "0.87.0",
53
+ "@earendil-works/pi-coding-agent": "0.99.1",
54
+ "@earendil-works/pi-tui": "0.99.1",
55
55
  "@types/node": "^24.0.0",
56
56
  "tsx": "^4.23.13",
57
57
  "typescript": "^5.9.0"
@@ -1,4 +1,12 @@
1
1
  {
2
+ "0.13.1": [],
3
+ "0.13.0": [
4
+ "Follows Pi 0.99's system theme and light terminals: rails, dots, and the working label take their colors from the theme itself",
5
+ "Running dots, rails, and silent mode's activity line are painted once per theme instead of every frame",
6
+ "The working label repaints when the theme changes, including when the terminal switches between dark and light",
7
+ "Tools a codemode script calls leave nothing behind in the rows' timings",
8
+ "Silent mode hides compaction and branch summaries that Pi writes mid-turn"
9
+ ],
2
10
  "0.12.0": [
3
11
  "Running rows animate without being rebuilt: one repaint per frame, however many tools run",
4
12
  "A tool's time counts from when it runs, not from when the model began writing its call",