pi-compact-tools 0.9.2 → 0.10.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/README.md +49 -4
- package/extensions/compact-tools-activity.ts +351 -0
- package/extensions/compact-tools-clock.ts +29 -0
- package/extensions/compact-tools-config.ts +47 -5
- package/extensions/compact-tools-custom.ts +77 -0
- package/extensions/compact-tools-hook.ts +47 -0
- package/extensions/compact-tools-invocation.ts +72 -0
- package/extensions/compact-tools-layout.ts +10 -0
- package/extensions/compact-tools-palette.ts +18 -0
- package/extensions/compact-tools-progress.ts +15 -10
- package/extensions/compact-tools-release.ts +136 -0
- package/extensions/compact-tools-runtime.ts +38 -11
- package/extensions/compact-tools-silent.ts +334 -0
- package/extensions/compact-tools-thinking.ts +7 -7
- package/extensions/compact-tools-types.ts +14 -1
- package/extensions/compact-tools-viewport.ts +234 -0
- package/extensions/compact-tools.ts +156 -12
- package/package.json +5 -4
- package/release-notes.json +10 -0
- package/schemas/compact-tools.schema.json +34 -2
- package/scripts/check-resources.mjs +8 -0
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# pi-compact-tools
|
|
2
2
|
|
|
3
|
-
Compact, expandable rendering for Pi's built-in tools, with a GitHub Dark theme.
|
|
3
|
+
Compact, expandable rendering for Pi's built-in tools and for custom tools from other packages, with a GitHub Dark theme.
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
|
-

|
|
7
|
+

|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- Compact rows for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`, with status, duration, and line count
|
|
14
|
+
- The same compact rows for custom tools registered by other packages, such as web search or MCP tools, with no setup
|
|
14
15
|
- Syntax-highlighted edit diffs and numbered `read`/`write` code views, including Markdown code fences
|
|
16
|
+
- Edits report what they changed on their status line, lines added and removed in the diff colors: `(+3 -1)`
|
|
15
17
|
- Animated working label above the prompt, lit in the session's own thinking-level color, and a `Ctrl+T` thinking summary/detail cycle
|
|
16
18
|
- Theme-agnostic chrome: rails, the tool indicator, and the working label are derived from the active theme and stay legible on light and dark backgrounds
|
|
17
19
|
- No Nerd Font required
|
|
@@ -37,12 +39,55 @@ Optional. Put it in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-t
|
|
|
37
39
|
```
|
|
38
40
|
|
|
39
41
|
- `tools`: tools to render compactly. Also available: `grep`, `find`, `ls`, `powershell`.
|
|
40
|
-
- `auto_compact`: `true` starts hidden, `false` starts with a preview of up to `previewLines` rows (`1`–`100`).
|
|
42
|
+
- `auto_compact`: `true` starts hidden, `false` starts with a preview of up to `previewLines` rows (`1`–`100`). Custom tools can be listed by name too, such as `"web_search": false`.
|
|
43
|
+
- `mode`: `"normal"` (default) or `"silent"`. See [Silent mode](#silent-mode).
|
|
44
|
+
- `custom_tools`: compact rendering for tools from other packages. See [Custom tools](#custom-tools).
|
|
45
|
+
|
|
46
|
+
## Custom tools
|
|
47
|
+
|
|
48
|
+
Tools registered by other packages get the same row as the built-ins: a status indicator, the tool name, a one-line summary of the call, then duration and line count once it finishes. It works for any tool, whichever package registered it, with nothing to configure.
|
|
49
|
+
|
|
50
|
+
Expanding a row shows the call's arguments and then the result as the tool's own package renders it, so a package's custom result view is kept. Tools without a result renderer show their text output.
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"custom_tools": { "enabled": true, "auto_compact": true, "exclude": ["my_tool"] }
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `enabled`: `false` leaves every custom tool with its own renderer. `"custom_tools": false` is shorthand.
|
|
59
|
+
- `auto_compact`: like the built-in setting, `true` starts rows hidden and `false` starts with a preview. A tool named in the top-level `auto_compact` uses its own setting instead.
|
|
60
|
+
- `exclude`: tool names that keep their own renderer.
|
|
61
|
+
|
|
62
|
+
Built-in tools follow `tools` above: a built-in left out of that list keeps Pi's default renderer.
|
|
63
|
+
|
|
64
|
+
Pi has no public API for changing how another package's tool is drawn, so this wraps the renderer lookup of Pi's tool row component. Nothing in Pi's installation is modified, and it only applies while this extension is loaded. If a Pi release changes that component, custom tools fall back to their own renderers and Pi shows a warning once.
|
|
65
|
+
|
|
66
|
+
## Silent mode
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
Silent mode shows only your prompts and the final answers. Tool calls (including custom tools from other packages), the assistant's in-between turns, thinking, error and abort notices, extension notices such as web-search progress, and Pi's own status lines are hidden while the animated working label shows what is running.
|
|
71
|
+
|
|
72
|
+
- While the agent works, a light runs back and forth along a short line beneath your latest prompt, above the working label and just as long, leaving a fading afterimage in the thinking level's color. It steps aside once the answer starts.
|
|
73
|
+
- `Ctrl+'` or `/silent` toggles it for the current session; `/silent on` and `/silent off` set it explicitly.
|
|
74
|
+
- `Ctrl+'` needs a terminal that reports it as its own key (the Kitty keyboard protocol: Ghostty, Kitty, WezTerm, and others). Where it only types `'`, use `/silent`.
|
|
75
|
+
- Set `"mode": "silent"` in `compact-tools.json` to start every session silent.
|
|
76
|
+
- Turning it off brings back every hidden row. Nothing is removed from the session or the model's context.
|
|
77
|
+
- Errors are hidden too, so a run that fails ends without an answer. Turn silent mode off to see what went wrong.
|
|
78
|
+
|
|
79
|
+
Pi has no public API for hiding other extensions' tool rows, so silent mode wraps Pi's transcript components. If a future Pi release changes them, `/silent` reports that it is unavailable and the transcript renders normally.
|
|
41
80
|
|
|
42
81
|
## Controls
|
|
43
82
|
|
|
44
83
|
- Click a row or press `Ctrl+O` to expand or collapse results.
|
|
84
|
+
- In fullscreen mode, expanding or collapsing every row, cycling thinking, and toggling silent mode keep the text you were reading in place instead of jumping.
|
|
45
85
|
- `Ctrl+T` cycles thinking: summary → detail → summary → hidden.
|
|
86
|
+
- `Ctrl+'` or `/silent` toggles silent mode.
|
|
87
|
+
|
|
88
|
+
## Release notes
|
|
89
|
+
|
|
90
|
+
After an update, the next new conversation opens with a bordered "What's new" block, the way Pi shows its own changelog: once, listing every release since the last one shown. A resumed session keeps it for the next new one, and so does silent mode. The block lives only in the TUI; it is never saved to the session or sent to the model. When publishing a new version, add its changes to `release-notes.json`; `npm run check` requires an entry matching `package.json`.
|
|
46
91
|
|
|
47
92
|
## Development
|
|
48
93
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { UserMessageComponent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { onTick } from "./compact-tools-clock.ts";
|
|
4
|
+
import { colorizeRgb, interpolateRgb, type Rgb } from "./compact-tools-color.ts";
|
|
5
|
+
import { hookMethod } from "./compact-tools-hook.ts";
|
|
6
|
+
import { activityGlow, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
|
|
7
|
+
|
|
8
|
+
const WIDGET_KEY = "compact-tools-silent-activity-render";
|
|
9
|
+
const ACTIVITY_STATE = Symbol.for("pi-compact-tools.silent.activity.state");
|
|
10
|
+
// Markers under which earlier versions kept the unwrapped methods; hookMethod
|
|
11
|
+
// replaces those wrappers so a /reload never leaves the old drawing in place.
|
|
12
|
+
const LEGACY_USER_RENDER = Symbol.for("pi-compact-tools.silent.activity.originalUserRender");
|
|
13
|
+
const LEGACY_TRANSCRIPT_RENDER = Symbol.for("pi-compact-tools.silent.activity.originalTranscriptRender");
|
|
14
|
+
const LEGACY_GAP_RENDER = Symbol.for("pi-compact-tools.silent.activity.originalGapRender");
|
|
15
|
+
/**
|
|
16
|
+
* Pi mounts the same components in regular and fullscreen mode, in this order:
|
|
17
|
+
* transcript, pending messages, status, widgets above, editor, widgets below, footer.
|
|
18
|
+
*/
|
|
19
|
+
const MOUNTED_CHILDREN = 7;
|
|
20
|
+
const WIDGETS_ABOVE_CHILD = 3;
|
|
21
|
+
/**
|
|
22
|
+
* Frames per run to the end of the line and back, on the shared 80 ms clock: about
|
|
23
|
+
* two seconds. The light crosses the middle fast, a cell or more per frame, so
|
|
24
|
+
* few frames are needed; the tail fills in every cell it swept past between them.
|
|
25
|
+
*/
|
|
26
|
+
const BREATH_FRAMES = 26;
|
|
27
|
+
const DOT_COUNT = 9;
|
|
28
|
+
/**
|
|
29
|
+
* Pi draws the working label into the editor's top border after a "── " prefix,
|
|
30
|
+
* so the label's first letter sits in column 3. The line starts there too, lining
|
|
31
|
+
* up with "Thinking…" just below it.
|
|
32
|
+
*/
|
|
33
|
+
const LABEL_COLUMN = 3;
|
|
34
|
+
/** A heavy line segment: consecutive cells join into one unbroken line, like the editor border below. */
|
|
35
|
+
const DOT = "━";
|
|
36
|
+
/** Brightness a passed cell keeps from one 80 ms frame to the next: the tail fades within about a third of a second. */
|
|
37
|
+
const AFTERGLOW = 0.6;
|
|
38
|
+
/** Frames of history the tail is traced over; beyond this it is fully dark. */
|
|
39
|
+
const AFTERGLOW_FRAMES = 8;
|
|
40
|
+
/** Samples between frames, so a light crossing two cells in one frame still leaves both in its tail. */
|
|
41
|
+
const SUBSTEPS = 8;
|
|
42
|
+
/** Brightness levels: coarse enough that unchanged frames compare equal and skip a repaint. */
|
|
43
|
+
const TONE_LEVELS = 32;
|
|
44
|
+
/** Perceptual easing for color: faint tail steps would otherwise read as bands. */
|
|
45
|
+
const TONE_GAMMA = 0.75;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the light is at a (possibly fractional) frame. It runs to the last cell and
|
|
49
|
+
* back, and each run follows a quintic S-curve (smootherstep): it lingers at each
|
|
50
|
+
* end, is fastest through the middle, and settles into the far end.
|
|
51
|
+
*/
|
|
52
|
+
function lightPosition(time: number): number {
|
|
53
|
+
const step = ((time % BREATH_FRAMES) + BREATH_FRAMES) % BREATH_FRAMES;
|
|
54
|
+
const half = BREATH_FRAMES / 2;
|
|
55
|
+
const run = step < half ? step / half : (BREATH_FRAMES - step) / half;
|
|
56
|
+
return run * run * run * (run * (run * 6 - 15) + 10) * (DOT_COUNT - 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cellAt(time: number): number {
|
|
60
|
+
return Math.round(lightPosition(time));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Brightness of every cell for one frame. The head is the cell the light is on, at
|
|
65
|
+
* full brightness. A cell it has passed, even between two frames, keeps a fading
|
|
66
|
+
* afterimage, so the light trails an unbroken tail. Nothing ahead of the head is
|
|
67
|
+
* ever lit: the direction turns only when the head actually steps off an end cell,
|
|
68
|
+
* and cells ahead of it in the new direction are dark.
|
|
69
|
+
*/
|
|
70
|
+
function computeFrame(frame: number): number[] {
|
|
71
|
+
const head = cellAt(frame);
|
|
72
|
+
let direction: 1 | -1 = 1;
|
|
73
|
+
for (let sample = 1; sample <= BREATH_FRAMES * SUBSTEPS; sample++) {
|
|
74
|
+
const previous = cellAt(frame - sample / SUBSTEPS);
|
|
75
|
+
if (previous !== head) {
|
|
76
|
+
direction = head > previous ? 1 : -1;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return Array.from({ length: DOT_COUNT }, (_, index) => {
|
|
81
|
+
if ((index - head) * direction > 0) return 0;
|
|
82
|
+
for (let sample = 0; sample <= AFTERGLOW_FRAMES * SUBSTEPS; sample++) {
|
|
83
|
+
if (cellAt(frame - sample / SUBSTEPS) === index) {
|
|
84
|
+
return Math.round(AFTERGLOW ** (sample / SUBSTEPS) * TONE_LEVELS) / TONE_LEVELS;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return 0;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One run is computed once when the module loads; every frame after is a lookup.
|
|
92
|
+
const FRAMES: readonly (readonly number[])[] = Array.from({ length: BREATH_FRAMES }, (_, frame) => computeFrame(frame));
|
|
93
|
+
const FRAME_CHANGED: readonly boolean[] = FRAMES.map((cells, frame) => {
|
|
94
|
+
const previous = FRAMES[(frame + BREATH_FRAMES - 1) % BREATH_FRAMES]!;
|
|
95
|
+
return cells.some((value, index) => value !== previous[index]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function frameIndex(frame: number): number {
|
|
99
|
+
return ((Math.floor(frame) % BREATH_FRAMES) + BREATH_FRAMES) % BREATH_FRAMES;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Brightness of each cell, 0 to 1, at a frame. */
|
|
103
|
+
export function dotIntensities(frame: number): number[] {
|
|
104
|
+
return [...FRAMES[frameIndex(frame)]!];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether a frame looks different from the one before it; an identical frame needs no repaint. */
|
|
108
|
+
export function frameChanges(frame: number): boolean {
|
|
109
|
+
return FRAME_CHANGED[frameIndex(frame)]!;
|
|
110
|
+
}
|
|
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>>();
|
|
114
|
+
|
|
115
|
+
function cachedGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
|
|
116
|
+
let byLevel = glowCache.get(theme);
|
|
117
|
+
if (!byLevel) {
|
|
118
|
+
byLevel = new Map();
|
|
119
|
+
glowCache.set(theme, byLevel);
|
|
120
|
+
}
|
|
121
|
+
if (!byLevel.has(level)) byLevel.set(level, activityGlow(theme, level) ?? null);
|
|
122
|
+
return byLevel.get(level) ?? undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toneColor(glow: ColorRamp, intensity: number): Rgb {
|
|
126
|
+
return interpolateRgb(glow.from, glow.to, intensity ** TONE_GAMMA);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A line the light runs along with a fading afterimage, in the thinking level's color, fitted to the width. */
|
|
130
|
+
export function renderActivityDots(
|
|
131
|
+
frame: number,
|
|
132
|
+
width: number,
|
|
133
|
+
theme: Theme,
|
|
134
|
+
level: ThinkingLevel = "medium",
|
|
135
|
+
column = LABEL_COLUMN,
|
|
136
|
+
): string[] {
|
|
137
|
+
if (width <= 0) return [];
|
|
138
|
+
const indent = Math.max(0, Math.min(column, width - 1));
|
|
139
|
+
const fitting = Math.min(DOT_COUNT, width - indent);
|
|
140
|
+
const glow = cachedGlow(theme, level);
|
|
141
|
+
const dots = FRAMES[frameIndex(frame)]!.slice(0, fitting).map((intensity) => glow
|
|
142
|
+
? colorizeRgb(theme, toneColor(glow, intensity), DOT)
|
|
143
|
+
// Reached only when the theme's colors did not resolve.
|
|
144
|
+
: theme.fg(intensity > 0.5 ? "text" : "dim", DOT));
|
|
145
|
+
return [" ".repeat(indent) + dots.join("")];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Add the activity row directly beneath the prompt, keeping Pi's padding inside its box. */
|
|
149
|
+
export function attachActivityToUserMessage(lines: string[], activity: string): string[] {
|
|
150
|
+
return [...lines, activity];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface ActivityState {
|
|
154
|
+
active: boolean;
|
|
155
|
+
frame: number;
|
|
156
|
+
level?: ThinkingLevel;
|
|
157
|
+
theme?: Theme;
|
|
158
|
+
tui?: TUI;
|
|
159
|
+
target?: UserMessageComponent;
|
|
160
|
+
candidate?: UserMessageComponent;
|
|
161
|
+
selectionPending?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sharedActivityState(): ActivityState {
|
|
165
|
+
const holder = globalThis as typeof globalThis & { [ACTIVITY_STATE]?: ActivityState };
|
|
166
|
+
holder[ACTIVITY_STATE] ??= { active: false, frame: 0 };
|
|
167
|
+
return holder[ACTIVITY_STATE];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Fill unused transcript rows above the content while silent mode is active. */
|
|
171
|
+
export function bottomAlignTranscript(lines: string[], viewportHeight: number): string[] {
|
|
172
|
+
const gap = Math.max(0, Math.floor(viewportHeight) - lines.length);
|
|
173
|
+
return gap > 0 ? [...Array<string>(gap).fill(""), ...lines] : lines;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function patchTranscriptAlignment(tui: TUI): void {
|
|
177
|
+
const getPrimaryScrollView = (tui as TUI & { getPrimaryScrollView?: () => unknown }).getPrimaryScrollView;
|
|
178
|
+
const scroll = getPrimaryScrollView?.call(tui) as { child?: { render(width: number): string[] }; viewportHeight?: number } | undefined;
|
|
179
|
+
const document = scroll?.child;
|
|
180
|
+
if (!document) return;
|
|
181
|
+
hookMethod(document, "render", "activity.transcriptRender", (self, args, original) => {
|
|
182
|
+
const lines: string[] = original.apply(self, args);
|
|
183
|
+
if (!sharedActivityState().active) return lines;
|
|
184
|
+
const height = scroll?.viewportHeight;
|
|
185
|
+
return typeof height === "number" ? bottomAlignTranscript(lines, height) : lines;
|
|
186
|
+
}, [LEGACY_TRANSCRIPT_RENDER]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type ContainerLike = { children?: unknown[]; render(width: number): string[] };
|
|
190
|
+
|
|
191
|
+
function isSpacer(component: unknown): boolean {
|
|
192
|
+
return typeof component === "object" && component !== null && component.constructor?.name === "Spacer";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pi keeps one blank row between the transcript and the editor: the widgets-above
|
|
197
|
+
* area renders a lone spacer when no extension placed a widget there. While the
|
|
198
|
+
* line animates it already separates the prompt from the editor, so that row
|
|
199
|
+
* would only push them apart. It is found by its fixed place among the TUI's
|
|
200
|
+
* children, the same in regular and fullscreen mode, and left
|
|
201
|
+
* alone whenever the layout differs or a real widget occupies it.
|
|
202
|
+
*/
|
|
203
|
+
function patchEditorGap(tui: TUI): void {
|
|
204
|
+
const children = (tui as TUI & { children?: unknown[] }).children;
|
|
205
|
+
if (!Array.isArray(children) || children.length !== MOUNTED_CHILDREN) return;
|
|
206
|
+
const gap = children[WIDGETS_ABOVE_CHILD] as ContainerLike | undefined;
|
|
207
|
+
if (!gap || !Array.isArray(gap.children)) return;
|
|
208
|
+
hookMethod(gap, "render", "activity.editorGap", (self: ContainerLike, args, original) => {
|
|
209
|
+
if (!sharedActivityState().active) return original.apply(self, args);
|
|
210
|
+
return (self.children ?? []).every(isSpacer) ? [] : original.apply(self, args);
|
|
211
|
+
}, [LEGACY_GAP_RENDER]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Draw the activity row as part of the latest submitted user message. */
|
|
215
|
+
function patchUserMessage(): boolean {
|
|
216
|
+
const prototype = UserMessageComponent?.prototype;
|
|
217
|
+
if (!prototype) return false;
|
|
218
|
+
return hookMethod(prototype, "render", "activity.userRender", (self: UserMessageComponent, args, original) => {
|
|
219
|
+
const state = sharedActivityState();
|
|
220
|
+
if (!state.active) return original.apply(self, args);
|
|
221
|
+
const lines: string[] = original.apply(self, args);
|
|
222
|
+
if (lines.length === 0) return lines;
|
|
223
|
+
// The transcript renders user messages in order. Commit the last one after
|
|
224
|
+
// the current render pass, then repaint only if the target changed.
|
|
225
|
+
state.candidate = self;
|
|
226
|
+
if (!state.selectionPending) {
|
|
227
|
+
state.selectionPending = true;
|
|
228
|
+
queueMicrotask(() => {
|
|
229
|
+
state.selectionPending = false;
|
|
230
|
+
if (!state.active || state.target === state.candidate) return;
|
|
231
|
+
state.target = state.candidate;
|
|
232
|
+
state.tui?.requestRender();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (state.target !== self || !state.theme) return lines;
|
|
236
|
+
const width = args[0] as number;
|
|
237
|
+
return attachActivityToUserMessage(lines, renderActivityDots(state.frame, width, state.theme, state.level)[0] ?? "");
|
|
238
|
+
}, [LEGACY_USER_RENDER]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Animates beneath the latest submitted prompt during a silent turn. */
|
|
242
|
+
export class SilentActivityAnimator {
|
|
243
|
+
private context: ExtensionContext | undefined;
|
|
244
|
+
private running = false;
|
|
245
|
+
/** The final answer is streaming; the dots step aside rather than sit under it. */
|
|
246
|
+
private answering = false;
|
|
247
|
+
private enabled = false;
|
|
248
|
+
private mounted = false;
|
|
249
|
+
private frame = 0;
|
|
250
|
+
/** Stops this animation's share of the shared clock. */
|
|
251
|
+
private stopTicking: (() => void) | undefined;
|
|
252
|
+
|
|
253
|
+
constructor(private readonly pi: ExtensionAPI) {
|
|
254
|
+
pi.on("agent_start", () => {
|
|
255
|
+
if (!this.context) return;
|
|
256
|
+
this.running = true;
|
|
257
|
+
this.answering = false;
|
|
258
|
+
this.refresh();
|
|
259
|
+
});
|
|
260
|
+
// Text means the assistant is answering. Thinking, a tool call, or a new turn
|
|
261
|
+
// means it went back to work, so the dots return.
|
|
262
|
+
pi.on("message_update", (event) => {
|
|
263
|
+
const type = event.assistantMessageEvent?.type;
|
|
264
|
+
if (type === "text_start" || type === "text_delta") this.setAnswering(true);
|
|
265
|
+
else if (type === "thinking_start" || type === "toolcall_start") this.setAnswering(false);
|
|
266
|
+
});
|
|
267
|
+
pi.on("tool_execution_start", () => this.setAnswering(false));
|
|
268
|
+
pi.on("turn_start", () => this.setAnswering(false));
|
|
269
|
+
pi.on("agent_end", () => {
|
|
270
|
+
if (!this.context) return;
|
|
271
|
+
this.running = false;
|
|
272
|
+
this.refresh();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
bind(ctx: ExtensionContext): void {
|
|
277
|
+
this.hide();
|
|
278
|
+
this.context = ctx;
|
|
279
|
+
this.running = ctx.isIdle?.() === false;
|
|
280
|
+
this.answering = false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
setEnabled(enabled: boolean): void {
|
|
284
|
+
this.enabled = enabled;
|
|
285
|
+
this.refresh();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
dispose(): void {
|
|
289
|
+
this.hide();
|
|
290
|
+
this.context = undefined;
|
|
291
|
+
this.running = false;
|
|
292
|
+
this.enabled = false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Guarded like the progress label: a host with a partial API keeps the default color. */
|
|
296
|
+
private readLevel(): ThinkingLevel {
|
|
297
|
+
return typeof this.pi.getThinkingLevel === "function" ? this.pi.getThinkingLevel() : "medium";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private setAnswering(answering: boolean): void {
|
|
301
|
+
if (!this.context || this.answering === answering) return;
|
|
302
|
+
this.answering = answering;
|
|
303
|
+
this.refresh();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private refresh(): void {
|
|
307
|
+
if (this.context && this.enabled && this.running && !this.answering) this.show();
|
|
308
|
+
else this.hide();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private show(): void {
|
|
312
|
+
if (!this.context || this.mounted || !patchUserMessage()) return;
|
|
313
|
+
this.frame = 0;
|
|
314
|
+
this.mounted = true;
|
|
315
|
+
Object.assign(sharedActivityState(), {
|
|
316
|
+
active: true, frame: 0, level: this.readLevel(), theme: this.context.ui.theme,
|
|
317
|
+
target: undefined, candidate: undefined,
|
|
318
|
+
});
|
|
319
|
+
// This invisible widget provides Pi's TUI for animation repaints.
|
|
320
|
+
this.context.ui.setWidget(WIDGET_KEY, (tui) => {
|
|
321
|
+
sharedActivityState().tui = tui;
|
|
322
|
+
patchTranscriptAlignment(tui);
|
|
323
|
+
patchEditorGap(tui);
|
|
324
|
+
return { render: () => [], invalidate() {} };
|
|
325
|
+
}, { placement: "belowEditor" });
|
|
326
|
+
// Ticks with the working label, so the two share one repaint per frame, and
|
|
327
|
+
// frames that look the same as the last one ask for no repaint at all.
|
|
328
|
+
this.stopTicking = onTick(() => {
|
|
329
|
+
this.frame++;
|
|
330
|
+
const state = sharedActivityState();
|
|
331
|
+
const level = this.readLevel();
|
|
332
|
+
const changed = frameChanges(this.frame) || level !== state.level;
|
|
333
|
+
state.frame = this.frame;
|
|
334
|
+
state.level = level;
|
|
335
|
+
if (changed) state.tui?.requestRender();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private hide(): void {
|
|
340
|
+
const state = sharedActivityState();
|
|
341
|
+
state.active = false;
|
|
342
|
+
state.tui = undefined;
|
|
343
|
+
state.target = undefined;
|
|
344
|
+
state.candidate = undefined;
|
|
345
|
+
this.stopTicking?.();
|
|
346
|
+
this.stopTicking = undefined;
|
|
347
|
+
if (!this.mounted) return;
|
|
348
|
+
this.mounted = false;
|
|
349
|
+
this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shared animation clock. Every animation tick asks Pi to lay out and repaint
|
|
3
|
+
* the whole screen, so the working label and the silent-mode line tick together:
|
|
4
|
+
* both change inside the same callback, and Pi coalesces their requests into one
|
|
5
|
+
* frame instead of two unsynchronized timers each forcing frames of their own.
|
|
6
|
+
*/
|
|
7
|
+
export const TICK_MS = 80;
|
|
8
|
+
|
|
9
|
+
type Listener = () => void;
|
|
10
|
+
|
|
11
|
+
const listeners = new Set<Listener>();
|
|
12
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
13
|
+
|
|
14
|
+
/** Run `listener` on every tick until the returned function is called. The clock stops when idle. */
|
|
15
|
+
export function onTick(listener: Listener): () => void {
|
|
16
|
+
listeners.add(listener);
|
|
17
|
+
if (!timer) {
|
|
18
|
+
timer = setInterval(() => {
|
|
19
|
+
for (const tick of [...listeners]) tick();
|
|
20
|
+
}, TICK_MS);
|
|
21
|
+
timer.unref?.();
|
|
22
|
+
}
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener);
|
|
25
|
+
if (listeners.size > 0 || !timer) return;
|
|
26
|
+
clearInterval(timer);
|
|
27
|
+
timer = undefined;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -2,15 +2,18 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
|
+
DISPLAY_MODES,
|
|
5
6
|
SUPPORTED_TOOL_SET,
|
|
6
7
|
type CompactToolName,
|
|
7
8
|
type CompactToolsConfig,
|
|
9
|
+
type DisplayMode,
|
|
8
10
|
} from "./compact-tools-types.ts";
|
|
9
11
|
|
|
10
12
|
const CONFIG_FILE = "compact-tools.json";
|
|
11
13
|
type JsonObject = Record<string, unknown>;
|
|
12
14
|
|
|
13
15
|
export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
16
|
+
mode: "normal",
|
|
14
17
|
tools: ["read", "write", "edit", "bash"],
|
|
15
18
|
previewLines: 10,
|
|
16
19
|
auto_compact: {
|
|
@@ -23,6 +26,11 @@ export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
|
23
26
|
find: true,
|
|
24
27
|
ls: true,
|
|
25
28
|
},
|
|
29
|
+
custom_tools: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
auto_compact: true,
|
|
32
|
+
exclude: [],
|
|
33
|
+
},
|
|
26
34
|
};
|
|
27
35
|
|
|
28
36
|
function isObject(value: unknown): value is JsonObject {
|
|
@@ -37,6 +45,12 @@ function isIntegerInRange(value: unknown, minimum: number, maximum: number): val
|
|
|
37
45
|
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
function parseMode(value: unknown, path: string): DisplayMode | undefined {
|
|
49
|
+
if ((DISPLAY_MODES as readonly unknown[]).includes(value)) return value as DisplayMode;
|
|
50
|
+
warn(path, `mode must be one of ${DISPLAY_MODES.join(", ")}; using previous value`);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
function parseTools(value: unknown, path: string): CompactToolName[] | undefined {
|
|
41
55
|
if (!Array.isArray(value)) {
|
|
42
56
|
warn(path, "tools must be an array; using previous values");
|
|
@@ -63,13 +77,39 @@ function parseAutoCompact(
|
|
|
63
77
|
return base;
|
|
64
78
|
}
|
|
65
79
|
const result = { ...base };
|
|
80
|
+
// Any tool name is accepted: a custom tool's entry overrides custom_tools.auto_compact.
|
|
66
81
|
for (const [name, enabled] of Object.entries(value)) {
|
|
67
|
-
if (
|
|
68
|
-
warn(path, `ignoring unsupported auto_compact tool: ${name}`);
|
|
69
|
-
} else if (typeof enabled !== "boolean") {
|
|
82
|
+
if (typeof enabled !== "boolean") {
|
|
70
83
|
warn(path, `auto_compact.${name} must be true or false; using previous value`);
|
|
71
84
|
} else {
|
|
72
|
-
result[name
|
|
85
|
+
result[name] = enabled;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseCustomTools(
|
|
92
|
+
base: CompactToolsConfig["custom_tools"],
|
|
93
|
+
value: unknown,
|
|
94
|
+
path: string,
|
|
95
|
+
): CompactToolsConfig["custom_tools"] {
|
|
96
|
+
if (value === undefined) return base;
|
|
97
|
+
if (typeof value === "boolean") return { ...base, enabled: value };
|
|
98
|
+
if (!isObject(value)) {
|
|
99
|
+
warn(path, "custom_tools must be true, false, or an object; using previous values");
|
|
100
|
+
return base;
|
|
101
|
+
}
|
|
102
|
+
const result = { ...base };
|
|
103
|
+
for (const key of ["enabled", "auto_compact"] as const) {
|
|
104
|
+
if (value[key] === undefined) continue;
|
|
105
|
+
if (typeof value[key] === "boolean") result[key] = value[key];
|
|
106
|
+
else warn(path, `custom_tools.${key} must be true or false; using previous value`);
|
|
107
|
+
}
|
|
108
|
+
if (value.exclude !== undefined) {
|
|
109
|
+
if (Array.isArray(value.exclude) && value.exclude.every((name) => typeof name === "string")) {
|
|
110
|
+
result.exclude = [...new Set(value.exclude as string[])];
|
|
111
|
+
} else {
|
|
112
|
+
warn(path, "custom_tools.exclude must be an array of tool names; using previous values");
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
115
|
return result;
|
|
@@ -81,14 +121,16 @@ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: stri
|
|
|
81
121
|
warn(path, "expected a JSON object; using previous values");
|
|
82
122
|
return base;
|
|
83
123
|
}
|
|
124
|
+
const mode = value.mode === undefined ? base.mode : (parseMode(value.mode, path) ?? base.mode);
|
|
84
125
|
const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
|
|
85
126
|
const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
|
|
127
|
+
const custom_tools = parseCustomTools(base.custom_tools, value.custom_tools, path);
|
|
86
128
|
const previewLines = value.previewLines === undefined ? base.previewLines
|
|
87
129
|
: isIntegerInRange(value.previewLines, 1, 100) ? value.previewLines : base.previewLines;
|
|
88
130
|
if (value.previewLines !== undefined && !isIntegerInRange(value.previewLines, 1, 100)) {
|
|
89
131
|
warn(path, "previewLines must be 1–100; using previous value");
|
|
90
132
|
}
|
|
91
|
-
return { tools, auto_compact, previewLines };
|
|
133
|
+
return { mode, tools, auto_compact, custom_tools, previewLines };
|
|
92
134
|
}
|
|
93
135
|
|
|
94
136
|
function readJson(path: string): unknown {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ToolExecutionComponent, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Symbol.for keys are shared by every evaluation of this module. The prototype is
|
|
4
|
+
// Pi's and outlives a /reload, so the patch is installed once and always asks the
|
|
5
|
+
// most recently loaded extension instance, through the shared slot, what to render.
|
|
6
|
+
const RESOLVER_KEY = Symbol.for("pi-compact-tools.custom.resolver");
|
|
7
|
+
const PATCHED_KEY = Symbol.for("pi-compact-tools.custom.patched");
|
|
8
|
+
const ROUTED_METHODS = ["getCallRenderer", "getResultRenderer", "getRenderShell", "hasRendererDefinition"] as const;
|
|
9
|
+
|
|
10
|
+
type AnyToolDefinition = ToolDefinition<any, any, any>;
|
|
11
|
+
|
|
12
|
+
/** The fields of Pi's tool row that decide which renderer is used. */
|
|
13
|
+
export interface ToolRow {
|
|
14
|
+
toolName: string;
|
|
15
|
+
toolDefinition?: AnyToolDefinition;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RowRenderers {
|
|
19
|
+
renderCall: NonNullable<AnyToolDefinition["renderCall"]>;
|
|
20
|
+
renderResult: NonNullable<AnyToolDefinition["renderResult"]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RowResolver = (row: ToolRow) => RowRenderers | undefined;
|
|
24
|
+
|
|
25
|
+
type RoutedPrototype = Record<(typeof ROUTED_METHODS)[number], (...args: unknown[]) => unknown>;
|
|
26
|
+
type ResolverSlot = typeof globalThis & { [RESOLVER_KEY]?: RowResolver };
|
|
27
|
+
|
|
28
|
+
export function setRowResolver(resolver: RowResolver | undefined): void {
|
|
29
|
+
(globalThis as ResolverSlot)[RESOLVER_KEY] = resolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pi routes every renderer lookup on a tool row through these four methods, so
|
|
34
|
+
* answering them is enough to give any tool, whichever extension registered it,
|
|
35
|
+
* compact renderers. Returns false when the host no longer has that shape.
|
|
36
|
+
*/
|
|
37
|
+
export function patchToolRows(prototype: object | undefined): boolean {
|
|
38
|
+
if (!prototype) return false;
|
|
39
|
+
const routed = prototype as RoutedPrototype;
|
|
40
|
+
if (!ROUTED_METHODS.every((method) => typeof routed[method] === "function")) return false;
|
|
41
|
+
if (Object.hasOwn(prototype, PATCHED_KEY)) return true;
|
|
42
|
+
// Decided once per row: Pi picks the row's container from these answers in its
|
|
43
|
+
// constructor, so a row must never switch renderers after it is built. /reload
|
|
44
|
+
// rebuilds the transcript, so config changes still reach every row.
|
|
45
|
+
const decisions = new WeakMap<object, RowRenderers | null>();
|
|
46
|
+
const resolve = (row: ToolRow): RowRenderers | undefined => {
|
|
47
|
+
let renderers = decisions.get(row);
|
|
48
|
+
if (renderers === undefined) {
|
|
49
|
+
try {
|
|
50
|
+
renderers = (globalThis as ResolverSlot)[RESOLVER_KEY]?.(row) ?? null;
|
|
51
|
+
} catch {
|
|
52
|
+
renderers = null;
|
|
53
|
+
}
|
|
54
|
+
decisions.set(row, renderers);
|
|
55
|
+
}
|
|
56
|
+
return renderers ?? undefined;
|
|
57
|
+
};
|
|
58
|
+
const original = Object.fromEntries(ROUTED_METHODS.map((method) => [method, routed[method]])) as RoutedPrototype;
|
|
59
|
+
routed.getCallRenderer = function (this: ToolRow, ...args) {
|
|
60
|
+
return resolve(this)?.renderCall ?? original.getCallRenderer.apply(this, args);
|
|
61
|
+
};
|
|
62
|
+
routed.getResultRenderer = function (this: ToolRow, ...args) {
|
|
63
|
+
return resolve(this)?.renderResult ?? original.getResultRenderer.apply(this, args);
|
|
64
|
+
};
|
|
65
|
+
routed.getRenderShell = function (this: ToolRow, ...args) {
|
|
66
|
+
return resolve(this) ? "self" : original.getRenderShell.apply(this, args);
|
|
67
|
+
};
|
|
68
|
+
routed.hasRendererDefinition = function (this: ToolRow, ...args) {
|
|
69
|
+
return resolve(this) ? true : original.hasRendererDefinition.apply(this, args);
|
|
70
|
+
};
|
|
71
|
+
Object.defineProperty(prototype, PATCHED_KEY, { value: true });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installToolRowPatch(): boolean {
|
|
76
|
+
return patchToolRows(ToolExecutionComponent?.prototype);
|
|
77
|
+
}
|