pi-compact-tools 0.9.3 → 0.10.1
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 +51 -13
- 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 +9 -9
- package/extensions/compact-tools-release.ts +138 -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 +3 -2
- package/release-notes.json +11 -0
- package/schemas/compact-tools.schema.json +34 -2
- package/scripts/check-resources.mjs +9 -0
package/README.md
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
# pi-compact-tools
|
|
2
2
|
|
|
3
|
-
Compact, expandable
|
|
3
|
+
Compact, expandable tool rows for Pi, including custom tools from other packages, with a GitHub Dark theme.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
8
6
|
|
|
9
7
|

|
|
10
8
|
|
|
11
9
|
## Features
|
|
12
10
|
|
|
13
11
|
- Compact rows for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`, with status, duration, and line count
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
12
|
+
- The same rows for custom tools from other packages, with no setup
|
|
13
|
+
- Syntax-highlighted edit diffs, with lines added and removed: `(+3 -1)`
|
|
14
|
+
- Numbered code views for `read` and `write`
|
|
15
|
+
- Silent mode: only prompts and final answers
|
|
16
|
+
- Works with any theme; no Nerd Font required
|
|
18
17
|
|
|
19
18
|
## Install
|
|
20
19
|
|
|
@@ -22,27 +21,64 @@ Compact, expandable rendering for Pi's built-in tools, with a GitHub Dark theme.
|
|
|
22
21
|
pi install npm:pi-compact-tools
|
|
23
22
|
```
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
Restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
|
|
26
25
|
|
|
27
26
|
## Configuration
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
All settings go in one optional file: `~/.pi/agent/compact-tools.json`, or `<project>/.pi/compact-tools.json` for one project. Every key is optional.
|
|
30
29
|
|
|
31
30
|
```json
|
|
32
31
|
{
|
|
33
32
|
"tools": ["read", "write", "edit", "bash"],
|
|
34
33
|
"previewLines": 10,
|
|
35
|
-
"auto_compact": { "read": true, "write": true, "edit": false, "bash": true }
|
|
34
|
+
"auto_compact": { "read": true, "write": true, "edit": false, "bash": true },
|
|
35
|
+
"mode": "normal",
|
|
36
|
+
"custom_tools": { "enabled": true, "auto_compact": true, "exclude": [] }
|
|
36
37
|
}
|
|
37
38
|
```
|
|
38
39
|
|
|
39
|
-
- `tools`: tools to
|
|
40
|
-
- `auto_compact`: `true` starts hidden, `false`
|
|
40
|
+
- `tools`: built-in tools to compact. Also available: `grep`, `find`, `ls`, `powershell`.
|
|
41
|
+
- `auto_compact`: `true` starts a row hidden, `false` shows a preview of up to `previewLines` rows. Custom tool names work too.
|
|
42
|
+
- `mode`: `"normal"` or `"silent"`. See [Silent mode](#silent-mode).
|
|
43
|
+
- `custom_tools`: see [Custom tools](#custom-tools).
|
|
44
|
+
|
|
45
|
+
## Custom tools
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
Tools from other packages get compact rows automatically. Expanding a row shows the tool's own result view.
|
|
50
|
+
|
|
51
|
+
Set them with the `custom_tools` key in the same `compact-tools.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"auto_compact": { "web_search": false },
|
|
56
|
+
"custom_tools": { "enabled": true, "auto_compact": true, "exclude": ["my_tool"] }
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- `enabled`: `false` turns this off for every custom tool.
|
|
61
|
+
- `auto_compact`: the default for every custom tool. Override one tool in the top-level `auto_compact`, like `web_search` above.
|
|
62
|
+
- `exclude`: tools that keep their original look.
|
|
63
|
+
|
|
64
|
+
## Silent mode
|
|
65
|
+
|
|
66
|
+

|
|
67
|
+
|
|
68
|
+
Shows only your prompts and the final answers. Tool calls, thinking, and notices are hidden while a light under the prompt shows the agent is working.
|
|
69
|
+
|
|
70
|
+
- Toggle with `Ctrl+'` or `/silent` (`/silent on`, `/silent off`). `Ctrl+'` needs a terminal with the Kitty keyboard protocol, such as Ghostty, Kitty, or WezTerm.
|
|
71
|
+
- Start every session silent with `"mode": "silent"`.
|
|
72
|
+
- Turning it off brings every hidden row back.
|
|
73
|
+
- Errors are hidden too. If a run ends without an answer, turn silent mode off to see why.
|
|
41
74
|
|
|
42
75
|
## Controls
|
|
43
76
|
|
|
77
|
+

|
|
78
|
+
|
|
44
79
|
- Click a row or press `Ctrl+O` to expand or collapse results.
|
|
45
80
|
- `Ctrl+T` cycles thinking: summary → detail → summary → hidden.
|
|
81
|
+
- `Ctrl+'` or `/silent` toggles silent mode.
|
|
46
82
|
|
|
47
83
|
## Development
|
|
48
84
|
|
|
@@ -52,6 +88,8 @@ npm run check
|
|
|
52
88
|
pi -e .
|
|
53
89
|
```
|
|
54
90
|
|
|
91
|
+
Before publishing, add the new version's notes to `release-notes.json`. Pi shows them once after users update. Use `[]` for a release with nothing to announce.
|
|
92
|
+
|
|
55
93
|
## License
|
|
56
94
|
|
|
57
95
|
MIT
|
|
@@ -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
|
+
}
|