pi-compact-tools 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/compact-tools-activity.ts +20 -21
- package/extensions/compact-tools-color.ts +154 -89
- package/extensions/compact-tools-grouping.ts +102 -20
- package/extensions/compact-tools-layout.ts +58 -2
- package/extensions/compact-tools-palette.ts +63 -36
- package/extensions/compact-tools-progress.ts +14 -4
- package/extensions/compact-tools-runtime.ts +127 -23
- package/extensions/compact-tools-silent.ts +17 -2
- package/extensions/compact-tools-types.ts +2 -0
- package/extensions/compact-tools.ts +77 -14
- package/package.json +3 -3
- package/release-notes.json +14 -0
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
1
|
+
import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
indicatorGlyph,
|
|
4
|
+
indicatorStrength,
|
|
5
|
+
indicatorTone,
|
|
6
|
+
RUNNING_INDICATOR_FRAME_COUNT,
|
|
7
|
+
type RowStatus,
|
|
8
|
+
} from "./compact-tools-core.ts";
|
|
3
9
|
import {
|
|
4
10
|
colorizeRgb,
|
|
5
11
|
interpolateRgb,
|
|
6
12
|
liftContrast,
|
|
7
13
|
neutralizeRgb,
|
|
14
|
+
paletteOf,
|
|
15
|
+
rgbPainter,
|
|
8
16
|
themeColorRgb,
|
|
9
17
|
withContrast,
|
|
10
18
|
type Rgb,
|
|
@@ -24,9 +32,7 @@ import {
|
|
|
24
32
|
* background the moves invert, and standing out means going darker.
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
|
-
type
|
|
28
|
-
|
|
29
|
-
type ContrastBand = { color: ThemeForeground; keepHue: number; minimum: number; maximum: number };
|
|
35
|
+
type ContrastBand = { color: ThemeColor; keepHue: number; minimum: number; maximum: number };
|
|
30
36
|
|
|
31
37
|
/** A band with no color attached, for a derivation that supplies its own source. */
|
|
32
38
|
type ContrastRange = { minimum: number; maximum: number };
|
|
@@ -63,32 +69,40 @@ const THINKING_LEVEL_COLOR = {
|
|
|
63
69
|
high: "thinkingHigh",
|
|
64
70
|
xhigh: "thinkingXhigh",
|
|
65
71
|
max: "thinkingMax",
|
|
66
|
-
} as const satisfies Record<ThinkingLevel,
|
|
72
|
+
} as const satisfies Record<ThinkingLevel, ThemeColor>;
|
|
67
73
|
|
|
68
74
|
/** The theme color a thinking level is painted with. */
|
|
69
|
-
function thinkingLevelColor(level: ThinkingLevel):
|
|
75
|
+
export function thinkingLevelColor(level: ThinkingLevel): ThemeColor {
|
|
70
76
|
return THINKING_LEVEL_COLOR[level] ?? "thinkingMedium";
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
|
-
*
|
|
75
|
-
* the same
|
|
76
|
-
*
|
|
80
|
+
* Derived colors, kept for as long as the palette they came from. `/theme` swaps the
|
|
81
|
+
* palette behind the same Theme, so the palette rather than the Theme is the key: a
|
|
82
|
+
* switch misses the cache by itself, and every frame in between is a lookup.
|
|
77
83
|
*/
|
|
84
|
+
const derivedByPalette = new WeakMap<object, Map<string, unknown>>();
|
|
85
|
+
|
|
86
|
+
export function perPalette<T>(theme: Theme, key: string, derive: () => T): T {
|
|
87
|
+
const palette = paletteOf(theme);
|
|
88
|
+
let cache = derivedByPalette.get(palette);
|
|
89
|
+
if (!cache) derivedByPalette.set(palette, cache = new Map());
|
|
90
|
+
if (!cache.has(key)) cache.set(key, derive());
|
|
91
|
+
return cache.get(key) as T;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
function bandedRgb(theme: Theme, band: ContrastBand): Rgb | undefined {
|
|
79
95
|
const rgb = themeColorRgb(theme, band.color);
|
|
80
96
|
if (!rgb) return undefined;
|
|
81
97
|
return withContrast(theme, neutralizeRgb(rgb, band.keepHue), band.minimum, band.maximum);
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
/**
|
|
85
|
-
* A chrome painter bound to one theme. Deriving the tone costs a parse and a dozen floating
|
|
86
|
-
* point operations, and the frame is drawn once per line of tool output or thinking detail,
|
|
87
|
-
* so callers in a loop take a painter once and callers with a single rail use `paintChrome`.
|
|
88
|
-
*/
|
|
100
|
+
/** A painter for the chrome tone, derived once per palette however many rails a frame draws. */
|
|
89
101
|
export function chromePainter(theme: Theme): (text: string) => string {
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
return perPalette(theme, "chrome", () => {
|
|
103
|
+
const chrome = bandedRgb(theme, CHROME);
|
|
104
|
+
return chrome ? rgbPainter(theme, chrome) : (text: string) => theme.fg("dim", text);
|
|
105
|
+
});
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
/** Paint one rail, connector, or status line in the chrome tone. */
|
|
@@ -104,18 +118,27 @@ function ramp(theme: Theme, low: ContrastBand, high: ContrastBand): ColorRamp |
|
|
|
104
118
|
return from && to ? { from, to } : undefined;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
/**
|
|
122
|
+
* A row's status dot; a running one pulses between the theme's endpoints as frames advance.
|
|
123
|
+
* Every running row repaints its dot on every frame, so the pulse is painted once per palette.
|
|
124
|
+
*/
|
|
125
|
+
export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
|
|
126
|
+
if (status !== "running") return theme.fg(indicatorTone(status), indicatorGlyph(status));
|
|
127
|
+
const pulse = runningPulse(theme);
|
|
128
|
+
return pulse[((frame % pulse.length) + pulse.length) % pulse.length]!;
|
|
110
129
|
}
|
|
111
130
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
/** Every step of the running dot's pulse, painted once per palette. */
|
|
132
|
+
function runningPulse(theme: Theme): readonly string[] {
|
|
133
|
+
return perPalette(theme, "running-pulse", () => {
|
|
134
|
+
const pulse = ramp(theme, PULSE_LOW, PULSE_HIGH);
|
|
135
|
+
return Array.from({ length: RUNNING_INDICATOR_FRAME_COUNT }, (_, step) => {
|
|
136
|
+
const glyph = indicatorGlyph("running", step);
|
|
137
|
+
return pulse
|
|
138
|
+
? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength("running", step)), glyph)
|
|
139
|
+
: theme.fg(indicatorTone("running", step), glyph);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
/** How far the brightest activity dot moves from the level's color toward the label's crest. */
|
|
@@ -129,17 +152,21 @@ const ACTIVITY_UNLIT = 0.05;
|
|
|
129
152
|
* label's full crest washes every level toward the same near-white.
|
|
130
153
|
*/
|
|
131
154
|
export function activityGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
155
|
+
return perPalette(theme, `activity:${level}`, () => {
|
|
156
|
+
const glow = progressGlow(theme, level);
|
|
157
|
+
if (!glow) return undefined;
|
|
158
|
+
const lit = interpolateRgb(glow.from, glow.to, ACTIVITY_CREST_MIX);
|
|
159
|
+
return { from: withContrast(theme, lit, ACTIVITY_UNLIT, ACTIVITY_UNLIT), to: lit };
|
|
160
|
+
});
|
|
136
161
|
}
|
|
137
162
|
|
|
138
163
|
/** Sweep endpoints for the working label at a thinking level, resting color to crest. */
|
|
139
164
|
export function progressGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
165
|
+
return perPalette(theme, `progress:${level}`, () => {
|
|
166
|
+
const raw = themeColorRgb(theme, thinkingLevelColor(level));
|
|
167
|
+
if (!raw) return undefined;
|
|
168
|
+
const lifted = liftContrast(theme, raw, GLOW_REST_LIFT);
|
|
169
|
+
const from = withContrast(theme, lifted, GLOW_FLOOR.minimum, GLOW_FLOOR.maximum);
|
|
170
|
+
return { from, to: liftContrast(theme, from, GLOW_CREST_LIFT) };
|
|
171
|
+
});
|
|
145
172
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { onTick } from "./compact-tools-clock.ts";
|
|
4
|
-
import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
|
|
4
|
+
import { colorizeRgb, interpolateRgb, paletteOf } from "./compact-tools-color.ts";
|
|
5
5
|
import { progressGlow, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
|
|
6
6
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
7
7
|
|
|
@@ -88,6 +88,8 @@ export class ProgressController {
|
|
|
88
88
|
private readonly activeTools = new Map<string, string>();
|
|
89
89
|
private message: string | undefined;
|
|
90
90
|
private frames: string[] = [];
|
|
91
|
+
/** The palette `frames` were painted with. */
|
|
92
|
+
private paintedFor: object | undefined;
|
|
91
93
|
private frame = 0;
|
|
92
94
|
/** Stops this label's share of the animation clock. */
|
|
93
95
|
private stopTicking: (() => void) | undefined;
|
|
@@ -134,7 +136,7 @@ export class ProgressController {
|
|
|
134
136
|
if (!this.context || event.level === this.level) return;
|
|
135
137
|
this.level = event.level;
|
|
136
138
|
// Recolor in place: the label and its animation phase are unchanged.
|
|
137
|
-
this.frames =
|
|
139
|
+
this.frames = [];
|
|
138
140
|
this.renderMessage();
|
|
139
141
|
});
|
|
140
142
|
}
|
|
@@ -169,7 +171,7 @@ export class ProgressController {
|
|
|
169
171
|
private setMessage(message: string): void {
|
|
170
172
|
if (!this.context || (this.message === message && this.stopTicking)) return;
|
|
171
173
|
this.message = message;
|
|
172
|
-
this.frames =
|
|
174
|
+
this.frames = [];
|
|
173
175
|
this.frame = 0;
|
|
174
176
|
this.renderMessage();
|
|
175
177
|
if (this.stopTicking) return;
|
|
@@ -180,7 +182,15 @@ export class ProgressController {
|
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
private renderMessage(): void {
|
|
183
|
-
if (!this.message || !this.context
|
|
185
|
+
if (!this.message || !this.context) return;
|
|
186
|
+
// Painted once per label, level, and palette: a theme switch, or the system theme
|
|
187
|
+
// following the terminal from dark to light, repaints on the next frame.
|
|
188
|
+
const theme = this.context.ui.theme;
|
|
189
|
+
const palette = paletteOf(theme);
|
|
190
|
+
if (this.frames.length === 0 || this.paintedFor !== palette) {
|
|
191
|
+
this.frames = createGlowFrames(this.message, theme, this.level);
|
|
192
|
+
this.paintedFor = palette;
|
|
193
|
+
}
|
|
184
194
|
this.context.ui.setWorkingMessage(this.frames[this.frame % this.frames.length]);
|
|
185
195
|
}
|
|
186
196
|
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
108
|
-
|
|
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.
|
|
151
|
-
if (this.
|
|
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.
|
|
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):
|
|
285
|
+
private restoreTiming(state: RowState, toolCallId: string): void {
|
|
181
286
|
const timing = this.executionTimings.get(toolCallId);
|
|
182
|
-
if (!timing) return
|
|
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,
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
188
|
+
const state = runtime.syncRow(ctx);
|
|
174
189
|
runtime.syncExpansion(state, ctx.expanded, name);
|
|
175
190
|
const kind = rowKind(name);
|
|
176
|
-
const
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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,
|
|
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("
|
|
521
|
-
pi.on("
|
|
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
|
}
|