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 CHANGED
@@ -1,20 +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 tool rows for Pi, including custom tools from other packages, with a GitHub Dark theme.
4
4
 
5
- ![Compact rows for every built-in tool](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif)
6
-
7
- ![Syntax-highlighted edit diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
5
+ ![Syntax-highlighted edit diff with lines added and removed](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
8
6
 
9
7
  ![Numbered read code view](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/code-aware-results.gif)
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
- - Syntax-highlighted edit diffs and numbered `read`/`write` code views, including Markdown code fences
15
- - Animated working label above the prompt, lit in the session's own thinking-level color, and a `Ctrl+T` thinking summary/detail cycle
16
- - 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
- - No Nerd Font required
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
- Then restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
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
- Optional. Put it in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json`:
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 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`).
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
+ ![Compact rows for built-in and custom tools, including a web search and an edit with its diff and line counts](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif)
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
+ ![Silent mode switched on mid-run: the tool rows fold away into a moving light, the answer stays, and every row comes back when it is off](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/silent-mode.gif)
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
+ ![Ctrl+O expands every tool row, then collapses them again](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/expand-collapse.gif)
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 (!SUPPORTED_TOOL_SET.has(name)) {
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 as CompactToolName] = enabled;
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
+ }