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.
@@ -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, 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,21 +109,25 @@ export function frameChanges(frame: number): boolean {
109
109
  return FRAME_CHANGED[frameIndex(frame)]!;
110
110
  }
111
111
 
112
- // Resolving a ramp walks the theme's colors; it only changes with the theme or the level.
113
- const glowCache = new WeakMap<Theme, Map<ThinkingLevel, ColorRamp | null>>();
112
+ type PaintedBreath = readonly (readonly string[])[];
114
113
 
115
- function cachedGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
116
- let byLevel = glowCache.get(theme);
117
- if (!byLevel) {
118
- byLevel = new Map();
119
- glowCache.set(theme, byLevel);
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
- if (!byLevel.has(level)) byLevel.set(level, activityGlow(theme, level) ?? null);
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
- const glow = cachedGlow(theme, level);
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
- const ANSI_BASIC_RGB: readonly Rgb[] = [
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 ansi256ToRgb(index: number): Rgb {
14
- if (index < 16) return ANSI_BASIC_RGB[index] ?? { r: 192, g: 192, b: 192 };
60
+ function indexedRgb(index: number): Rgb {
61
+ if (index < 16) return BASIC_RGB[index] ?? BASIC_RGB[7]!;
15
62
  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
- };
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 = 8 + Math.min(23, index - 232) * 10;
66
+ const gray = GRAYS[Math.min(23, index - 232)]!;
25
67
  return { r: gray, g: gray, b: gray };
26
68
  }
27
69
 
28
- const ANSI_256_RGB: readonly Rgb[] = Array.from({ length: 256 }, (_, index) => ansi256ToRgb(index));
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 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.
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
- 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;
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
- 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;
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
- 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;
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
- 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. */
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 = 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);
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 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.
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 = 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);
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: ThemeForeground, amount = 0.17): Rgb | undefined {
229
+ export function diffTintRgb(theme: Theme, color: ThemeColor, amount = 0.17): Rgb | undefined {
159
230
  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);
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
- export function memberStatus(row: ToolRowLike): RowStatus {
43
- if (!row.result || row.isPartial) return "running";
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]!) === "running") return 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(rows: readonly ToolRowLike[], bold: (text: string) => string = (text) => text): string {
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
- type GroupState = { expanded: boolean; lastHostExpanded: boolean };
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
- return new ToolGroupComponent(rows, members, state, (group, width, expanded) => this.draw(group, width, expanded),
253
- () => tui.requestRender());
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 = this.context?.ui.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 failed = rows.some((row) => memberStatus(row) === "error");
268
- const status: RowStatus = running ? "running" : failed ? "error" : "success";
269
- let title = `${paintIndicator(theme, status, this.runtime.frame)} ${theme.fg("toolTitle", theme.bold(summarizeGroup(rows)))}`;
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
- /** A Container that does not re-copy all child lines on every fullscreen scroll frame. */
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
- if (this.cachedWidth !== width || !this.cachedLines) {
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, " ");