pi-compact-tools 0.11.2 → 0.12.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.
@@ -3,7 +3,7 @@ import type { TUI } from "@earendil-works/pi-tui";
3
3
  import { onTick } from "./compact-tools-clock.ts";
4
4
  import { colorizeRgb, interpolateRgb, type Rgb } 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, thinkingLevelColor, type ColorRamp, 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,17 +109,22 @@ 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
+ /**
113
+ * Resolving a ramp walks the theme's colors, so each level keeps its last one.
114
+ * It is kept against the colors it is made from, the level's and the text's, not
115
+ * against the theme: Pi's theme is one object whose palette /theme swaps.
116
+ */
117
+ const glowCache = new Map<ThinkingLevel, { colors: string; glow: ColorRamp | undefined }>();
114
118
 
115
119
  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);
120
- }
121
- if (!byLevel.has(level)) byLevel.set(level, activityGlow(theme, level) ?? null);
122
- return byLevel.get(level) ?? undefined;
120
+ const colors = typeof theme.getFgAnsi === "function"
121
+ ? theme.getFgAnsi(thinkingLevelColor(level)) + theme.getFgAnsi("text")
122
+ : "";
123
+ const cached = glowCache.get(level);
124
+ if (cached && cached.colors === colors) return cached.glow;
125
+ const glow = activityGlow(theme, level);
126
+ glowCache.set(level, { colors, glow });
127
+ return glow;
123
128
  }
124
129
 
125
130
  function toneColor(glow: ColorRamp, intensity: number): Rgb {
@@ -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, " ");
@@ -66,7 +66,7 @@ const THINKING_LEVEL_COLOR = {
66
66
  } as const satisfies Record<ThinkingLevel, ThemeForeground>;
67
67
 
68
68
  /** The theme color a thinking level is painted with. */
69
- function thinkingLevelColor(level: ThinkingLevel): ThemeForeground {
69
+ export function thinkingLevelColor(level: ThinkingLevel): ThemeForeground {
70
70
  return THINKING_LEVEL_COLOR[level] ?? "thinkingMedium";
71
71
  }
72
72
 
@@ -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,21 @@ 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>();
30
47
 
31
- constructor() {
48
+ constructor(private readonly clock: () => number = () => performance.now()) {
32
49
  const shared = globalThis as SharedState;
33
50
  this.executionTimings = shared[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
34
51
  shared[EXECUTION_TIMINGS_KEY] = this.executionTimings;
@@ -62,24 +79,76 @@ export class ToolRuntime {
62
79
  if (clearTimings) this.clearTimings();
63
80
  }
64
81
 
82
+ /**
83
+ * Bind the animation to Pi's frame requests. A running dot is painted when its
84
+ * row is drawn, so a frame only has to be asked for, once, however many rows
85
+ * run. Unbound, each running row is rebuilt to repaint, as Pi does on its own.
86
+ */
87
+ bindRenderer(requestRender: (() => void) | undefined): void {
88
+ this.requestRender = requestRender;
89
+ }
90
+
91
+ /**
92
+ * The agent started or stopped working. Once it stops nothing can still be
93
+ * running, so a row that never got its result stops pulsing and waits instead.
94
+ */
95
+ setBusy(busy: boolean): void {
96
+ if (busy && this.busy !== true) this.currentCalls.clear();
97
+ this.busy = busy;
98
+ if (busy) return;
99
+ const animating = this.indicators.size > 0;
100
+ this.stopIndicators();
101
+ if (animating) this.requestRender?.();
102
+ }
103
+
104
+ /**
105
+ * Pi announced a call of the run going on: its arguments began to stream, or it
106
+ * began to run. Pi tells extensions before it makes the call's row. The set is
107
+ * emptied as each run starts, so it holds one run's calls at most.
108
+ */
109
+ noteCall(toolCallId: string): void {
110
+ this.currentCalls.add(toolCallId);
111
+ }
112
+
113
+ /**
114
+ * Whether a call can be running: only one Pi announced in the run going on. A
115
+ * row restored from the session, one left without a result when its run ended,
116
+ * and one Pi made again for an old call when it rebuilt the transcript were
117
+ * never announced in this run; whatever they did is over. Until the host says
118
+ * whether the agent works, every call can run, as before.
119
+ */
120
+ canRun(toolCallId: string): boolean {
121
+ return this.busy === undefined || (this.busy && this.currentCalls.has(toolCallId));
122
+ }
123
+
65
124
  syncIndicator(toolCallId: string, running: boolean, invalidate: () => void): number {
66
125
  if (!running) {
67
126
  this.removeIndicator(toolCallId);
68
127
  return 0;
69
128
  }
70
- this.indicatorInvalidators.set(toolCallId, invalidate);
129
+ const indicator = this.indicators.get(toolCallId);
130
+ if (indicator) {
131
+ indicator.invalidate = invalidate;
132
+ indicator.drawnAt = this.clock();
133
+ } else {
134
+ this.indicators.set(toolCallId, { invalidate, drawnAt: this.clock() });
135
+ }
71
136
  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);
137
+ this.indicatorTimer = setInterval(() => this.tick(), INDICATOR_INTERVAL_MS);
78
138
  this.indicatorTimer.unref?.();
79
139
  }
80
140
  return this.indicatorFrame;
81
141
  }
82
142
 
143
+ /**
144
+ * A running row that is out of sight but still counts, such as one folded into
145
+ * a group whose line pulses for it, keeps the animation going.
146
+ */
147
+ keepAnimating(toolCallId: string): void {
148
+ const indicator = this.indicators.get(toolCallId);
149
+ if (indicator) indicator.drawnAt = this.clock();
150
+ }
151
+
83
152
  syncExpansion(state: RowState, hostExpanded: boolean, name: string): boolean {
84
153
  const initialized = this.initializeExpansion(state, name, hostExpanded);
85
154
  if (!initialized && state.lastHostExpanded !== hostExpanded) {
@@ -98,15 +167,18 @@ export class ToolRuntime {
98
167
  state.hasResult = available;
99
168
  }
100
169
 
101
- syncRow(
102
- ctx: RenderContext,
103
- running = ctx.executionStarted && ctx.state.endedAt === undefined,
104
- finished = false,
105
- ): RowState {
170
+ syncRow(ctx: RenderContext, finished = false): RowState {
106
171
  const state = ctx.state;
107
- if (this.restoreTiming(state, ctx.toolCallId)) running = false;
108
- const started = !ctx.argsComplete || ctx.executionStarted || running;
172
+ // Pi can rebuild the transcript mid-run, making new rows for old calls; a call
173
+ // keeps the run its first row was made in.
174
+ this.restoreTiming(state, ctx.toolCallId);
175
+ // A row is timed from when it runs, as Pi times its own: the model writing the
176
+ // arguments is not the tool's time. A restored row never ran in front of the
177
+ // reader, so it has no time to measure unless this process timed it before a
178
+ // /reload, and a call that never ran has none either.
179
+ const started = this.canRun(ctx.toolCallId) && ctx.executionStarted;
109
180
  if (started && state.startedAt === undefined) state.startedAt = Date.now();
181
+ if (finished) state.finished = true;
110
182
  if (finished && state.startedAt !== undefined && state.endedAt === undefined) state.endedAt = Date.now();
111
183
  if (finished) this.removeIndicator(ctx.toolCallId);
112
184
  this.persistTiming(state, ctx.toolCallId);
@@ -146,9 +218,28 @@ export class ToolRuntime {
146
218
  };
147
219
  }
148
220
 
221
+ private tick(): void {
222
+ this.indicatorFrame++;
223
+ const requestRender = this.requestRender;
224
+ if (requestRender) {
225
+ const oldest = this.clock() - UNDRAWN_ROW_MS;
226
+ for (const [toolCallId, indicator] of this.indicators) {
227
+ if (indicator.drawnAt < oldest) this.removeIndicator(toolCallId);
228
+ }
229
+ if (this.indicators.size === 0) return;
230
+ }
231
+ // Silent mode hides tool rows; repainting the screen for them would be pure cost.
232
+ if (isSilent()) return;
233
+ if (requestRender) {
234
+ requestRender();
235
+ return;
236
+ }
237
+ for (const { invalidate } of this.indicators.values()) invalidate();
238
+ }
239
+
149
240
  private removeIndicator(toolCallId: string): void {
150
- this.indicatorInvalidators.delete(toolCallId);
151
- if (this.indicatorInvalidators.size > 0 || !this.indicatorTimer) return;
241
+ this.indicators.delete(toolCallId);
242
+ if (this.indicators.size > 0 || !this.indicatorTimer) return;
152
243
  clearInterval(this.indicatorTimer);
153
244
  this.indicatorTimer = undefined;
154
245
  this.indicatorFrame = 0;
@@ -157,7 +248,7 @@ export class ToolRuntime {
157
248
  private stopIndicators(): void {
158
249
  if (this.indicatorTimer) clearInterval(this.indicatorTimer);
159
250
  this.indicatorTimer = undefined;
160
- this.indicatorInvalidators.clear();
251
+ this.indicators.clear();
161
252
  this.indicatorFrame = 0;
162
253
  }
163
254
 
@@ -177,12 +268,11 @@ export class ToolRuntime {
177
268
  return true;
178
269
  }
179
270
 
180
- private restoreTiming(state: RowState, toolCallId: string): boolean {
271
+ private restoreTiming(state: RowState, toolCallId: string): void {
181
272
  const timing = this.executionTimings.get(toolCallId);
182
- if (!timing) return false;
273
+ if (!timing) return;
183
274
  state.startedAt = timing.startedAt;
184
275
  state.endedAt = timing.endedAt;
185
- return timing.endedAt !== undefined;
186
276
  }
187
277
 
188
278
  private persistTiming(state: RowState, toolCallId: string): void {
@@ -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,7 +566,15 @@ 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));
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
+ runtime.noteCall(event.toolCallId);
576
+ runtime.noteExecutionStart(event.toolCallId);
577
+ });
521
578
  pi.on("tool_execution_end", (event) => runtime.noteExecutionEnd(event.toolCallId));
522
579
  pi.on("session_shutdown", (event) => {
523
580
  thinkingCycle.dispose();
@@ -525,6 +582,8 @@ export default function compactTools(pi: ExtensionAPI): void {
525
582
  silent.dispose();
526
583
  groups.dispose();
527
584
  viewport.dispose();
585
+ unbindRenderer(renderContext);
586
+ renderContext = undefined;
528
587
  runtime.reset(event.reason !== "reload");
529
588
  });
530
589
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Compact, expandable tool rows for Pi in its own compact style or Claude Code's, with a silent mode and a GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,11 @@
1
1
  {
2
+ "0.12.0": [
3
+ "Running rows animate without being rebuilt: one repaint per frame, however many tools run",
4
+ "A tool's time counts from when it runs, not from when the model began writing its call",
5
+ "Rows restored from a session show no made-up time, and a call left without a result waits instead of pulsing on",
6
+ "Claude style: groups draw about ten times faster in long sessions, and wait along with their calls",
7
+ "Silent mode's activity line follows a theme switch"
8
+ ],
2
9
  "0.11.2": [
3
10
  "Claude style no longer cuts commands to one row; long ones wrap, and only past two lines or 160 characters are shortened"
4
11
  ],