pi-compact-tools 0.12.0 → 0.13.0
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/extensions/compact-tools-activity.ts +20 -26
- package/extensions/compact-tools-color.ts +154 -89
- package/extensions/compact-tools-palette.ts +63 -36
- package/extensions/compact-tools-progress.ts +14 -4
- package/extensions/compact-tools-runtime.ts +16 -2
- package/extensions/compact-tools-silent.ts +17 -2
- package/extensions/compact-tools.ts +5 -1
- package/package.json +3 -3
- package/release-notes.json +7 -0
|
@@ -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
|
|
4
|
+
import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
|
|
5
5
|
import { hookMethod } from "./compact-tools-hook.ts";
|
|
6
|
-
import { activityGlow,
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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,148 @@
|
|
|
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
|
-
|
|
8
|
+
/**
|
|
9
|
+
* What Pi 0.99 added to its Theme for extensions. Older Pi has none of it, so each
|
|
10
|
+
* piece is read as optional and the few lines below stand in when it is missing.
|
|
11
|
+
*/
|
|
12
|
+
type HostTheme = Theme & {
|
|
13
|
+
colors?: Readonly<Record<string, tui.Color | undefined>>;
|
|
14
|
+
appearance?: "dark" | "light";
|
|
15
|
+
};
|
|
16
|
+
const colorToRgb = (tui as Partial<typeof tui>).colorToRgb;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A theme token's concrete color. Pi 0.99 resolves every token, including one set
|
|
20
|
+
* to the terminal's own color and one it renders faint; older Pi only writes an
|
|
21
|
+
* escape sequence, which is read back. A token Pi does not know resolves to
|
|
22
|
+
* nothing rather than throwing, since callers compute names from host state.
|
|
23
|
+
*/
|
|
24
|
+
export function themeColorRgb(theme: Theme, color: ThemeColor): Rgb | undefined {
|
|
25
|
+
const { colors } = theme as HostTheme;
|
|
26
|
+
if (colors && colorToRgb) {
|
|
27
|
+
const concrete = colors[color];
|
|
28
|
+
if (!concrete) return undefined;
|
|
29
|
+
const { r, g, b } = colorToRgb(concrete);
|
|
30
|
+
// Rounded as Pi rounds a color it writes out, so derived shades start from what the terminal shows.
|
|
31
|
+
return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
|
|
32
|
+
}
|
|
33
|
+
return escapeColorRgb(theme, color);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pi before 0.99: the color inside the token's escape sequence. */
|
|
37
|
+
function escapeColorRgb(theme: Theme, color: ThemeColor): Rgb | undefined {
|
|
38
|
+
let ansi: string;
|
|
39
|
+
try {
|
|
40
|
+
ansi = theme.getFgAnsi(color);
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const trueColor = ansi.match(/\[38;2;(\d+);(\d+);(\d+)m/u);
|
|
45
|
+
if (trueColor) return { r: Number(trueColor[1]), g: Number(trueColor[2]), b: Number(trueColor[3]) };
|
|
46
|
+
const indexed = ansi.match(/\[38;5;(\d+)m/u);
|
|
47
|
+
return indexed ? indexedRgb(Number(indexed[1])) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BASIC_RGB: readonly Rgb[] = [
|
|
7
51
|
{ 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
52
|
{ 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
53
|
{ 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
54
|
{ 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
55
|
];
|
|
56
|
+
/** The 256-color cube's channel values and its gray ramp, as xterm and Pi define them. */
|
|
57
|
+
const CUBE = [0, 95, 135, 175, 215, 255];
|
|
58
|
+
const GRAYS = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
|
|
12
59
|
|
|
13
|
-
function
|
|
14
|
-
if (index < 16) return
|
|
60
|
+
function indexedRgb(index: number): Rgb {
|
|
61
|
+
if (index < 16) return BASIC_RGB[index] ?? BASIC_RGB[7]!;
|
|
15
62
|
if (index < 232) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
r: channel(Math.floor(value / 36)),
|
|
20
|
-
g: channel(Math.floor((value % 36) / 6)),
|
|
21
|
-
b: channel(value % 6),
|
|
22
|
-
};
|
|
63
|
+
const cube = index - 16;
|
|
64
|
+
return { r: CUBE[Math.floor(cube / 36)]!, g: CUBE[Math.floor((cube % 36) / 6)]!, b: CUBE[cube % 6]! };
|
|
23
65
|
}
|
|
24
|
-
const gray =
|
|
66
|
+
const gray = GRAYS[Math.min(23, index - 232)]!;
|
|
25
67
|
return { r: gray, g: gray, b: gray };
|
|
26
68
|
}
|
|
27
69
|
|
|
28
|
-
|
|
70
|
+
function nearest(values: readonly number[], target: number): number {
|
|
71
|
+
let best = 0;
|
|
72
|
+
for (let index = 1; index < values.length; index++) {
|
|
73
|
+
if (Math.abs(target - values[index]!) < Math.abs(target - values[best]!)) best = index;
|
|
74
|
+
}
|
|
75
|
+
return best;
|
|
76
|
+
}
|
|
29
77
|
|
|
30
78
|
/**
|
|
31
|
-
* The
|
|
32
|
-
*
|
|
33
|
-
*
|
|
79
|
+
* The 256-color entry Pi 0.99 picks for a color: the nearest cube or gray step, never
|
|
80
|
+
* the first sixteen, which are whatever the terminal makes of them. Written here so
|
|
81
|
+
* every Pi this extension runs on draws a derived color the same way.
|
|
34
82
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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;
|
|
83
|
+
function ansi256(rgb: Rgb): number {
|
|
84
|
+
const [r, g, b] = [nearest(CUBE, rgb.r), nearest(CUBE, rgb.g), nearest(CUBE, rgb.b)];
|
|
85
|
+
const cube = { r: CUBE[r]!, g: CUBE[g]!, b: CUBE[b]! };
|
|
86
|
+
const grayIndex = nearest(GRAYS, Math.round(luminanceRgb(rgb)));
|
|
87
|
+
const gray = GRAYS[grayIndex]!;
|
|
88
|
+
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;
|
|
89
|
+
const spread = Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
|
|
90
|
+
return spread < 10 && distance({ r: gray, g: gray, b: gray }) < distance(cube) ? 232 + grayIndex : 16 + 36 * r + 6 * g + b;
|
|
52
91
|
}
|
|
53
92
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
/** The opening escape sequence for a color, foreground (38) or background (48), in the terminal's color mode. */
|
|
94
|
+
function openColor(theme: Theme, color: Rgb, set: 38 | 48): string {
|
|
95
|
+
return theme.getColorMode?.() === "256color"
|
|
96
|
+
? `\x1b[${set};5;${ansi256(color)}m`
|
|
97
|
+
: `\x1b[${set};2;${Math.round(color.r)};${Math.round(color.g)};${Math.round(color.b)}m`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
|
|
101
|
+
return `${openColor(theme, color, 38)}${text}\x1b[39m`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function fillRgb(theme: Theme, color: Rgb, text: string): string {
|
|
105
|
+
return `${openColor(theme, color, 48)}${text}\x1b[49m`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A painter for one color, its escape sequence written once for every text it paints. */
|
|
109
|
+
export function rgbPainter(theme: Theme, color: Rgb): (text: string) => string {
|
|
110
|
+
const open = openColor(theme, color, 38);
|
|
111
|
+
return (text) => `${open}${text}\x1b[39m`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The palette a theme draws with right now, as an object to key derived colors by.
|
|
116
|
+
* Pi 0.99 hands out a new `theme.colors` whenever the theme or the terminal's colors
|
|
117
|
+
* change. Pi 0.85 to 0.87 give every theme its own map of escape sequences, reached
|
|
118
|
+
* through the same Theme object, which `/theme` points at another theme. A host with
|
|
119
|
+
* neither is told apart by the escape sequences of the colors derived from.
|
|
120
|
+
*/
|
|
121
|
+
export function paletteOf(theme: Theme): object {
|
|
122
|
+
const { colors, fgColors } = theme as HostTheme & { fgColors?: unknown };
|
|
123
|
+
if (colors) return colors;
|
|
124
|
+
if (fgColors instanceof Map) return fgColors;
|
|
125
|
+
let key = theme.getColorMode?.() ?? "";
|
|
126
|
+
for (const token of PALETTE_TOKENS) {
|
|
127
|
+
try {
|
|
128
|
+
key += theme.getFgAnsi(token);
|
|
129
|
+
} catch {
|
|
130
|
+
key += "?";
|
|
131
|
+
}
|
|
63
132
|
}
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
return indexed ? ansi256ToRgb(Number(indexed[1])) : undefined;
|
|
133
|
+
let palette = escapePalettes.get(key);
|
|
134
|
+
if (!palette) escapePalettes.set(key, palette = {});
|
|
135
|
+
return palette;
|
|
68
136
|
}
|
|
69
137
|
|
|
138
|
+
/** Every token the derived colors are made from. */
|
|
139
|
+
const PALETTE_TOKENS: readonly ThemeColor[] = [
|
|
140
|
+
"text", "dim", "muted", "toolDiffAdded", "toolDiffRemoved",
|
|
141
|
+
"thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
|
|
142
|
+
];
|
|
143
|
+
/** One object per palette seen; a session switches among a handful of themes at most. */
|
|
144
|
+
const escapePalettes = new Map<string, object>();
|
|
145
|
+
|
|
70
146
|
export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
|
|
71
147
|
const bounded = Math.max(0, Math.min(1, amount));
|
|
72
148
|
const channel = (start: number, end: number) => Math.round(start + (end - start) * bounded);
|
|
@@ -77,34 +153,23 @@ export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
|
|
|
77
153
|
};
|
|
78
154
|
}
|
|
79
155
|
|
|
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
156
|
/** Perceived brightness on the same 0-255 scale as the channels, and linear under interpolation. */
|
|
94
157
|
function luminanceRgb(rgb: Rgb): number {
|
|
95
158
|
return rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114;
|
|
96
159
|
}
|
|
97
160
|
|
|
98
|
-
|
|
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. */
|
|
161
|
+
/** The poles stand in for the terminal background: standing out means moving away from the theme's own. */
|
|
105
162
|
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
|
|
106
163
|
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
|
|
107
164
|
|
|
165
|
+
/** The theme's declared appearance; before Pi 0.99, a theme with light text is a dark one. */
|
|
166
|
+
function isDark(theme: Theme): boolean {
|
|
167
|
+
const { appearance } = theme as HostTheme;
|
|
168
|
+
if (appearance) return appearance === "dark";
|
|
169
|
+
const text = themeColorRgb(theme, "text");
|
|
170
|
+
return !text || luminanceRgb(text) > 128;
|
|
171
|
+
}
|
|
172
|
+
|
|
108
173
|
/** Mix a color toward its own gray so a vivid theme hue cannot turn chrome into a highlight. */
|
|
109
174
|
export function neutralizeRgb(rgb: Rgb, keepHue: number): Rgb {
|
|
110
175
|
const gray = Math.round(luminanceRgb(rgb));
|
|
@@ -128,40 +193,40 @@ function atBrightness(rgb: Rgb, target: number): Rgb {
|
|
|
128
193
|
return reached >= target - 0.5 ? rounded : interpolateRgb(rounded, WHITE, (target - reached) / (255 - reached));
|
|
129
194
|
}
|
|
130
195
|
|
|
196
|
+
/** How far a color stands off the background: 0 for invisible, 1 for the opposite pole. */
|
|
197
|
+
function contrastLevel(dark: boolean, rgb: Rgb): number {
|
|
198
|
+
const brightness = luminanceRgb(rgb) / 255;
|
|
199
|
+
return dark ? brightness : 1 - brightness;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function atContrast(dark: boolean, rgb: Rgb, level: number): Rgb {
|
|
203
|
+
return atBrightness(rgb, (dark ? level : 1 - level) * 255);
|
|
204
|
+
}
|
|
205
|
+
|
|
131
206
|
/** Lift a color part of the way to maximum contrast, keeping its hue for as long as it can. */
|
|
132
207
|
export function liftContrast(theme: Theme, rgb: Rgb, amount: number): Rgb {
|
|
133
|
-
const dark =
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
const target = level + (1 - level) * amount;
|
|
137
|
-
return atBrightness(rgb, (dark ? target : 1 - target) * 255);
|
|
208
|
+
const dark = isDark(theme);
|
|
209
|
+
const level = contrastLevel(dark, rgb);
|
|
210
|
+
return atContrast(dark, rgb, level + (1 - level) * amount);
|
|
138
211
|
}
|
|
139
212
|
|
|
140
213
|
/**
|
|
141
|
-
* Move a color until
|
|
142
|
-
*
|
|
143
|
-
*
|
|
214
|
+
* Move a color until its contrast against the background lands inside the band. A color
|
|
215
|
+
* already inside is returned untouched, so a theme's own choice survives wherever it is
|
|
216
|
+
* legible to begin with.
|
|
144
217
|
*/
|
|
145
218
|
export function withContrast(theme: Theme, rgb: Rgb, minimum: number, maximum: number): Rgb {
|
|
146
|
-
const dark =
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
if (target === level) return rgb;
|
|
151
|
-
return atBrightness(rgb, (dark ? target : 1 - target) * 255);
|
|
219
|
+
const dark = isDark(theme);
|
|
220
|
+
const level = contrastLevel(dark, rgb);
|
|
221
|
+
const target = Math.min(maximum, Math.max(minimum, level));
|
|
222
|
+
return target === level ? rgb : atContrast(dark, rgb, target);
|
|
152
223
|
}
|
|
153
224
|
|
|
154
225
|
/**
|
|
155
226
|
* Diff rows need a tint that reads on the terminal background without hijacking `toolSuccessBg`
|
|
156
227
|
* and `toolErrorBg`, which Pi paints across the whole tool row.
|
|
157
228
|
*/
|
|
158
|
-
export function diffTintRgb(theme: Theme, color:
|
|
229
|
+
export function diffTintRgb(theme: Theme, color: ThemeColor, amount = 0.17): Rgb | undefined {
|
|
159
230
|
const rgb = themeColorRgb(theme, color);
|
|
160
|
-
|
|
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);
|
|
231
|
+
return rgb && interpolateRgb(isDark(theme) ? BLACK : WHITE, rgb, amount);
|
|
167
232
|
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
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
|
|
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,
|
|
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):
|
|
75
|
+
export function thinkingLevelColor(level: ThinkingLevel): ThemeColor {
|
|
70
76
|
return THINKING_LEVEL_COLOR[level] ?? "thinkingMedium";
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
|
-
*
|
|
75
|
-
* the same
|
|
76
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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)
|
|
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,
|
|
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
|
/**
|
|
@@ -572,10 +572,14 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
572
572
|
// their rows, and told to extensions before Pi makes them.
|
|
573
573
|
pi.on("message_update", (event) => noteCalls(event.message));
|
|
574
574
|
pi.on("tool_execution_start", (event) => {
|
|
575
|
+
if (event.parentToolCallId) return runtime.noteNestedCall(event.toolCallId, true);
|
|
575
576
|
runtime.noteCall(event.toolCallId);
|
|
576
577
|
runtime.noteExecutionStart(event.toolCallId);
|
|
577
578
|
});
|
|
578
|
-
pi.on("tool_execution_end", (event) =>
|
|
579
|
+
pi.on("tool_execution_end", (event) => {
|
|
580
|
+
if (event.parentToolCallId) return runtime.noteNestedCall(event.toolCallId, false);
|
|
581
|
+
runtime.noteExecutionEnd(event.toolCallId);
|
|
582
|
+
});
|
|
579
583
|
pi.on("session_shutdown", (event) => {
|
|
580
584
|
thinkingCycle.dispose();
|
|
581
585
|
progress.dispose();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-compact-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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.
|
|
54
|
-
"@earendil-works/pi-tui": "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"
|
package/release-notes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"0.13.0": [
|
|
3
|
+
"Follows Pi 0.99's system theme and light terminals: rails, dots, and the working label take their colors from the theme itself",
|
|
4
|
+
"Running dots, rails, and silent mode's activity line are painted once per theme instead of every frame",
|
|
5
|
+
"The working label repaints when the theme changes, including when the terminal switches between dark and light",
|
|
6
|
+
"Tools a codemode script calls leave nothing behind in the rows' timings",
|
|
7
|
+
"Silent mode hides compaction and branch summaries that Pi writes mid-turn"
|
|
8
|
+
],
|
|
2
9
|
"0.12.0": [
|
|
3
10
|
"Running rows animate without being rebuilt: one repaint per frame, however many tools run",
|
|
4
11
|
"A tool's time counts from when it runs, not from when the model began writing its call",
|