pi-compact-tools 0.11.2 → 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 -21
- package/extensions/compact-tools-color.ts +154 -89
- package/extensions/compact-tools-grouping.ts +102 -20
- package/extensions/compact-tools-layout.ts +58 -2
- package/extensions/compact-tools-palette.ts +63 -36
- package/extensions/compact-tools-progress.ts +14 -4
- package/extensions/compact-tools-runtime.ts +127 -23
- package/extensions/compact-tools-silent.ts +17 -2
- package/extensions/compact-tools-types.ts +2 -0
- package/extensions/compact-tools.ts +77 -14
- package/package.json +3 -3
- package/release-notes.json +14 -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,21 +109,25 @@ export function frameChanges(frame: number): boolean {
|
|
|
109
109
|
return FRAME_CHANGED[frameIndex(frame)]!;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
const glowCache = new WeakMap<Theme, Map<ThinkingLevel, ColorRamp | null>>();
|
|
112
|
+
type PaintedBreath = readonly (readonly string[])[];
|
|
114
113
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
/**
|
|
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.
|
|
117
|
+
*/
|
|
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);
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
return byLevel.get(level) ?? undefined;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function toneColor(glow: ColorRamp, intensity: number): Rgb {
|
|
126
|
-
return interpolateRgb(glow.from, glow.to, intensity ** TONE_GAMMA);
|
|
130
|
+
return breath;
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
/** A line the light runs along with a fading afterimage, in the thinking level's color, fitted to the width. */
|
|
@@ -137,12 +141,7 @@ export function renderActivityDots(
|
|
|
137
141
|
if (width <= 0) return [];
|
|
138
142
|
const indent = Math.max(0, Math.min(column, width - 1));
|
|
139
143
|
const fitting = Math.min(DOT_COUNT, width - indent);
|
|
140
|
-
|
|
141
|
-
const dots = FRAMES[frameIndex(frame)]!.slice(0, fitting).map((intensity) => glow
|
|
142
|
-
? colorizeRgb(theme, toneColor(glow, intensity), DOT)
|
|
143
|
-
// Reached only when the theme's colors did not resolve.
|
|
144
|
-
: theme.fg(intensity > 0.5 ? "text" : "dim", DOT));
|
|
145
|
-
return [" ".repeat(indent) + dots.join("")];
|
|
144
|
+
return [" ".repeat(indent) + paintedBreath(theme, level)[frameIndex(frame)]!.slice(0, fitting).join("")];
|
|
146
145
|
}
|
|
147
146
|
|
|
148
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
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ToolExecutionComponent,
|
|
4
4
|
type AgentToolResult,
|
|
5
5
|
type ExtensionContext,
|
|
6
|
+
type Theme,
|
|
6
7
|
} from "@earendil-works/pi-coding-agent";
|
|
7
8
|
import { Container, Spacer, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
8
9
|
import { claudeFailure, lookHint, lookKind, pathOf, type LookKind } from "./compact-tools-claude.ts";
|
|
@@ -39,8 +40,18 @@ export interface ToolRowLike {
|
|
|
39
40
|
invalidate(): void;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
/** Whether a call without its result can still get one; the row itself answers the same way. */
|
|
44
|
+
export type CanRun = (row: ToolRowLike) => boolean;
|
|
45
|
+
|
|
46
|
+
const ALWAYS_RUNS: CanRun = () => true;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A call with no result yet runs, unless it can no longer get one: restored from
|
|
50
|
+
* the session without it, or left without it when its run ended. Then it waits,
|
|
51
|
+
* as its row shows.
|
|
52
|
+
*/
|
|
53
|
+
export function memberStatus(row: ToolRowLike, canRun: CanRun = ALWAYS_RUNS): RowStatus {
|
|
54
|
+
if (!row.result || row.isPartial) return canRun(row) ? "running" : "pending";
|
|
44
55
|
return row.result.isError ? "error" : "success";
|
|
45
56
|
}
|
|
46
57
|
|
|
@@ -60,9 +71,9 @@ function kindOf(row: ToolRowLike): LookKind | undefined {
|
|
|
60
71
|
return kind;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
function lastRunning(rows: readonly ToolRowLike[]): ToolRowLike | undefined {
|
|
74
|
+
function lastRunning(rows: readonly ToolRowLike[], canRun: CanRun): ToolRowLike | undefined {
|
|
64
75
|
for (let index = rows.length - 1; index >= 0; index--) {
|
|
65
|
-
if (memberStatus(rows[index]
|
|
76
|
+
if (memberStatus(rows[index]!, canRun) === "running") return rows[index];
|
|
66
77
|
}
|
|
67
78
|
return undefined;
|
|
68
79
|
}
|
|
@@ -89,9 +100,13 @@ export function countGroup(rows: readonly ToolRowLike[]): Record<LookKind, numbe
|
|
|
89
100
|
* "Searched for 2 patterns, read 3 files". While any call in the group still runs,
|
|
90
101
|
* every verb reads in progress. `bold` marks the counts, as Claude Code does.
|
|
91
102
|
*/
|
|
92
|
-
export function summarizeGroup(
|
|
103
|
+
export function summarizeGroup(
|
|
104
|
+
rows: readonly ToolRowLike[],
|
|
105
|
+
bold: (text: string) => string = (text) => text,
|
|
106
|
+
canRun: CanRun = ALWAYS_RUNS,
|
|
107
|
+
): string {
|
|
93
108
|
const counts = countGroup(rows);
|
|
94
|
-
const running = lastRunning(rows) !== undefined;
|
|
109
|
+
const running = lastRunning(rows, canRun) !== undefined;
|
|
95
110
|
const text = PHRASES.filter((phrase) => counts[phrase.kind] > 0)
|
|
96
111
|
.map((phrase) => {
|
|
97
112
|
const count = counts[phrase.kind];
|
|
@@ -102,8 +117,8 @@ export function summarizeGroup(rows: readonly ToolRowLike[], bold: (text: string
|
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
/** While a call runs, the line under its group names what it looks at. */
|
|
105
|
-
export function groupHint(rows: readonly ToolRowLike[]): string | undefined {
|
|
106
|
-
const running = lastRunning(rows);
|
|
120
|
+
export function groupHint(rows: readonly ToolRowLike[], canRun: CanRun = ALWAYS_RUNS): string | undefined {
|
|
121
|
+
const running = lastRunning(rows, canRun);
|
|
107
122
|
if (!running) return undefined;
|
|
108
123
|
return lookHint(running.toolName, argsOf(running)) || undefined;
|
|
109
124
|
}
|
|
@@ -114,7 +129,33 @@ export function groupFailures(rows: readonly ToolRowLike[]): string[] {
|
|
|
114
129
|
.map((row) => claudeFailure(row.toolName, getTextResult(row.result!)).headline);
|
|
115
130
|
}
|
|
116
131
|
|
|
117
|
-
|
|
132
|
+
/**
|
|
133
|
+
* A finished group's lines, and everything they were drawn from. Only a group with
|
|
134
|
+
* no running row is kept, so every row in it has its final result.
|
|
135
|
+
*/
|
|
136
|
+
type HeaderCache = {
|
|
137
|
+
paint: string;
|
|
138
|
+
width: number;
|
|
139
|
+
expanded: boolean;
|
|
140
|
+
rows: readonly ToolRowLike[];
|
|
141
|
+
args: unknown[];
|
|
142
|
+
results: unknown[];
|
|
143
|
+
lines: string[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type GroupState = { expanded: boolean; lastHostExpanded: boolean; header?: HeaderCache };
|
|
147
|
+
|
|
148
|
+
/** The colors a group line is drawn in, resolved once per frame rather than once per group. */
|
|
149
|
+
type Paint = { theme: Theme; chrome: (text: string) => string; key: string };
|
|
150
|
+
|
|
151
|
+
/** Theme colors a group line uses; a theme that changes any of them draws the lines anew. */
|
|
152
|
+
const PAINT_COLORS = ["toolTitle", "error", "success", "muted", "dim", "text", "borderMuted"] as const;
|
|
153
|
+
|
|
154
|
+
function headerStill(cache: HeaderCache, rows: readonly ToolRowLike[], paint: string, width: number, expanded: boolean): boolean {
|
|
155
|
+
if (cache.paint !== paint || cache.width !== width || cache.expanded !== expanded || cache.rows.length !== rows.length) return false;
|
|
156
|
+
return rows.every((row, index) => row === cache.rows[index] && row.args === cache.args[index]
|
|
157
|
+
&& row.result === cache.results[index]);
|
|
158
|
+
}
|
|
118
159
|
|
|
119
160
|
/** A run of tool rows drawn as one summary line, which a click or Ctrl+O opens into the rows themselves. */
|
|
120
161
|
export class ToolGroupComponent extends Container {
|
|
@@ -212,6 +253,10 @@ export function groupChildren(
|
|
|
212
253
|
export class ToolGroupController {
|
|
213
254
|
private context: ExtensionContext | undefined;
|
|
214
255
|
private readonly states = new WeakMap<object, GroupState>();
|
|
256
|
+
/** This frame's colors, set as the chat starts drawing. */
|
|
257
|
+
private paint: Paint | undefined;
|
|
258
|
+
/** Asks the runtime, which knows which run each call belongs to, as the call's own row does. */
|
|
259
|
+
private readonly canRun: CanRun = (row) => this.runtime.canRun(row.toolCallId);
|
|
215
260
|
|
|
216
261
|
constructor(private readonly runtime: ToolRuntime) {}
|
|
217
262
|
|
|
@@ -225,11 +270,13 @@ export class ToolGroupController {
|
|
|
225
270
|
hookMethod(chat, "render", "claude.groups", (self, args, original) => {
|
|
226
271
|
if (!this.context || isSilent() || this.runtime.config.style !== "claude") return original.apply(self, args);
|
|
227
272
|
const children = self.children as unknown[];
|
|
273
|
+
this.paint = this.resolvePaint();
|
|
228
274
|
self.children = groupChildren(children, (rows, members) => this.makeGroup(rows, members, tui));
|
|
229
275
|
try {
|
|
230
276
|
return original.apply(self, args);
|
|
231
277
|
} finally {
|
|
232
278
|
self.children = children;
|
|
279
|
+
this.paint = undefined;
|
|
233
280
|
}
|
|
234
281
|
});
|
|
235
282
|
}
|
|
@@ -249,8 +296,40 @@ export class ToolGroupController {
|
|
|
249
296
|
state = { expanded: key.expanded, lastHostExpanded: key.expanded };
|
|
250
297
|
this.states.set(key, state);
|
|
251
298
|
}
|
|
252
|
-
|
|
253
|
-
|
|
299
|
+
const groupState = state;
|
|
300
|
+
return new ToolGroupComponent(rows, members, state,
|
|
301
|
+
(group, width, expanded) => this.drawCached(group, width, expanded, groupState), () => tui.requestRender());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private resolvePaint(): Paint | undefined {
|
|
305
|
+
const theme = this.context?.ui.theme;
|
|
306
|
+
if (!theme) return undefined;
|
|
307
|
+
const chrome = chromePainter(theme);
|
|
308
|
+
const key = PAINT_COLORS.map((color) => theme.fg(color, "x")).join("") + theme.bold("x") + chrome("x");
|
|
309
|
+
return { theme, chrome, key };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A finished group draws the same lines until one of its rows, the width, the
|
|
314
|
+
* expansion, or the theme changes, so those lines are kept. A running group's
|
|
315
|
+
* line pulses and counts time, so it is drawn every frame.
|
|
316
|
+
*/
|
|
317
|
+
private drawCached(rows: readonly ToolRowLike[], width: number, expanded: boolean, state: GroupState): string[] {
|
|
318
|
+
const paint = this.paint ?? this.resolvePaint();
|
|
319
|
+
if (!paint) return [];
|
|
320
|
+
if (lastRunning(rows, this.canRun)) return this.draw(rows, width, expanded, paint);
|
|
321
|
+
if (state.header && headerStill(state.header, rows, paint.key, width, expanded)) return state.header.lines;
|
|
322
|
+
const lines = this.draw(rows, width, expanded, paint);
|
|
323
|
+
state.header = {
|
|
324
|
+
paint: paint.key,
|
|
325
|
+
width,
|
|
326
|
+
expanded,
|
|
327
|
+
rows: [...rows],
|
|
328
|
+
args: rows.map((row) => row.args),
|
|
329
|
+
results: rows.map((row) => row.result),
|
|
330
|
+
lines,
|
|
331
|
+
};
|
|
332
|
+
return lines;
|
|
254
333
|
}
|
|
255
334
|
|
|
256
335
|
/**
|
|
@@ -258,23 +337,26 @@ export class ToolGroupController {
|
|
|
258
337
|
* the title style. While it runs the dot pulses and the line below names what it
|
|
259
338
|
* looks at.
|
|
260
339
|
*/
|
|
261
|
-
private draw(rows: readonly ToolRowLike[], width: number, expanded: boolean): string[] {
|
|
262
|
-
const theme =
|
|
263
|
-
if (!theme) return [];
|
|
264
|
-
const chrome = chromePainter(theme);
|
|
340
|
+
private draw(rows: readonly ToolRowLike[], width: number, expanded: boolean, paint: Paint): string[] {
|
|
341
|
+
const { theme, chrome } = paint;
|
|
265
342
|
const fit = (line: string) => truncateToWidth(line, Math.max(1, width), "…");
|
|
266
|
-
const running = lastRunning(rows);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
343
|
+
const running = lastRunning(rows, this.canRun);
|
|
344
|
+
const statuses = rows.map((row) => memberStatus(row, this.canRun));
|
|
345
|
+
// A call that waits for a result it will never get leaves the group waiting too.
|
|
346
|
+
const status: RowStatus = running ? "running" : statuses.includes("error") ? "error"
|
|
347
|
+
: statuses.includes("pending") ? "pending" : "success";
|
|
348
|
+
const summary = summarizeGroup(rows, undefined, this.canRun);
|
|
349
|
+
let title = `${paintIndicator(theme, status, this.runtime.frame)} ${theme.fg("toolTitle", theme.bold(summary))}`;
|
|
270
350
|
if (running) {
|
|
351
|
+
// The folded row is not drawn, so the line that pulses for it keeps the clock going.
|
|
352
|
+
this.runtime.keepAnimating(running.toolCallId);
|
|
271
353
|
const started = this.runtime.timing(running.toolCallId)?.startedAt;
|
|
272
354
|
const elapsed = started === undefined ? 0 : Date.now() - started;
|
|
273
355
|
title += `${elapsed >= 2000 ? chrome(` · ${formatDurationMs(elapsed)}`) : ""}…`;
|
|
274
356
|
}
|
|
275
357
|
if (!expanded) title += chrome(` ${EXPAND_HINT}`);
|
|
276
358
|
const lines = ["", fit(` ${title}`)];
|
|
277
|
-
const hintText = groupHint(rows);
|
|
359
|
+
const hintText = groupHint(rows, this.canRun);
|
|
278
360
|
if (hintText) lines.push(fit(chrome(` └ ${hintText}`)));
|
|
279
361
|
if (!expanded) for (const failure of groupFailures(rows)) lines.push(fit(chrome(" └ ") + theme.fg("error", failure)));
|
|
280
362
|
return lines;
|
|
@@ -39,13 +39,24 @@ class CachedComponent implements Component {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* A Container that does not re-copy all child lines on every fullscreen scroll
|
|
44
|
+
* frame. Its lines are kept for a width, and for whatever else `cacheKey()`
|
|
45
|
+
* says they depend on, until a child is added, removed, or invalidated.
|
|
46
|
+
*/
|
|
43
47
|
export class CachedContainer extends Container {
|
|
44
48
|
private cachedWidth?: number;
|
|
49
|
+
private cachedKey?: unknown;
|
|
45
50
|
private cachedLines?: string[];
|
|
46
51
|
|
|
52
|
+
/** What besides the width the lines depend on, read as each frame is drawn. */
|
|
53
|
+
protected cacheKey(): unknown {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
private clearRenderCache(): void {
|
|
48
58
|
this.cachedWidth = undefined;
|
|
59
|
+
this.cachedKey = undefined;
|
|
49
60
|
this.cachedLines = undefined;
|
|
50
61
|
}
|
|
51
62
|
|
|
@@ -65,8 +76,10 @@ export class CachedContainer extends Container {
|
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
override render(width: number): string[] {
|
|
68
|
-
|
|
79
|
+
const key = this.cacheKey();
|
|
80
|
+
if (this.cachedWidth !== width || this.cachedKey !== key || !this.cachedLines) {
|
|
69
81
|
this.cachedWidth = width;
|
|
82
|
+
this.cachedKey = key;
|
|
70
83
|
this.cachedLines = super.render(width);
|
|
71
84
|
}
|
|
72
85
|
return this.cachedLines;
|
|
@@ -78,6 +91,49 @@ export class CachedContainer extends Container {
|
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A call line whose status dot is painted when the row is drawn rather than when
|
|
96
|
+
* it was built. The dot follows the row's state and the animation frame without
|
|
97
|
+
* Pi rebuilding the row, and a line is laid out once for each dot it has shown.
|
|
98
|
+
*/
|
|
99
|
+
export class LiveCallContainer extends CachedContainer {
|
|
100
|
+
private readonly titles = new Map<string, Component>();
|
|
101
|
+
private indicator = "";
|
|
102
|
+
private readonly titleLine: Component = {
|
|
103
|
+
render: (width) => this.title().render(width),
|
|
104
|
+
invalidate: () => this.titles.clear(),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly paintIndicator: () => string,
|
|
109
|
+
private readonly renderTitle: (indicator: string) => Component,
|
|
110
|
+
) {
|
|
111
|
+
super();
|
|
112
|
+
super.addChild(this.titleLine);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Clearing removes what was added beneath the call line; the line itself stays. */
|
|
116
|
+
override clear(): void {
|
|
117
|
+
super.clear();
|
|
118
|
+
super.addChild(this.titleLine);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The dot, painted now; the title drawn beneath it follows. */
|
|
122
|
+
protected override cacheKey(): string {
|
|
123
|
+
this.indicator = this.paintIndicator();
|
|
124
|
+
return this.indicator;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private title(): Component {
|
|
128
|
+
let title = this.titles.get(this.indicator);
|
|
129
|
+
if (!title) {
|
|
130
|
+
title = this.renderTitle(this.indicator);
|
|
131
|
+
this.titles.set(this.indicator, title);
|
|
132
|
+
}
|
|
133
|
+
return title;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
81
137
|
export function prefixedText(text: string, firstPrefix: string, continuationPrefix = firstPrefix): Component {
|
|
82
138
|
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
|
|
83
139
|
const normalized = text.replace(/\t/g, " ");
|