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,10 +1,18 @@
1
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
- import { indicatorGlyph, indicatorStrength, indicatorTone, type RowStatus } from "./compact-tools-core.ts";
1
+ import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ indicatorGlyph,
4
+ indicatorStrength,
5
+ indicatorTone,
6
+ RUNNING_INDICATOR_FRAME_COUNT,
7
+ type RowStatus,
8
+ } from "./compact-tools-core.ts";
3
9
  import {
4
10
  colorizeRgb,
5
11
  interpolateRgb,
6
12
  liftContrast,
7
13
  neutralizeRgb,
14
+ paletteOf,
15
+ rgbPainter,
8
16
  themeColorRgb,
9
17
  withContrast,
10
18
  type Rgb,
@@ -24,9 +32,7 @@ import {
24
32
  * background the moves invert, and standing out means going darker.
25
33
  */
26
34
 
27
- type ThemeForeground = Parameters<Theme["getFgAnsi"]>[0];
28
-
29
- type ContrastBand = { color: ThemeForeground; keepHue: number; minimum: number; maximum: number };
35
+ type ContrastBand = { color: ThemeColor; keepHue: number; minimum: number; maximum: number };
30
36
 
31
37
  /** A band with no color attached, for a derivation that supplies its own source. */
32
38
  type ContrastRange = { minimum: number; maximum: number };
@@ -63,32 +69,40 @@ const THINKING_LEVEL_COLOR = {
63
69
  high: "thinkingHigh",
64
70
  xhigh: "thinkingXhigh",
65
71
  max: "thinkingMax",
66
- } as const satisfies Record<ThinkingLevel, ThemeForeground>;
72
+ } as const satisfies Record<ThinkingLevel, ThemeColor>;
67
73
 
68
74
  /** The theme color a thinking level is painted with. */
69
- function thinkingLevelColor(level: ThinkingLevel): ThemeForeground {
75
+ export function thinkingLevelColor(level: ThinkingLevel): ThemeColor {
70
76
  return THINKING_LEVEL_COLOR[level] ?? "thinkingMedium";
71
77
  }
72
78
 
73
79
  /**
74
- * Derivation is deliberately not cached against the Theme: `/theme` can swap a palette behind
75
- * the same instance, and a stale entry would outlive the switch. Callers that redraw per line
76
- * hold the derived value themselves instead.
80
+ * Derived colors, kept for as long as the palette they came from. `/theme` swaps the
81
+ * palette behind the same Theme, so the palette rather than the Theme is the key: a
82
+ * switch misses the cache by itself, and every frame in between is a lookup.
77
83
  */
84
+ const derivedByPalette = new WeakMap<object, Map<string, unknown>>();
85
+
86
+ export function perPalette<T>(theme: Theme, key: string, derive: () => T): T {
87
+ const palette = paletteOf(theme);
88
+ let cache = derivedByPalette.get(palette);
89
+ if (!cache) derivedByPalette.set(palette, cache = new Map());
90
+ if (!cache.has(key)) cache.set(key, derive());
91
+ return cache.get(key) as T;
92
+ }
93
+
78
94
  function bandedRgb(theme: Theme, band: ContrastBand): Rgb | undefined {
79
95
  const rgb = themeColorRgb(theme, band.color);
80
96
  if (!rgb) return undefined;
81
97
  return withContrast(theme, neutralizeRgb(rgb, band.keepHue), band.minimum, band.maximum);
82
98
  }
83
99
 
84
- /**
85
- * A chrome painter bound to one theme. Deriving the tone costs a parse and a dozen floating
86
- * point operations, and the frame is drawn once per line of tool output or thinking detail,
87
- * so callers in a loop take a painter once and callers with a single rail use `paintChrome`.
88
- */
100
+ /** A painter for the chrome tone, derived once per palette however many rails a frame draws. */
89
101
  export function chromePainter(theme: Theme): (text: string) => string {
90
- const chrome = bandedRgb(theme, CHROME);
91
- return chrome ? (text) => colorizeRgb(theme, chrome, text) : (text) => theme.fg("dim", text);
102
+ return perPalette(theme, "chrome", () => {
103
+ const chrome = bandedRgb(theme, CHROME);
104
+ return chrome ? rgbPainter(theme, chrome) : (text: string) => theme.fg("dim", text);
105
+ });
92
106
  }
93
107
 
94
108
  /** Paint one rail, connector, or status line in the chrome tone. */
@@ -104,18 +118,27 @@ function ramp(theme: Theme, low: ContrastBand, high: ContrastBand): ColorRamp |
104
118
  return from && to ? { from, to } : undefined;
105
119
  }
106
120
 
107
- /** Fade endpoints for the running tool indicator, or undefined when the theme resolves no RGB. */
108
- export function indicatorPulse(theme: Theme): ColorRamp | undefined {
109
- return ramp(theme, PULSE_LOW, PULSE_HIGH);
121
+ /**
122
+ * A row's status dot; a running one pulses between the theme's endpoints as frames advance.
123
+ * Every running row repaints its dot on every frame, so the pulse is painted once per palette.
124
+ */
125
+ export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
126
+ if (status !== "running") return theme.fg(indicatorTone(status), indicatorGlyph(status));
127
+ const pulse = runningPulse(theme);
128
+ return pulse[((frame % pulse.length) + pulse.length) % pulse.length]!;
110
129
  }
111
130
 
112
- /** A row's status dot; a running one pulses between the theme's endpoints as frames advance. */
113
- export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
114
- const glyph = indicatorGlyph(status, frame);
115
- const pulse = status === "running" ? indicatorPulse(theme) : undefined;
116
- return pulse
117
- ? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength(status, frame)), glyph)
118
- : theme.fg(indicatorTone(status, frame), glyph);
131
+ /** Every step of the running dot's pulse, painted once per palette. */
132
+ function runningPulse(theme: Theme): readonly string[] {
133
+ return perPalette(theme, "running-pulse", () => {
134
+ const pulse = ramp(theme, PULSE_LOW, PULSE_HIGH);
135
+ return Array.from({ length: RUNNING_INDICATOR_FRAME_COUNT }, (_, step) => {
136
+ const glyph = indicatorGlyph("running", step);
137
+ return pulse
138
+ ? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength("running", step)), glyph)
139
+ : theme.fg(indicatorTone("running", step), glyph);
140
+ });
141
+ });
119
142
  }
120
143
 
121
144
  /** How far the brightest activity dot moves from the level's color toward the label's crest. */
@@ -129,17 +152,21 @@ const ACTIVITY_UNLIT = 0.05;
129
152
  * label's full crest washes every level toward the same near-white.
130
153
  */
131
154
  export function activityGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
132
- const glow = progressGlow(theme, level);
133
- if (!glow) return undefined;
134
- const lit = interpolateRgb(glow.from, glow.to, ACTIVITY_CREST_MIX);
135
- return { from: withContrast(theme, lit, ACTIVITY_UNLIT, ACTIVITY_UNLIT), to: lit };
155
+ return perPalette(theme, `activity:${level}`, () => {
156
+ const glow = progressGlow(theme, level);
157
+ if (!glow) return undefined;
158
+ const lit = interpolateRgb(glow.from, glow.to, ACTIVITY_CREST_MIX);
159
+ return { from: withContrast(theme, lit, ACTIVITY_UNLIT, ACTIVITY_UNLIT), to: lit };
160
+ });
136
161
  }
137
162
 
138
163
  /** Sweep endpoints for the working label at a thinking level, resting color to crest. */
139
164
  export function progressGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
140
- const raw = themeColorRgb(theme, thinkingLevelColor(level));
141
- if (!raw) return undefined;
142
- const lifted = liftContrast(theme, raw, GLOW_REST_LIFT);
143
- const from = withContrast(theme, lifted, GLOW_FLOOR.minimum, GLOW_FLOOR.maximum);
144
- return { from, to: liftContrast(theme, from, GLOW_CREST_LIFT) };
165
+ return perPalette(theme, `progress:${level}`, () => {
166
+ const raw = themeColorRgb(theme, thinkingLevelColor(level));
167
+ if (!raw) return undefined;
168
+ const lifted = liftContrast(theme, raw, GLOW_REST_LIFT);
169
+ const from = withContrast(theme, lifted, GLOW_FLOOR.minimum, GLOW_FLOOR.maximum);
170
+ return { from, to: liftContrast(theme, from, GLOW_CREST_LIFT) };
171
+ });
145
172
  }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { onTick } from "./compact-tools-clock.ts";
4
- import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
4
+ import { colorizeRgb, interpolateRgb, paletteOf } from "./compact-tools-color.ts";
5
5
  import { progressGlow, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
6
6
  import type { ToolArgs } from "./compact-tools-types.ts";
7
7
 
@@ -88,6 +88,8 @@ export class ProgressController {
88
88
  private readonly activeTools = new Map<string, string>();
89
89
  private message: string | undefined;
90
90
  private frames: string[] = [];
91
+ /** The palette `frames` were painted with. */
92
+ private paintedFor: object | undefined;
91
93
  private frame = 0;
92
94
  /** Stops this label's share of the animation clock. */
93
95
  private stopTicking: (() => void) | undefined;
@@ -134,7 +136,7 @@ export class ProgressController {
134
136
  if (!this.context || event.level === this.level) return;
135
137
  this.level = event.level;
136
138
  // Recolor in place: the label and its animation phase are unchanged.
137
- this.frames = createGlowFrames(this.message ?? "", this.context.ui.theme, this.level);
139
+ this.frames = [];
138
140
  this.renderMessage();
139
141
  });
140
142
  }
@@ -169,7 +171,7 @@ export class ProgressController {
169
171
  private setMessage(message: string): void {
170
172
  if (!this.context || (this.message === message && this.stopTicking)) return;
171
173
  this.message = message;
172
- this.frames = createGlowFrames(message, this.context.ui.theme, this.level);
174
+ this.frames = [];
173
175
  this.frame = 0;
174
176
  this.renderMessage();
175
177
  if (this.stopTicking) return;
@@ -180,7 +182,15 @@ export class ProgressController {
180
182
  }
181
183
 
182
184
  private renderMessage(): void {
183
- if (!this.message || !this.context || this.frames.length === 0) return;
185
+ if (!this.message || !this.context) return;
186
+ // Painted once per label, level, and palette: a theme switch, or the system theme
187
+ // following the terminal from dark to light, repaints on the next frame.
188
+ const theme = this.context.ui.theme;
189
+ const palette = paletteOf(theme);
190
+ if (this.frames.length === 0 || this.paintedFor !== palette) {
191
+ this.frames = createGlowFrames(this.message, theme, this.level);
192
+ this.paintedFor = palette;
193
+ }
184
194
  this.context.ui.setWorkingMessage(this.frames[this.frame % this.frames.length]);
185
195
  }
186
196
 
@@ -12,9 +12,16 @@ import {
12
12
 
13
13
  const MAX_TRACKED_ROWS = 2_000;
14
14
  const INDICATOR_INTERVAL_MS = 45;
15
+ /**
16
+ * How long a running row may go undrawn before it stops asking for frames. Pi draws
17
+ * every row it shows on every frame, so only a row that left the transcript, or
18
+ * never got its result, goes this long; it asks again the next time it is drawn.
19
+ */
20
+ const UNDRAWN_ROW_MS = 1_500;
15
21
  const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
16
22
 
17
23
  type ExecutionTiming = { startedAt: number; endedAt?: number };
24
+ type Indicator = { invalidate: () => void; drawnAt: number };
18
25
  type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
19
26
  type SharedState = typeof globalThis & {
20
27
  [EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
@@ -24,11 +31,26 @@ export class ToolRuntime {
24
31
  private configValue = DEFAULT_CONFIG;
25
32
  private configRevision: object = {};
26
33
  private readonly executionTimings: Map<string, ExecutionTiming>;
27
- private readonly indicatorInvalidators = new Map<string, () => void>();
34
+ private readonly indicators = new Map<string, Indicator>();
28
35
  private indicatorFrame = 0;
29
36
  private indicatorTimer: ReturnType<typeof setInterval> | undefined;
37
+ /** Pi's frame request, once bound to its terminal; rows then repaint without being rebuilt. */
38
+ private requestRender: (() => void) | undefined;
39
+ /** Whether the agent is working; undefined until the host says, which counts as working. */
40
+ private busy: boolean | undefined;
41
+ /**
42
+ * The calls Pi announced in the run going on. Only these can be running, so
43
+ * nothing about earlier calls has to be remembered, and the set never holds
44
+ * more than one run's calls.
45
+ */
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>();
30
52
 
31
- constructor() {
53
+ constructor(private readonly clock: () => number = () => performance.now()) {
32
54
  const shared = globalThis as SharedState;
33
55
  this.executionTimings = shared[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
34
56
  shared[EXECUTION_TIMINGS_KEY] = this.executionTimings;
@@ -62,24 +84,85 @@ export class ToolRuntime {
62
84
  if (clearTimings) this.clearTimings();
63
85
  }
64
86
 
87
+ /**
88
+ * Bind the animation to Pi's frame requests. A running dot is painted when its
89
+ * row is drawn, so a frame only has to be asked for, once, however many rows
90
+ * run. Unbound, each running row is rebuilt to repaint, as Pi does on its own.
91
+ */
92
+ bindRenderer(requestRender: (() => void) | undefined): void {
93
+ this.requestRender = requestRender;
94
+ }
95
+
96
+ /**
97
+ * The agent started or stopped working. Once it stops nothing can still be
98
+ * running, so a row that never got its result stops pulsing and waits instead.
99
+ */
100
+ setBusy(busy: boolean): void {
101
+ if (busy && this.busy !== true) {
102
+ this.currentCalls.clear();
103
+ this.nestedCalls.clear();
104
+ }
105
+ this.busy = busy;
106
+ if (busy) return;
107
+ const animating = this.indicators.size > 0;
108
+ this.stopIndicators();
109
+ if (animating) this.requestRender?.();
110
+ }
111
+
112
+ /**
113
+ * Pi announced a call of the run going on: its arguments began to stream, or it
114
+ * began to run. Pi tells extensions before it makes the call's row. The set is
115
+ * emptied as each run starts, so it holds one run's calls at most.
116
+ */
117
+ noteCall(toolCallId: string): void {
118
+ this.currentCalls.add(toolCallId);
119
+ }
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
+
127
+ /**
128
+ * Whether a call can be running: only one Pi announced in the run going on. A
129
+ * row restored from the session, one left without a result when its run ended,
130
+ * and one Pi made again for an old call when it rebuilt the transcript were
131
+ * never announced in this run; whatever they did is over. Until the host says
132
+ * whether the agent works, every call can run, as before.
133
+ */
134
+ canRun(toolCallId: string): boolean {
135
+ return this.busy === undefined || (this.busy && this.currentCalls.has(toolCallId));
136
+ }
137
+
65
138
  syncIndicator(toolCallId: string, running: boolean, invalidate: () => void): number {
66
139
  if (!running) {
67
140
  this.removeIndicator(toolCallId);
68
141
  return 0;
69
142
  }
70
- this.indicatorInvalidators.set(toolCallId, invalidate);
143
+ const indicator = this.indicators.get(toolCallId);
144
+ if (indicator) {
145
+ indicator.invalidate = invalidate;
146
+ indicator.drawnAt = this.clock();
147
+ } else {
148
+ this.indicators.set(toolCallId, { invalidate, drawnAt: this.clock() });
149
+ }
71
150
  if (!this.indicatorTimer) {
72
- this.indicatorTimer = setInterval(() => {
73
- this.indicatorFrame++;
74
- // Silent mode hides tool rows; repainting the screen for them would be pure cost.
75
- if (isSilent()) return;
76
- for (const requestRender of this.indicatorInvalidators.values()) requestRender();
77
- }, INDICATOR_INTERVAL_MS);
151
+ this.indicatorTimer = setInterval(() => this.tick(), INDICATOR_INTERVAL_MS);
78
152
  this.indicatorTimer.unref?.();
79
153
  }
80
154
  return this.indicatorFrame;
81
155
  }
82
156
 
157
+ /**
158
+ * A running row that is out of sight but still counts, such as one folded into
159
+ * a group whose line pulses for it, keeps the animation going.
160
+ */
161
+ keepAnimating(toolCallId: string): void {
162
+ const indicator = this.indicators.get(toolCallId);
163
+ if (indicator) indicator.drawnAt = this.clock();
164
+ }
165
+
83
166
  syncExpansion(state: RowState, hostExpanded: boolean, name: string): boolean {
84
167
  const initialized = this.initializeExpansion(state, name, hostExpanded);
85
168
  if (!initialized && state.lastHostExpanded !== hostExpanded) {
@@ -98,15 +181,18 @@ export class ToolRuntime {
98
181
  state.hasResult = available;
99
182
  }
100
183
 
101
- syncRow(
102
- ctx: RenderContext,
103
- running = ctx.executionStarted && ctx.state.endedAt === undefined,
104
- finished = false,
105
- ): RowState {
184
+ syncRow(ctx: RenderContext, finished = false): RowState {
106
185
  const state = ctx.state;
107
- if (this.restoreTiming(state, ctx.toolCallId)) running = false;
108
- const started = !ctx.argsComplete || ctx.executionStarted || running;
186
+ // Pi can rebuild the transcript mid-run, making new rows for old calls; a call
187
+ // keeps the run its first row was made in.
188
+ this.restoreTiming(state, ctx.toolCallId);
189
+ // A row is timed from when it runs, as Pi times its own: the model writing the
190
+ // arguments is not the tool's time. A restored row never ran in front of the
191
+ // reader, so it has no time to measure unless this process timed it before a
192
+ // /reload, and a call that never ran has none either.
193
+ const started = this.canRun(ctx.toolCallId) && ctx.executionStarted;
109
194
  if (started && state.startedAt === undefined) state.startedAt = Date.now();
195
+ if (finished) state.finished = true;
110
196
  if (finished && state.startedAt !== undefined && state.endedAt === undefined) state.endedAt = Date.now();
111
197
  if (finished) this.removeIndicator(ctx.toolCallId);
112
198
  this.persistTiming(state, ctx.toolCallId);
@@ -132,7 +218,7 @@ export class ToolRuntime {
132
218
  createTimedExecute(definition: BuiltInDefinition): TimedExecute {
133
219
  const execute = definition.execute as TimedExecute;
134
220
  return async (...args: any[]) => {
135
- const toolCallId = typeof args[0] === "string" ? args[0] : undefined;
221
+ const toolCallId = typeof args[0] === "string" && !this.nestedCalls.has(args[0]) ? args[0] : undefined;
136
222
  const startedAt = (toolCallId ? this.executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
137
223
  if (toolCallId && !this.executionTimings.has(toolCallId)) {
138
224
  this.executionTimings.set(toolCallId, { startedAt });
@@ -146,9 +232,28 @@ export class ToolRuntime {
146
232
  };
147
233
  }
148
234
 
235
+ private tick(): void {
236
+ this.indicatorFrame++;
237
+ const requestRender = this.requestRender;
238
+ if (requestRender) {
239
+ const oldest = this.clock() - UNDRAWN_ROW_MS;
240
+ for (const [toolCallId, indicator] of this.indicators) {
241
+ if (indicator.drawnAt < oldest) this.removeIndicator(toolCallId);
242
+ }
243
+ if (this.indicators.size === 0) return;
244
+ }
245
+ // Silent mode hides tool rows; repainting the screen for them would be pure cost.
246
+ if (isSilent()) return;
247
+ if (requestRender) {
248
+ requestRender();
249
+ return;
250
+ }
251
+ for (const { invalidate } of this.indicators.values()) invalidate();
252
+ }
253
+
149
254
  private removeIndicator(toolCallId: string): void {
150
- this.indicatorInvalidators.delete(toolCallId);
151
- if (this.indicatorInvalidators.size > 0 || !this.indicatorTimer) return;
255
+ this.indicators.delete(toolCallId);
256
+ if (this.indicators.size > 0 || !this.indicatorTimer) return;
152
257
  clearInterval(this.indicatorTimer);
153
258
  this.indicatorTimer = undefined;
154
259
  this.indicatorFrame = 0;
@@ -157,7 +262,7 @@ export class ToolRuntime {
157
262
  private stopIndicators(): void {
158
263
  if (this.indicatorTimer) clearInterval(this.indicatorTimer);
159
264
  this.indicatorTimer = undefined;
160
- this.indicatorInvalidators.clear();
265
+ this.indicators.clear();
161
266
  this.indicatorFrame = 0;
162
267
  }
163
268
 
@@ -177,12 +282,11 @@ export class ToolRuntime {
177
282
  return true;
178
283
  }
179
284
 
180
- private restoreTiming(state: RowState, toolCallId: string): boolean {
285
+ private restoreTiming(state: RowState, toolCallId: string): void {
181
286
  const timing = this.executionTimings.get(toolCallId);
182
- if (!timing) return false;
287
+ if (!timing) return;
183
288
  state.startedAt = timing.startedAt;
184
289
  state.endedAt = timing.endedAt;
185
- return timing.endedAt !== undefined;
186
290
  }
187
291
 
188
292
  private persistTiming(state: RowState, toolCallId: string): void {
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  AssistantMessageComponent,
3
+ BranchSummaryMessageComponent,
4
+ CompactionSummaryMessageComponent,
3
5
  CustomMessageComponent,
4
6
  DynamicBorder,
5
7
  ToolExecutionComponent,
@@ -118,12 +120,25 @@ export function renderAnswerOnly(component: AssistantLike, width: number, render
118
120
  }
119
121
  }
120
122
 
123
+ /**
124
+ * A summary Pi writes into the chat of its own accord: the context it compacted
125
+ * away, or the /tree branch it left behind. Both are bookkeeping rather than a
126
+ * prompt or an answer, and compaction arrives mid-turn, where a visible row
127
+ * would come between the animating activity line and the editor below it.
128
+ * Read through `typeof` so an older Pi without the export keeps its own rows.
129
+ */
130
+ function isSummary(child: unknown): boolean {
131
+ return (typeof CompactionSummaryMessageComponent === "function" && isKind(child, CompactionSummaryMessageComponent))
132
+ || (typeof BranchSummaryMessageComponent === "function" && isKind(child, BranchSummaryMessageComponent));
133
+ }
134
+
121
135
  /**
122
136
  * A row Pi (or this extension) adds to the chat directly rather than as a message:
123
- * status lines are Text, and "What's New" blocks add borders and Markdown too.
137
+ * status lines are Text, "What's New" blocks add borders and Markdown too, and
138
+ * compaction and branch summaries come as components of their own.
124
139
  */
125
140
  function isNotice(child: unknown): boolean {
126
- return isKind(child, Text) || isKind(child, DynamicBorder) || isKind(child, Markdown);
141
+ return isKind(child, Text) || isKind(child, DynamicBorder) || isKind(child, Markdown) || isSummary(child);
127
142
  }
128
143
 
129
144
  /**
@@ -37,6 +37,8 @@ export interface RowState {
37
37
  hasResult?: boolean;
38
38
  lastHostExpanded?: boolean;
39
39
  configRevision?: object;
40
+ /** The row has its final result, whether or not it was timed. */
41
+ finished?: boolean;
40
42
  startedAt?: number;
41
43
  endedAt?: number;
42
44
  resultLineSummary?: string;
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AgentToolResult,
3
3
  ExtensionAPI,
4
+ ExtensionContext,
4
5
  Theme,
5
6
  ToolDefinition,
6
7
  ToolRenderResultOptions,
@@ -37,6 +38,7 @@ import {
37
38
  import {
38
39
  CachedContainer,
39
40
  claudeRows,
41
+ LiveCallContainer,
40
42
  limitComponentLines,
41
43
  prefixedLines,
42
44
  prefixedText,
@@ -72,6 +74,7 @@ const PREVIEW_DIFF_CONTEXT_LINES = 1;
72
74
  const CLAUDE_INDENT = " ";
73
75
 
74
76
  const runtime = new ToolRuntime();
77
+ const RENDER_WIDGET_KEY = "compact-tools-render";
75
78
  const registeredTools = new Set<CompactToolName>();
76
79
  let registeredConfiguration: string | undefined;
77
80
 
@@ -95,13 +98,25 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
95
98
  // write/edit arguments can stream for much longer than their eventual filesystem
96
99
  // operation. Treat that active tool-call phase as running so every built-in row
97
100
  // animates consistently instead of waiting for execute() to begin.
98
- const active = ctx.executionStarted || !ctx.argsComplete;
99
- return classifyCallStatus(ctx.isError, active, state.endedAt !== undefined);
101
+ const active = runtime.canRun(ctx.toolCallId) && (ctx.executionStarted || !ctx.argsComplete);
102
+ return classifyCallStatus(ctx.isError, active, state.finished === true || state.endedAt !== undefined);
100
103
  }
101
104
 
102
- function renderIndicator(theme: Theme, ctx: RenderContext, state: RowState): string {
103
- const status = callStatus(ctx, state);
104
- return paintIndicator(theme, status, runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()));
105
+ /**
106
+ * The status dot, painted each time the row is drawn. Pi draws a call before its
107
+ * result, so a dot painted with the call would miss the result that arrived with
108
+ * it; reading the row's state at draw time sees it. A settled dot never changes.
109
+ */
110
+ function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState): () => string {
111
+ let settled: string | undefined;
112
+ return () => {
113
+ if (settled !== undefined) return settled;
114
+ const status = callStatus(ctx, state);
115
+ const indicator = paintIndicator(theme, status,
116
+ runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()));
117
+ if (status === "success" || status === "error") settled = indicator;
118
+ return indicator;
119
+ };
105
120
  }
106
121
 
107
122
  function formatDuration(state: RowState): string | undefined {
@@ -170,22 +185,24 @@ function hasNestedArguments(args: ToolArgs): boolean {
170
185
 
171
186
  /** The call line: a status dot, the tool, and what it was asked, then any arguments the line leaves out. */
172
187
  function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderContext): Component {
173
- const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
188
+ const state = runtime.syncRow(ctx);
174
189
  runtime.syncExpansion(state, ctx.expanded, name);
175
190
  const kind = rowKind(name);
176
- const container = new CachedContainer();
191
+ const indicator = liveIndicator(theme, ctx, state);
192
+ // Painted once now as well, so a row that starts running starts the animation clock.
193
+ indicator();
177
194
  if (isClaude()) {
178
195
  // Claude Code's call line: `⦁ Bash(npm test)`, `⦁ Update(src/app.ts)`.
179
196
  const { label, argument } = claudeTitle(name, args, state.expanded === true);
180
- const title = `${renderIndicator(theme, ctx, state)} ${theme.fg("toolTitle", theme.bold(label))}`
197
+ const title = ` ${theme.fg("toolTitle", theme.bold(label))}`
181
198
  + (argument ? styleMultiline(`(${argument})`, (line) => theme.fg("toolOutput", line)) : "");
182
199
  // The title wraps rather than being cut to the row; only a very long command is shortened.
183
- container.addChild(renderToolCall(title, undefined, theme));
200
+ const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, undefined, theme));
184
201
  // The title already lists plain arguments; only nested ones need the full view.
185
202
  if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme));
186
203
  return container;
187
204
  }
188
- const title = `${renderIndicator(theme, ctx, state)} ${theme.fg("toolTitle", theme.bold(name))}`;
205
+ const title = ` ${theme.fg("toolTitle", theme.bold(name))}`;
189
206
  let details: string | undefined;
190
207
  if (kind === "shell") {
191
208
  const command = normalizeLineEndings(typeof args.command === "string" ? args.command : "");
@@ -196,7 +213,7 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
196
213
  const summary = kind === "file" ? getCallDetails(name, args) : summarizeCustomArguments(args);
197
214
  details = summary ? styleMultiline(summary, (line) => theme.fg("toolOutput", line)) : undefined;
198
215
  }
199
- container.addChild(renderToolCall(title, details, theme));
216
+ const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, details, theme));
200
217
  const extra = kind === "file" ? getArgumentDetails(name, args) : kind === "custom" && state.expanded ? args : {};
201
218
  if (Object.keys(extra).length > 0) container.addChild(renderArguments(extra, theme));
202
219
  return container;
@@ -357,7 +374,7 @@ function renderRowResult(
357
374
  ctx: RenderContext,
358
375
  author?: AuthorResultRenderer,
359
376
  ): Component {
360
- const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
377
+ const state = runtime.syncRow(ctx, !options.isPartial);
361
378
  runtime.syncExpansion(state, ctx.expanded, name);
362
379
  if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
363
380
  const kind = rowKind(name);
@@ -474,6 +491,31 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
474
491
  registeredConfiguration = signature;
475
492
  }
476
493
 
494
+ /** Note the calls an assistant message makes, as Pi reads them to make their rows. */
495
+ function noteCalls(message: unknown): void {
496
+ // Only a run's own updates announce calls; the check is cheap enough for every update.
497
+ const content = (message as { role?: string; content?: unknown }).role === "assistant"
498
+ ? (message as { content?: unknown }).content
499
+ : undefined;
500
+ if (!Array.isArray(content)) return;
501
+ for (const block of content) {
502
+ if (block?.type === "toolCall" && typeof block.id === "string") runtime.noteCall(block.id);
503
+ }
504
+ }
505
+
506
+ /** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
507
+ function bindRenderer(ctx: ExtensionContext): void {
508
+ ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
509
+ runtime.bindRenderer(() => tui.requestRender());
510
+ return { render: () => [], invalidate() {} };
511
+ }, { placement: "belowEditor" });
512
+ }
513
+
514
+ function unbindRenderer(ctx: ExtensionContext | undefined): void {
515
+ runtime.bindRenderer(undefined);
516
+ ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
517
+ }
518
+
477
519
  export default function compactTools(pi: ExtensionAPI): void {
478
520
  // Pi caches this factory and re-invokes it with a fresh `pi` for every session
479
521
  // replacement (/resume, /new, /fork) as well as /reload, each time starting from
@@ -491,13 +533,20 @@ export default function compactTools(pi: ExtensionAPI): void {
491
533
  const customRowsAvailable = installToolRowPatch();
492
534
  setRowResolver(resolveCustomRow);
493
535
  let customRowsWarned = false;
536
+ let renderContext: ExtensionContext | undefined;
494
537
  // Register once while the extension runtime is being built. In particular, this
495
538
  // makes the overrides available before Pi restores the active tool set on /reload.
496
539
  configure(pi, process.cwd());
497
540
  pi.on("session_start", (event, ctx) => {
498
541
  if (event.reason !== "reload") runtime.clearTimings();
542
+ // Rows restored from the session are drawn next; while idle none of them is running.
543
+ if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
499
544
  configure(pi, ctx.cwd, ctx.isProjectTrusted());
545
+ unbindRenderer(renderContext);
546
+ renderContext = undefined;
500
547
  if (ctx.mode === "tui" && runtime.config.style !== "off") {
548
+ renderContext = ctx;
549
+ bindRenderer(ctx);
501
550
  // First, so its input listener sees relayout keys before the thinking controller consumes them.
502
551
  viewport.bind(ctx);
503
552
  progress.bind(ctx);
@@ -517,14 +566,28 @@ export default function compactTools(pi: ExtensionAPI): void {
517
566
  groups.dispose();
518
567
  }
519
568
  });
520
- pi.on("tool_execution_start", (event) => runtime.noteExecutionStart(event.toolCallId));
521
- pi.on("tool_execution_end", (event) => runtime.noteExecutionEnd(event.toolCallId));
569
+ pi.on("agent_start", () => runtime.setBusy(true));
570
+ pi.on("agent_end", () => runtime.setBusy(false));
571
+ // The calls of the run going on, read from each update as Pi reads them to make
572
+ // their rows, and told to extensions before Pi makes them.
573
+ pi.on("message_update", (event) => noteCalls(event.message));
574
+ pi.on("tool_execution_start", (event) => {
575
+ if (event.parentToolCallId) return runtime.noteNestedCall(event.toolCallId, true);
576
+ runtime.noteCall(event.toolCallId);
577
+ runtime.noteExecutionStart(event.toolCallId);
578
+ });
579
+ pi.on("tool_execution_end", (event) => {
580
+ if (event.parentToolCallId) return runtime.noteNestedCall(event.toolCallId, false);
581
+ runtime.noteExecutionEnd(event.toolCallId);
582
+ });
522
583
  pi.on("session_shutdown", (event) => {
523
584
  thinkingCycle.dispose();
524
585
  progress.dispose();
525
586
  silent.dispose();
526
587
  groups.dispose();
527
588
  viewport.dispose();
589
+ unbindRenderer(renderContext);
590
+ renderContext = undefined;
528
591
  runtime.reset(event.reason !== "reload");
529
592
  });
530
593
  }