pi-compact-tools 0.16.2 → 0.16.6
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 +4 -3
- package/extensions/compact-tools-activity.ts +9 -10
- package/extensions/compact-tools-clock.ts +37 -20
- package/extensions/compact-tools-command.ts +13 -16
- package/extensions/compact-tools-grouping.ts +2 -1
- package/extensions/compact-tools-invocation.ts +6 -0
- package/extensions/compact-tools-progress.ts +17 -18
- package/extensions/compact-tools-rendering.ts +36 -1
- package/extensions/compact-tools-runtime.ts +9 -50
- package/extensions/compact-tools-silent.ts +11 -8
- package/extensions/compact-tools-thinking.ts +12 -4
- package/extensions/compact-tools-transcript.ts +93 -0
- package/extensions/compact-tools-viewport.ts +5 -3
- package/extensions/compact-tools.ts +93 -81
- package/package.json +3 -3
- package/release-notes.json +42 -0
package/README.md
CHANGED
|
@@ -23,8 +23,9 @@ Requires Node.js 22.19+ and Pi 0.85.1+. **Pi 1.0.2+ recommended** for native ren
|
|
|
23
23
|
| **Claude** | Grouped reads/searches, `⎿` results, edit previews | `/compact-tools claude` |
|
|
24
24
|
| **Codex** | `• Explored` groups, `• Ran` output, highlighted diffs | `/compact-tools codex` |
|
|
25
25
|
| **Compact** (default) | One row per call: status, duration, line count, and diff | `/compact-tools compact` |
|
|
26
|
+
| **Off / Pi native** | Pi's original rows, thinking display, and working indicator | `/compact-tools off` |
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
Every style, including **off**, redraws existing rows immediately, even while tools run. No reload or interruption is needed. Only read-only exploration is grouped; commands that write or execute work keep separate rows.
|
|
28
29
|
|
|
29
30
|
### Claude
|
|
30
31
|
|
|
@@ -69,10 +70,10 @@ The two failed edits are intentional. Reload/resume preserves summaries from nes
|
|
|
69
70
|
/compact-tools claude project # save for this trusted project
|
|
70
71
|
/compact-tools codex global # save globally
|
|
71
72
|
/compact-tools status # effective settings and paths
|
|
72
|
-
/compact-tools off # restore Pi's native
|
|
73
|
+
/compact-tools off # restore Pi's native UI live, even while streaming
|
|
73
74
|
```
|
|
74
75
|
|
|
75
|
-
Without a scope, changes are saved where the style is configured: project or global. A trusted project's own style overrides a global save.
|
|
76
|
+
Without a scope, changes are saved where the style is configured: project or global. A trusted project's own style overrides a global save. `/compact-tools off` immediately restores native tool rows, unmodified thinking, Pi's working indicator and keyboard behavior, with no grouping or silent hiding—even while a response or tool is running. No reload, abort, or restart occurs. Your selected theme, tool execution, and session history are not changed. Use `/compact-tools compact`, `claude`, or `codex` to enable it again, also live. Your silent-mode choice is retained for when you re-enable a style; `/silent` while off only updates that saved choice, without hiding native UI.
|
|
76
77
|
|
|
77
78
|
- Click a row/group or press `Ctrl+O` to expand/collapse.
|
|
78
79
|
- `Ctrl+T` changes the thinking display, not the model's reasoning effort.
|
|
@@ -251,7 +251,6 @@ export class SilentActivityAnimator {
|
|
|
251
251
|
|
|
252
252
|
constructor(private readonly pi: ExtensionAPI) {
|
|
253
253
|
pi.on("agent_start", () => {
|
|
254
|
-
if (!this.context) return;
|
|
255
254
|
this.running = true;
|
|
256
255
|
this.answering = false;
|
|
257
256
|
this.refresh();
|
|
@@ -265,7 +264,6 @@ export class SilentActivityAnimator {
|
|
|
265
264
|
});
|
|
266
265
|
pi.on("tool_execution_start", () => this.setAnswering(false));
|
|
267
266
|
pi.on("turn_start", () => {
|
|
268
|
-
if (!this.context) return;
|
|
269
267
|
this.running = true;
|
|
270
268
|
this.answering = false;
|
|
271
269
|
this.refresh();
|
|
@@ -274,17 +272,16 @@ export class SilentActivityAnimator {
|
|
|
274
272
|
// Keep the shared clock alive until Pi declares final settlement, as the
|
|
275
273
|
// working-label controller already does.
|
|
276
274
|
pi.on("agent_settled", () => {
|
|
277
|
-
if (!this.context) return;
|
|
278
275
|
this.running = false;
|
|
279
276
|
this.refresh();
|
|
280
277
|
});
|
|
281
278
|
}
|
|
282
279
|
|
|
283
|
-
bind(ctx: ExtensionContext): void {
|
|
280
|
+
bind(ctx: ExtensionContext, resume = false): void {
|
|
284
281
|
this.hide();
|
|
285
282
|
this.context = ctx;
|
|
286
283
|
this.running = ctx.isIdle?.() === false;
|
|
287
|
-
this.answering = false;
|
|
284
|
+
if (!resume) this.answering = false;
|
|
288
285
|
}
|
|
289
286
|
|
|
290
287
|
setEnabled(enabled: boolean): void {
|
|
@@ -293,10 +290,12 @@ export class SilentActivityAnimator {
|
|
|
293
290
|
}
|
|
294
291
|
|
|
295
292
|
dispose(): void {
|
|
296
|
-
this.hide();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
293
|
+
try { this.hide(); }
|
|
294
|
+
finally {
|
|
295
|
+
this.context = undefined;
|
|
296
|
+
this.running = false;
|
|
297
|
+
this.enabled = false;
|
|
298
|
+
}
|
|
300
299
|
}
|
|
301
300
|
|
|
302
301
|
/** Guarded like the progress label: a host with a partial API keeps the default color. */
|
|
@@ -305,7 +304,7 @@ export class SilentActivityAnimator {
|
|
|
305
304
|
}
|
|
306
305
|
|
|
307
306
|
private setAnswering(answering: boolean): void {
|
|
308
|
-
if (
|
|
307
|
+
if (this.answering === answering) return;
|
|
309
308
|
this.answering = answering;
|
|
310
309
|
this.refresh();
|
|
311
310
|
}
|
|
@@ -1,29 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* One clock for tool indicators, the working label, and silent activity. The
|
|
3
|
+
* fastest subscriber sets its cadence; slower animations share those frames,
|
|
4
|
+
* keeping their average period with a remainder instead of repainting between
|
|
5
|
+
* indicator frames. With no indicators it returns to the original 80ms cadence.
|
|
6
6
|
*/
|
|
7
7
|
export const TICK_MS = 80;
|
|
8
8
|
|
|
9
|
-
type
|
|
10
|
-
|
|
11
|
-
const listeners = new Set<Listener>();
|
|
9
|
+
type Subscription = { listener: () => void; period: number; elapsed: number };
|
|
10
|
+
const listeners = new Set<Subscription>();
|
|
12
11
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
12
|
+
let cadence = 0;
|
|
13
|
+
|
|
14
|
+
function reschedule(): void {
|
|
15
|
+
const next = listeners.size ? Math.min(...[...listeners].map(({ period }) => period)) : 0;
|
|
16
|
+
if (next === cadence) return;
|
|
17
|
+
if (timer) clearInterval(timer);
|
|
18
|
+
timer = undefined;
|
|
19
|
+
cadence = next;
|
|
20
|
+
if (!next) return;
|
|
21
|
+
timer = setInterval(() => {
|
|
22
|
+
// Capture the elapsed step: a subscriber may change the cadence or unsubscribe
|
|
23
|
+
// another callback while this dispatch is in progress.
|
|
24
|
+
const step = cadence;
|
|
25
|
+
for (const entry of [...listeners]) {
|
|
26
|
+
if (!listeners.has(entry)) continue;
|
|
27
|
+
entry.elapsed += step;
|
|
28
|
+
if (entry.elapsed < entry.period) continue;
|
|
29
|
+
entry.elapsed %= entry.period;
|
|
30
|
+
entry.listener();
|
|
31
|
+
}
|
|
32
|
+
}, next);
|
|
33
|
+
timer.unref?.();
|
|
34
|
+
}
|
|
13
35
|
|
|
14
|
-
/**
|
|
15
|
-
export function onTick(listener:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}, TICK_MS);
|
|
21
|
-
timer.unref?.();
|
|
22
|
-
}
|
|
36
|
+
/** Subscribe until disposed. No timer survives the last subscriber. */
|
|
37
|
+
export function onTick(listener: () => void, period = TICK_MS): () => void {
|
|
38
|
+
if (!Number.isFinite(period) || period <= 0) throw new RangeError("Animation period must be positive and finite");
|
|
39
|
+
const entry: Subscription = { listener, period, elapsed: 0 };
|
|
40
|
+
listeners.add(entry);
|
|
41
|
+
reschedule();
|
|
23
42
|
return () => {
|
|
24
|
-
listeners.delete(
|
|
25
|
-
|
|
26
|
-
clearInterval(timer);
|
|
27
|
-
timer = undefined;
|
|
43
|
+
listeners.delete(entry);
|
|
44
|
+
reschedule();
|
|
28
45
|
};
|
|
29
46
|
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { DISPLAY_STYLES, type CompactToolsConfig, type DisplayStyle } from "./compact-tools-types.ts";
|
|
6
6
|
import { loadConfig } from "./compact-tools-config.ts";
|
|
7
7
|
|
|
8
8
|
type Scope = "global" | "project";
|
|
9
9
|
const SCOPES: readonly Scope[] = ["global", "project"];
|
|
10
|
-
const HELP = "Usage: /compact-tools [compact|claude|codex|off] [global|project]\nWithout a scope the style is saved globally, or to this project when its settings choose the style.\n/compact-tools status · /compact-tools help\nCtrl+O expands rows; /silent toggles quiet mode.
|
|
10
|
+
const HELP = "Usage: /compact-tools [compact|claude|codex|off] [global|project]\nWithout a scope the style is saved globally, or to this project when its settings choose the style.\n/compact-tools status · /compact-tools help\nCtrl+O expands rows; /silent toggles quiet mode. All styles, including off, switch at once, even while tools run.";
|
|
11
11
|
const BUSY = "Wait for the current response to finish before changing tool styles.";
|
|
12
12
|
const CHOICES: Record<DisplayStyle, string> = {
|
|
13
13
|
compact: "Compact — one expandable row per tool call",
|
|
14
14
|
claude: "Claude — group reads and searches",
|
|
15
15
|
codex: "Codex — explored groups and output tails, like Codex CLI",
|
|
16
|
-
off: "Off — restore Pi's
|
|
16
|
+
off: "Off — restore Pi's native UI",
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
export function configPath(cwd: string, scope: Scope): string {
|
|
@@ -78,15 +78,10 @@ function errorMessage(error: unknown): string {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Draw the saved style in place, without a reload, and say whether it could: a switch
|
|
81
|
-
* between
|
|
82
|
-
*
|
|
81
|
+
* between any style, including native/off, changes only UI routing. It applies
|
|
82
|
+
* at once while a response runs, with no tool registry or transcript changes.
|
|
83
83
|
*/
|
|
84
|
-
export type ApplyStyle = (cwd: string, trusted: boolean) => boolean;
|
|
85
|
-
|
|
86
|
-
/** Whether a switch needs Pi to reload: only off hands tools to or takes them from Pi's own renderers. */
|
|
87
|
-
function needsReload(from: DisplayStyle, to: DisplayStyle): boolean {
|
|
88
|
-
return from === "off" || to === "off";
|
|
89
|
-
}
|
|
84
|
+
export type ApplyStyle = (cwd: string, trusted: boolean, ctx: ExtensionContext) => boolean;
|
|
90
85
|
|
|
91
86
|
export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => CompactToolsConfig, applyStyle?: ApplyStyle): void {
|
|
92
87
|
pi.registerCommand("compact-tools", {
|
|
@@ -122,9 +117,9 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
|
|
|
122
117
|
ctx.ui.notify(HELP, "warning");
|
|
123
118
|
return;
|
|
124
119
|
}
|
|
125
|
-
//
|
|
120
|
+
// Only a host/integration without live UI routing requires an idle reload.
|
|
126
121
|
const named = parts[0] as DisplayStyle | undefined;
|
|
127
|
-
if (named && !ctx.isIdle() &&
|
|
122
|
+
if (named && !ctx.isIdle() && !applyStyle) {
|
|
128
123
|
ctx.ui.notify(BUSY, "warning");
|
|
129
124
|
return;
|
|
130
125
|
}
|
|
@@ -155,7 +150,7 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
|
|
|
155
150
|
return;
|
|
156
151
|
}
|
|
157
152
|
// The agent may have started while the user was choosing in a dialog.
|
|
158
|
-
const reloads =
|
|
153
|
+
const reloads = !applyStyle;
|
|
159
154
|
if (reloads && !ctx.isIdle()) {
|
|
160
155
|
ctx.ui.notify(BUSY, "warning");
|
|
161
156
|
return;
|
|
@@ -175,7 +170,7 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
|
|
|
175
170
|
ctx.ui.notify(`Saved ${style} to global settings, but this project keeps its own ${effective} style. Run /compact-tools ${style} to switch it here.`, "warning");
|
|
176
171
|
return;
|
|
177
172
|
}
|
|
178
|
-
if (!reloads && applyStyle!(ctx.cwd, ctx.isProjectTrusted())) {
|
|
173
|
+
if (!reloads && applyStyle!(ctx.cwd, ctx.isProjectTrusted(), ctx)) {
|
|
179
174
|
ctx.ui.notify(`Switched to ${style} style (saved to ${where}).`, "info");
|
|
180
175
|
return;
|
|
181
176
|
}
|
|
@@ -183,7 +178,9 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
|
|
|
183
178
|
ctx.ui.notify(`Saved ${style} style to ${where}. It applies once the response finishes; run /reload then.`, "warning");
|
|
184
179
|
return;
|
|
185
180
|
}
|
|
186
|
-
ctx.ui.notify(
|
|
181
|
+
ctx.ui.notify(style === "off"
|
|
182
|
+
? `Restoring Pi's native UI (saved to ${where}). Reloading…`
|
|
183
|
+
: `Switching to ${style} style (saved to ${where}). Reloading…`, "info");
|
|
187
184
|
// Reloading makes this ctx stale, so a failure is reported through the UI held from before.
|
|
188
185
|
const ui = ctx.ui;
|
|
189
186
|
try {
|
|
@@ -324,8 +324,9 @@ export class ToolGroupController {
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
dispose(): void {
|
|
327
|
-
this.context
|
|
327
|
+
const context = this.context;
|
|
328
328
|
this.context = undefined;
|
|
329
|
+
context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
329
330
|
}
|
|
330
331
|
|
|
331
332
|
private makeGroup(rows: ToolRowLike[], members: unknown[], tui: { requestRender(): void }): Component {
|
|
@@ -3,6 +3,12 @@ import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
4
4
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
5
5
|
|
|
6
|
+
/** Historical/transcribed read arguments may contain numeric strings or null. Display only. */
|
|
7
|
+
export function readStartLine(value: unknown): number {
|
|
8
|
+
if (typeof value === "string" && value.trim() !== "") value = Number(value);
|
|
9
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
function pathFrom(args: ToolArgs): string {
|
|
7
13
|
const value = args.path ?? args.file_path;
|
|
8
14
|
return typeof value === "string" ? normalizeLineEndings(value) : "";
|
|
@@ -96,28 +96,24 @@ export class ProgressController {
|
|
|
96
96
|
private level: ThinkingLevel = "medium";
|
|
97
97
|
|
|
98
98
|
constructor(private readonly pi: ExtensionAPI) {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// any future handler added here — out of the detached path.
|
|
99
|
+
// Keep only the current phase/active calls while detached. No UI work or
|
|
100
|
+
// clock runs, but re-enabling mid-stream can resume the correct label.
|
|
102
101
|
pi.on("agent_start", () => {
|
|
103
|
-
if (!this.context) return;
|
|
104
102
|
this.setMessage("Thinking…");
|
|
105
103
|
});
|
|
106
104
|
pi.on("message_update", (event) => {
|
|
107
|
-
|
|
108
|
-
const type = event.assistantMessageEvent.type;
|
|
105
|
+
const type = event.assistantMessageEvent?.type;
|
|
109
106
|
if (type === "thinking_start" || type === "thinking_delta") this.setMessage("Thinking…");
|
|
110
107
|
else if (type === "text_start" || type === "text_delta") this.setMessage("Responding…");
|
|
111
108
|
else if (type === "toolcall_start" || type === "toolcall_delta") this.setMessage("Preparing tools…");
|
|
112
109
|
});
|
|
113
110
|
pi.on("tool_execution_start", (event) => {
|
|
114
|
-
if (
|
|
111
|
+
if (typeof event.toolName !== "string") return;
|
|
115
112
|
const message = formatToolProgress(event.toolName, event.args ?? {});
|
|
116
113
|
this.activeTools.set(event.toolCallId, message);
|
|
117
114
|
this.setMessage(message);
|
|
118
115
|
});
|
|
119
116
|
pi.on("tool_execution_end", (event) => {
|
|
120
|
-
if (!this.context) return;
|
|
121
117
|
this.activeTools.delete(event.toolCallId);
|
|
122
118
|
const remaining = [...this.activeTools.values()].at(-1);
|
|
123
119
|
this.setMessage(remaining ?? "Processing results…");
|
|
@@ -128,7 +124,6 @@ export class ProgressController {
|
|
|
128
124
|
// so releasing the label at agent_end makes a retried turn reappear under Pi's
|
|
129
125
|
// default label. Release it at the final settlement instead.
|
|
130
126
|
pi.on("agent_settled", () => {
|
|
131
|
-
if (!this.context) return;
|
|
132
127
|
this.activeTools.clear();
|
|
133
128
|
this.clearMessage();
|
|
134
129
|
});
|
|
@@ -146,30 +141,34 @@ export class ProgressController {
|
|
|
146
141
|
if (typeof this.pi.getThinkingLevel === "function") this.level = this.pi.getThinkingLevel();
|
|
147
142
|
}
|
|
148
143
|
|
|
149
|
-
bind(ctx: ExtensionContext): void {
|
|
144
|
+
bind(ctx: ExtensionContext, resume = false): void {
|
|
150
145
|
this.stopAnimation();
|
|
151
146
|
this.context = ctx;
|
|
152
147
|
this.readLevel();
|
|
153
|
-
|
|
148
|
+
const message = resume && ctx.isIdle?.() === false ? this.message ?? "Thinking…" : undefined;
|
|
149
|
+
if (!message) this.activeTools.clear();
|
|
154
150
|
this.message = undefined;
|
|
155
151
|
this.frames = [];
|
|
156
152
|
this.frame = 0;
|
|
157
153
|
ctx.ui.setWorkingVisible(true);
|
|
158
154
|
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
155
|
+
if (message) this.setMessage(message);
|
|
159
156
|
}
|
|
160
157
|
|
|
161
|
-
dispose(): void {
|
|
158
|
+
dispose(preservePhase = false): void {
|
|
159
|
+
const context = this.context;
|
|
160
|
+
this.context = undefined;
|
|
162
161
|
this.stopAnimation();
|
|
163
|
-
this.activeTools.clear();
|
|
164
|
-
this.
|
|
165
|
-
this.context?.ui.setWorkingIndicator();
|
|
166
|
-
this.message = undefined;
|
|
162
|
+
if (!preservePhase) this.activeTools.clear();
|
|
163
|
+
if (!preservePhase) this.message = undefined;
|
|
167
164
|
this.frames = [];
|
|
168
|
-
|
|
165
|
+
try { context?.ui.setWorkingMessage(); }
|
|
166
|
+
finally { context?.ui.setWorkingIndicator(); }
|
|
169
167
|
}
|
|
170
168
|
|
|
171
169
|
private setMessage(message: string): void {
|
|
172
|
-
if (!this.context
|
|
170
|
+
if (!this.context) { this.message = message; return; }
|
|
171
|
+
if (this.message === message && this.stopTicking) return;
|
|
173
172
|
this.message = message;
|
|
174
173
|
this.frames = [];
|
|
175
174
|
this.frame = 0;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
1
3
|
import {
|
|
2
|
-
createBashToolDefinition, createEditToolDefinition, createFindToolDefinition,
|
|
4
|
+
getPackageDir, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition,
|
|
3
5
|
createGrepToolDefinition, createLsToolDefinition, createPowerShellToolDefinition,
|
|
4
6
|
createReadToolDefinition, createWriteToolDefinition,
|
|
5
7
|
type ToolDefinition,
|
|
@@ -22,6 +24,20 @@ for (const [name, factory] of Object.entries(factories)) {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
let nativeTimerLoad: Promise<void> | undefined;
|
|
28
|
+
/** Optional bundled renderer: absent on old hosts, and never executes a tool. */
|
|
29
|
+
export function loadNativeTimerRenderers(): Promise<void> {
|
|
30
|
+
return nativeTimerLoad ??= (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const url = pathToFileURL(join(getPackageDir(), "dist/extensions/codemode/renderer.js"));
|
|
33
|
+
const { codemodeRenderers } = await import(url.href);
|
|
34
|
+
if (typeof codemodeRenderers?.renderCall === "function" && typeof codemodeRenderers?.renderResult === "function") {
|
|
35
|
+
builtinRenderers.set("codemode", { call: String(codemodeRenderers.renderCall), result: String(codemodeRenderers.renderResult) });
|
|
36
|
+
}
|
|
37
|
+
} catch { /* Optional host internals must not prevent UI loading. */ }
|
|
38
|
+
})();
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/** Conservative fallback on legacy hosts without source metadata. */
|
|
26
42
|
export function hasAuthorRenderers(name: string, definition: ToolDefinition<any, any, any> | undefined): boolean {
|
|
27
43
|
if (!definition) return false;
|
|
@@ -30,6 +46,25 @@ export function hasAuthorRenderers(name: string, definition: ToolDefinition<any,
|
|
|
30
46
|
|| (definition.renderResult && String(definition.renderResult) !== builtin?.result));
|
|
31
47
|
}
|
|
32
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Native shell rows own a one-second elapsed timer in their renderer state. Pause
|
|
51
|
+
* it when leaving native rendering (without fabricating a final result), and seed
|
|
52
|
+
* its start from the observed execution time when entering native rendering late.
|
|
53
|
+
* Guard by the exact host renderer fingerprint: third-party state is not ours.
|
|
54
|
+
*/
|
|
55
|
+
export function syncNativeToolTimer(row: object, name: string, base: ToolDefinition<any, any, any> | undefined, native: boolean, timing?: { startedAt: number; endedAt?: number }): void {
|
|
56
|
+
if (!base || !builtinRenderers.has(name) || !["bash", "powershell", "codemode"].includes(name) || hasAuthorRenderers(name, base)) return;
|
|
57
|
+
const state = (row as { rendererState?: { startedAt?: number; endedAt?: number; interval?: ReturnType<typeof setInterval> } }).rendererState;
|
|
58
|
+
if (!state) return;
|
|
59
|
+
if (native) {
|
|
60
|
+
if (state.startedAt === undefined && timing) state.startedAt = timing.startedAt;
|
|
61
|
+
if (state.endedAt === undefined && timing?.endedAt !== undefined) state.endedAt = timing.endedAt;
|
|
62
|
+
} else if (state.interval !== undefined) {
|
|
63
|
+
clearInterval(state.interval);
|
|
64
|
+
state.interval = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
33
68
|
/** Change only a transcript row's renderer shell, never a tool registry entry. */
|
|
34
69
|
export function refreshRowShell(row: object): void {
|
|
35
70
|
const target = row as {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { DEFAULT_CONFIG } from "./compact-tools-config.ts";
|
|
2
|
+
import { onTick } from "./compact-tools-clock.ts";
|
|
3
3
|
import { isSilent } from "./compact-tools-silent.ts";
|
|
4
4
|
import {
|
|
5
5
|
SUPPORTED_TOOL_SET,
|
|
6
|
-
type BuiltInDefinition,
|
|
7
6
|
type CompactToolName,
|
|
8
7
|
type CompactToolsConfig,
|
|
9
8
|
type RenderContext,
|
|
@@ -27,7 +26,6 @@ export function validDurationMs(value: unknown): value is number {
|
|
|
27
26
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
28
27
|
}
|
|
29
28
|
type Indicator = { invalidate: () => void; drawnAt: number };
|
|
30
|
-
type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
|
|
31
29
|
type SharedState = typeof globalThis & {
|
|
32
30
|
[EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
|
|
33
31
|
};
|
|
@@ -38,7 +36,7 @@ export class ToolRuntime {
|
|
|
38
36
|
private readonly executionTimings: Map<string, ExecutionTiming>;
|
|
39
37
|
private readonly indicators = new Map<string, Indicator>();
|
|
40
38
|
private indicatorFrame = 0;
|
|
41
|
-
private
|
|
39
|
+
private stopIndicatorTicks: (() => void) | undefined;
|
|
42
40
|
/** Pi's frame request, once bound to its terminal; rows then repaint without being rebuilt. */
|
|
43
41
|
private requestRender: (() => void) | undefined;
|
|
44
42
|
/** Whether the agent is working; undefined until the host says, which counts as working. */
|
|
@@ -49,12 +47,6 @@ export class ToolRuntime {
|
|
|
49
47
|
* more than one run's calls.
|
|
50
48
|
*/
|
|
51
49
|
private readonly currentCalls = new Set<string>();
|
|
52
|
-
/**
|
|
53
|
-
* Calls a tool is making through `ctx.executeTool()`, as codemode scripts do. They
|
|
54
|
-
* never get a row of their own, so no execution timing is kept for them.
|
|
55
|
-
* The independent nested-summary observer keeps only their outcome counts.
|
|
56
|
-
*/
|
|
57
|
-
private readonly nestedCalls = new Set<string>();
|
|
58
50
|
|
|
59
51
|
constructor(private readonly clock: () => number = () => performance.now()) {
|
|
60
52
|
const shared = globalThis as SharedState;
|
|
@@ -111,7 +103,6 @@ export class ToolRuntime {
|
|
|
111
103
|
setBusy(busy: boolean): void {
|
|
112
104
|
if (busy && this.busy !== true) {
|
|
113
105
|
this.currentCalls.clear();
|
|
114
|
-
this.nestedCalls.clear();
|
|
115
106
|
}
|
|
116
107
|
this.busy = busy;
|
|
117
108
|
if (busy) return;
|
|
@@ -129,12 +120,6 @@ export class ToolRuntime {
|
|
|
129
120
|
this.currentCalls.add(toolCallId);
|
|
130
121
|
}
|
|
131
122
|
|
|
132
|
-
/** A call another tool made began (`running`) or finished; Pi says so with `parentToolCallId`. */
|
|
133
|
-
noteNestedCall(toolCallId: string, running: boolean): void {
|
|
134
|
-
if (running) this.nestedCalls.add(toolCallId);
|
|
135
|
-
else this.nestedCalls.delete(toolCallId);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
123
|
/**
|
|
139
124
|
* Whether a call can be running: only one Pi announced in the run going on. A
|
|
140
125
|
* row restored from the session, one left without a result when its run ended,
|
|
@@ -158,10 +143,7 @@ export class ToolRuntime {
|
|
|
158
143
|
} else {
|
|
159
144
|
this.indicators.set(toolCallId, { invalidate, drawnAt: this.clock() });
|
|
160
145
|
}
|
|
161
|
-
|
|
162
|
-
this.indicatorTimer = setInterval(() => this.tick(), INDICATOR_INTERVAL_MS);
|
|
163
|
-
this.indicatorTimer.unref?.();
|
|
164
|
-
}
|
|
146
|
+
this.stopIndicatorTicks ??= onTick(() => this.tick(), INDICATOR_INTERVAL_MS);
|
|
165
147
|
return this.indicatorFrame;
|
|
166
148
|
}
|
|
167
149
|
|
|
@@ -211,12 +193,7 @@ export class ToolRuntime {
|
|
|
211
193
|
return state;
|
|
212
194
|
}
|
|
213
195
|
|
|
214
|
-
/**
|
|
215
|
-
* Record when a tool this extension does not wrap starts and ends. Pi reports
|
|
216
|
-
* both to extensions before it redraws the row, and the row draws its status
|
|
217
|
-
* dot before its result, so without this a finished custom tool kept the
|
|
218
|
-
* running dot. Wrapped built-ins already have timings, which are kept.
|
|
219
|
-
*/
|
|
196
|
+
/** Record top-level execution events without wrapping model-facing tools. */
|
|
220
197
|
noteExecutionStart(toolCallId: string): void {
|
|
221
198
|
if (!this.executionTimings.has(toolCallId)) this.executionTimings.set(toolCallId, { startedAt: Date.now() });
|
|
222
199
|
this.trimTimings();
|
|
@@ -230,24 +207,6 @@ export class ToolRuntime {
|
|
|
230
207
|
this.trimTimings();
|
|
231
208
|
}
|
|
232
209
|
|
|
233
|
-
createTimedExecute(definition: BuiltInDefinition): TimedExecute {
|
|
234
|
-
const execute = definition.execute as TimedExecute;
|
|
235
|
-
return async (...args: any[]) => {
|
|
236
|
-
const toolCallId = typeof args[0] === "string" && !this.nestedCalls.has(args[0]) ? args[0] : undefined;
|
|
237
|
-
const startedAt = (toolCallId ? this.executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
|
|
238
|
-
if (toolCallId && !this.executionTimings.has(toolCallId)) {
|
|
239
|
-
this.executionTimings.set(toolCallId, { startedAt });
|
|
240
|
-
this.trimTimings();
|
|
241
|
-
}
|
|
242
|
-
try {
|
|
243
|
-
return await execute.apply(definition, args);
|
|
244
|
-
} finally {
|
|
245
|
-
const timing = toolCallId ? this.executionTimings.get(toolCallId) : undefined;
|
|
246
|
-
if (timing) timing.endedAt = Date.now();
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
210
|
private tick(): void {
|
|
252
211
|
this.indicatorFrame++;
|
|
253
212
|
const requestRender = this.requestRender;
|
|
@@ -269,15 +228,15 @@ export class ToolRuntime {
|
|
|
269
228
|
|
|
270
229
|
private removeIndicator(toolCallId: string): void {
|
|
271
230
|
this.indicators.delete(toolCallId);
|
|
272
|
-
if (this.indicators.size > 0
|
|
273
|
-
|
|
274
|
-
this.
|
|
231
|
+
if (this.indicators.size > 0) return;
|
|
232
|
+
this.stopIndicatorTicks?.();
|
|
233
|
+
this.stopIndicatorTicks = undefined;
|
|
275
234
|
this.indicatorFrame = 0;
|
|
276
235
|
}
|
|
277
236
|
|
|
278
237
|
private stopIndicators(): void {
|
|
279
|
-
|
|
280
|
-
this.
|
|
238
|
+
this.stopIndicatorTicks?.();
|
|
239
|
+
this.stopIndicatorTicks = undefined;
|
|
281
240
|
this.indicators.clear();
|
|
282
241
|
this.indicatorFrame = 0;
|
|
283
242
|
}
|
|
@@ -311,9 +311,9 @@ export class SilentModeController {
|
|
|
311
311
|
return sharedState().enabled;
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
bind(ctx: ExtensionContext, mode: DisplayMode, reload: boolean): void {
|
|
314
|
+
bind(ctx: ExtensionContext, mode: DisplayMode, reload: boolean, resume = false): void {
|
|
315
315
|
this.context = ctx;
|
|
316
|
-
this.activity.bind(ctx);
|
|
316
|
+
this.activity.bind(ctx, resume);
|
|
317
317
|
// An invisible widget is the extension API's way to reach Pi's TUI, and
|
|
318
318
|
// through it the chat container whose notices silent mode hides.
|
|
319
319
|
ctx.ui.setWidget(WIDGET_KEY, (tui) => {
|
|
@@ -326,13 +326,16 @@ export class SilentModeController {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
dispose(): void {
|
|
329
|
-
this.
|
|
330
|
-
// A patched Pi prototype outlives this extension instance. Disable hiding
|
|
331
|
-
// while no instance is bound, but keep the choice for a subsequent /reload.
|
|
332
|
-
sharedState().active = false;
|
|
333
|
-
this.context?.ui.setStatus(STATUS_KEY, undefined);
|
|
334
|
-
this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
329
|
+
const context = this.context;
|
|
335
330
|
this.context = undefined;
|
|
331
|
+
// Detach state before calling host UI: an exception must not reactivate
|
|
332
|
+
// silent hiding or its animation while the extension is off.
|
|
333
|
+
sharedState().active = false;
|
|
334
|
+
try { this.activity.dispose(); }
|
|
335
|
+
finally {
|
|
336
|
+
try { context?.ui.setStatus(STATUS_KEY, undefined); }
|
|
337
|
+
finally { context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" }); }
|
|
338
|
+
}
|
|
336
339
|
}
|
|
337
340
|
|
|
338
341
|
private apply(enabled: boolean): boolean {
|
|
@@ -201,15 +201,18 @@ function getInitialThinkingPhase(): ThinkingPhase {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
export class ThinkingCycleController {
|
|
204
|
+
private context: ExtensionContext | undefined;
|
|
204
205
|
private phase: ThinkingPhase = "summary";
|
|
205
206
|
private unsubscribe: (() => void) | undefined;
|
|
206
207
|
private theme: Theme | undefined;
|
|
207
208
|
private hasToggleableThinking = false;
|
|
208
209
|
private refreshThinking: (() => void) | undefined;
|
|
209
210
|
|
|
210
|
-
constructor(pi: ExtensionAPI) {
|
|
211
|
+
constructor(pi: ExtensionAPI, enabled: () => boolean = () => true) {
|
|
211
212
|
pi.registerMarkdownTransformer((markdown, context) => {
|
|
212
|
-
|
|
213
|
+
// Off also applies before session_start, during transcript reconstruction
|
|
214
|
+
// and HTML exports. Do not summarize or restyle native thinking there.
|
|
215
|
+
if (!enabled() || context.messageType !== "assistant-thinking") return markdown;
|
|
213
216
|
const sections = splitThinkingSections(markdown);
|
|
214
217
|
this.hasToggleableThinking ||= sectionsHaveDetail(sections);
|
|
215
218
|
const theme = this.theme;
|
|
@@ -241,6 +244,7 @@ export class ThinkingCycleController {
|
|
|
241
244
|
|
|
242
245
|
bind(ctx: ExtensionContext): void {
|
|
243
246
|
this.unsubscribe?.();
|
|
247
|
+
this.context = ctx;
|
|
244
248
|
this.theme = ctx.ui.theme;
|
|
245
249
|
this.hasToggleableThinking = false;
|
|
246
250
|
// Match Pi's persisted host visibility after startup or /reload. When the
|
|
@@ -277,11 +281,15 @@ export class ThinkingCycleController {
|
|
|
277
281
|
});
|
|
278
282
|
}
|
|
279
283
|
|
|
280
|
-
dispose(): void {
|
|
281
|
-
this.
|
|
284
|
+
dispose(restoreNative = false): void {
|
|
285
|
+
const context = this.context;
|
|
286
|
+
const unsubscribe = this.unsubscribe;
|
|
287
|
+
this.context = undefined;
|
|
282
288
|
this.unsubscribe = undefined;
|
|
283
289
|
this.refreshThinking = undefined;
|
|
284
290
|
this.hasToggleableThinking = false;
|
|
285
291
|
this.theme = undefined;
|
|
292
|
+
try { unsubscribe?.(); }
|
|
293
|
+
finally { if (restoreNative) context?.ui.setHiddenThinkingLabel(); }
|
|
286
294
|
}
|
|
287
295
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ToolExecutionComponent, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { RowRenderers, ToolRow } from "./compact-tools-custom.ts";
|
|
4
|
+
import { refreshRowShell, syncNativeToolTimer } from "./compact-tools-rendering.ts";
|
|
5
|
+
import type { ToolRuntime } from "./compact-tools-runtime.ts";
|
|
6
|
+
import { findChat, isKind } from "./compact-tools-silent.ts";
|
|
7
|
+
|
|
8
|
+
type Definition = ToolDefinition<any, any, any> | undefined;
|
|
9
|
+
export interface UIFailure { operation: string; error: unknown }
|
|
10
|
+
|
|
11
|
+
/** Attempt every independent cleanup/row action; the caller reports failures once. */
|
|
12
|
+
export function attemptUI(failures: UIFailure[], operation: string, action: () => void): void {
|
|
13
|
+
try { action(); } catch (error) { failures.push({ operation, error }); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TranscriptOptions {
|
|
17
|
+
runtime: ToolRuntime;
|
|
18
|
+
publicRendering: boolean;
|
|
19
|
+
resolve(row: ToolRow): RowRenderers | undefined;
|
|
20
|
+
definition(name: string, base: Definition): Definition;
|
|
21
|
+
unwrap(definition: Definition): Definition;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const WIDGET_KEY = "compact-tools-render";
|
|
25
|
+
const emptyWidget = () => ({ render: () => [], invalidate() {} });
|
|
26
|
+
|
|
27
|
+
/** Own the TUI probe and the private host-row reconciliation boundary together. */
|
|
28
|
+
export function createTranscriptRenderer(options: TranscriptOptions) {
|
|
29
|
+
let context: ExtensionContext | undefined;
|
|
30
|
+
let refresh: ((failures: UIFailure[]) => void) | undefined;
|
|
31
|
+
|
|
32
|
+
const reconcile = (tui: TUI, failures: UIFailure[]) => {
|
|
33
|
+
const visit = (parent: { children?: unknown[] }) => {
|
|
34
|
+
for (const child of parent.children ?? []) {
|
|
35
|
+
if (!child || typeof child !== "object") continue;
|
|
36
|
+
attemptUI(failures, "tool row", () => {
|
|
37
|
+
if (!isKind(child, ToolExecutionComponent)) { visit(child); return; }
|
|
38
|
+
const row = child as ToolRow & { toolCallId: string; updateDisplay?(): void; callRendererComponent?: unknown; resultRendererComponent?: unknown };
|
|
39
|
+
const base = options.unwrap(row.toolDefinition);
|
|
40
|
+
syncNativeToolTimer(row, row.toolName, base, !options.resolve({ toolName: row.toolName, toolDefinition: base }), options.runtime.timing(row.toolCallId));
|
|
41
|
+
if (options.publicRendering) {
|
|
42
|
+
row.toolDefinition = options.definition(row.toolName, base);
|
|
43
|
+
// Components must not cross native/compact renderer ownership,
|
|
44
|
+
// even when both definitions request the same shell.
|
|
45
|
+
row.callRendererComponent = undefined;
|
|
46
|
+
row.resultRendererComponent = undefined;
|
|
47
|
+
}
|
|
48
|
+
refreshRowShell(row);
|
|
49
|
+
row.updateDisplay?.();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
attemptUI(failures, "transcript", () => {
|
|
54
|
+
const chat = findChat(tui);
|
|
55
|
+
if (chat && chat.children.length > 0) {
|
|
56
|
+
visit(chat);
|
|
57
|
+
attemptUI(failures, "repaint", () => tui.requestRender());
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
bind(ctx: ExtensionContext, failures: UIFailure[]): void {
|
|
64
|
+
context = ctx;
|
|
65
|
+
attemptUI(failures, "renderer probe", () => ctx.ui.setWidget(WIDGET_KEY, (tui) => {
|
|
66
|
+
options.runtime.bindRenderer(() => tui.requestRender());
|
|
67
|
+
refresh = (errors) => reconcile(tui, errors);
|
|
68
|
+
return emptyWidget();
|
|
69
|
+
}, { placement: "belowEditor" }));
|
|
70
|
+
refresh?.(failures);
|
|
71
|
+
},
|
|
72
|
+
refresh(failures: UIFailure[]): void { refresh?.(failures); },
|
|
73
|
+
detach(): void {
|
|
74
|
+
const previous = context;
|
|
75
|
+
context = undefined;
|
|
76
|
+
refresh = undefined;
|
|
77
|
+
options.runtime.bindRenderer(undefined);
|
|
78
|
+
previous?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
79
|
+
},
|
|
80
|
+
/** Reconcile provisional startup rows without binding runtime animation. */
|
|
81
|
+
reconcileOnce(ctx: ExtensionContext, failures: UIFailure[]): void {
|
|
82
|
+
if (refresh) { refresh(failures); return; }
|
|
83
|
+
try {
|
|
84
|
+
attemptUI(failures, "startup renderer probe", () => ctx.ui.setWidget(WIDGET_KEY, (tui) => {
|
|
85
|
+
reconcile(tui, failures);
|
|
86
|
+
return emptyWidget();
|
|
87
|
+
}, { placement: "belowEditor" }));
|
|
88
|
+
} finally {
|
|
89
|
+
attemptUI(failures, "startup renderer cleanup", () => ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" }));
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -221,12 +221,14 @@ export class ViewportKeeper {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
dispose(): void {
|
|
224
|
-
this.
|
|
224
|
+
const context = this.context;
|
|
225
|
+
const unsubscribe = this.unsubscribe;
|
|
226
|
+
this.context = undefined;
|
|
225
227
|
this.unsubscribe = undefined;
|
|
226
228
|
const state = sharedState();
|
|
227
229
|
state.pending = undefined;
|
|
228
230
|
state.tui = undefined;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
+
try { unsubscribe?.(); }
|
|
232
|
+
finally { context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" }); }
|
|
231
233
|
}
|
|
232
234
|
}
|
|
@@ -7,12 +7,11 @@ import type {
|
|
|
7
7
|
ToolDefinition,
|
|
8
8
|
ToolRenderResultOptions,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
11
10
|
import type { Component } from "@earendil-works/pi-tui";
|
|
12
11
|
import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
|
|
13
12
|
import { loadConfig } from "./compact-tools-config.ts";
|
|
14
13
|
import { paletteOf } from "./compact-tools-color.ts";
|
|
15
|
-
import { hasAuthorRenderers,
|
|
14
|
+
import { hasAuthorRenderers, loadNativeTimerRenderers, syncNativeToolTimer } from "./compact-tools-rendering.ts";
|
|
16
15
|
import { registerCompactToolsCommand } from "./compact-tools-command.ts";
|
|
17
16
|
import { clearRowResolver, installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
|
|
18
17
|
import {
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
getEditDiff,
|
|
24
23
|
getEditPatch,
|
|
25
24
|
getFileOutput,
|
|
25
|
+
readStartLine,
|
|
26
26
|
getTextResult,
|
|
27
27
|
isReadTextResult,
|
|
28
28
|
splitReadFooter,
|
|
@@ -56,7 +56,8 @@ import { chromePainter, completionPainter, paintIndicator } from "./compact-tool
|
|
|
56
56
|
import { ProgressController } from "./compact-tools-progress.ts";
|
|
57
57
|
import { showReleaseNotice } from "./compact-tools-release.ts";
|
|
58
58
|
import { ToolRuntime } from "./compact-tools-runtime.ts";
|
|
59
|
-
import {
|
|
59
|
+
import { SilentModeController } from "./compact-tools-silent.ts";
|
|
60
|
+
import { attemptUI, createTranscriptRenderer, type UIFailure } from "./compact-tools-transcript.ts";
|
|
60
61
|
import { ThinkingCycleController } from "./compact-tools-thinking.ts";
|
|
61
62
|
import { ViewportKeeper } from "./compact-tools-viewport.ts";
|
|
62
63
|
import {
|
|
@@ -72,7 +73,6 @@ const PREVIEW_DIFF_CONTEXT_LINES = 1;
|
|
|
72
73
|
|
|
73
74
|
const runtime = new ToolRuntime();
|
|
74
75
|
const nestedSummaries = new NestedCallSummaries();
|
|
75
|
-
const RENDER_WIDGET_KEY = "compact-tools-render";
|
|
76
76
|
let registeredConfiguration: string | undefined;
|
|
77
77
|
/**
|
|
78
78
|
* Whether the last session found its directory trusted. A /reload rebuilds the
|
|
@@ -278,7 +278,7 @@ function renderFileBody(
|
|
|
278
278
|
}
|
|
279
279
|
if (name === "read" && isReadTextResult(result)) {
|
|
280
280
|
const { body, footer } = splitReadFooter(output);
|
|
281
|
-
const startLine =
|
|
281
|
+
const startLine = readStartLine(args.offset);
|
|
282
282
|
return renderCodeView(body, path, theme, { startLine, footer, rail }) ?? fallback();
|
|
283
283
|
}
|
|
284
284
|
if (name === "write") return renderCodeView(output, path, theme, { rail }) ?? fallback();
|
|
@@ -509,9 +509,15 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
|
|
|
509
509
|
// Compact owns the outer margin and the result rail. An author
|
|
510
510
|
// renderer is embedded content, not another host-padded shell.
|
|
511
511
|
// Leave the field absent on legacy hosts.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
512
|
+
try {
|
|
513
|
+
value.component = authorRenderer(result, { ...options, expanded }, theme, {
|
|
514
|
+
...ctx, lastComponent: value.component, ...((ctx as { outputPad?: number }).outputPad === undefined ? {} : { outputPad: 0 }),
|
|
515
|
+
});
|
|
516
|
+
} finally {
|
|
517
|
+
// Compact owns animation; a delegated native codemode preview
|
|
518
|
+
// must not leave a second clock when later collapsed.
|
|
519
|
+
syncNativeToolTimer({ rendererState: ctx.state }, name, author, false);
|
|
520
|
+
}
|
|
515
521
|
return value.component;
|
|
516
522
|
}
|
|
517
523
|
: undefined;
|
|
@@ -552,6 +558,7 @@ type RendererAPI = ExtensionAPI & {
|
|
|
552
558
|
};
|
|
553
559
|
let publicRendering = false;
|
|
554
560
|
let sessionReady = false;
|
|
561
|
+
let publicBases = new WeakMap<object, ToolDefinition<any, any, any> | undefined>();
|
|
555
562
|
let publicOwners = new WeakMap<object, { call?: unknown; result?: unknown }>();
|
|
556
563
|
function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> | undefined): ToolDefinition<any, any, any> | undefined {
|
|
557
564
|
// Preserve next()'s result exactly once the session's configuration is known.
|
|
@@ -580,46 +587,47 @@ function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> |
|
|
|
580
587
|
const render = decide()?.renderResult ?? base?.renderResult;
|
|
581
588
|
return render!(value, options, theme, context(ctx, "result", render));
|
|
582
589
|
};
|
|
583
|
-
|
|
590
|
+
const definition = {
|
|
584
591
|
get renderShell() { return decide() ? "self" : base?.renderShell ?? "default"; },
|
|
585
592
|
get renderCall() { return decide()?.renderCall || base?.renderCall ? call : undefined; },
|
|
586
593
|
get renderResult() { return decide()?.renderResult || base?.renderResult ? result : undefined; },
|
|
587
594
|
} as ToolDefinition<any, any, any>;
|
|
595
|
+
publicBases.set(definition, base);
|
|
596
|
+
return definition;
|
|
588
597
|
}
|
|
589
598
|
let legacyResolver: ((row: ToolRow) => RowRenderers | undefined) | undefined;
|
|
590
|
-
let refreshTranscriptRows: (() => void) | undefined;
|
|
591
|
-
function invalidateRowDecisions(): void {
|
|
599
|
+
let refreshTranscriptRows: ((failures: UIFailure[]) => void) | undefined;
|
|
600
|
+
function invalidateRowDecisions(failures: UIFailure[]): void {
|
|
592
601
|
if (!publicRendering) {
|
|
593
602
|
legacyResolver = (row) => resolveCustomRow(row);
|
|
594
603
|
setRowResolver(legacyResolver);
|
|
595
604
|
}
|
|
596
|
-
refreshTranscriptRows?.();
|
|
605
|
+
refreshTranscriptRows?.(failures);
|
|
597
606
|
}
|
|
598
607
|
|
|
599
|
-
function configure(_pi: ExtensionAPI, cwd?: string, projectTrusted = false): void {
|
|
608
|
+
function configure(_pi: ExtensionAPI, cwd?: string, projectTrusted = false, failures: UIFailure[] = []): void {
|
|
600
609
|
const resolvedCwd = cwd ?? process.cwd();
|
|
601
610
|
const config = loadConfig(resolvedCwd, projectTrusted);
|
|
602
611
|
const signature = JSON.stringify([resolvedCwd, config]);
|
|
603
612
|
if (signature === registeredConfiguration) return;
|
|
604
613
|
runtime.configure(config);
|
|
605
614
|
registeredConfiguration = signature;
|
|
606
|
-
invalidateRowDecisions();
|
|
615
|
+
invalidateRowDecisions(failures);
|
|
607
616
|
}
|
|
608
617
|
|
|
609
618
|
/**
|
|
610
619
|
* Apply a style saved by /compact-tools without reloading Pi. The compact, Claude, and
|
|
611
620
|
* Codex styles share every tool registration and read the style as they draw, so the
|
|
612
621
|
* configuration is swapped and each tool row on screen is drawn again, a running one
|
|
613
|
-
* included.
|
|
622
|
+
* included. Only non-style configuration changes require a reload.
|
|
614
623
|
*/
|
|
615
|
-
function applyStyle(cwd: string, trusted: boolean): boolean {
|
|
624
|
+
function applyStyle(cwd: string, trusted: boolean, failures: UIFailure[]): boolean {
|
|
616
625
|
const config = loadConfig(cwd, trusted);
|
|
617
626
|
const current = runtime.config;
|
|
618
|
-
if (config.style === "off" || current.style === "off") return false;
|
|
619
627
|
if (JSON.stringify({ ...config, style: current.style }) !== JSON.stringify(current)) return false;
|
|
620
628
|
runtime.configure(config);
|
|
621
629
|
registeredConfiguration = JSON.stringify([cwd, config]);
|
|
622
|
-
invalidateRowDecisions();
|
|
630
|
+
invalidateRowDecisions(failures);
|
|
623
631
|
return true;
|
|
624
632
|
}
|
|
625
633
|
|
|
@@ -643,44 +651,15 @@ function madeByTool(event: object): boolean {
|
|
|
643
651
|
return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
|
|
644
652
|
}
|
|
645
653
|
|
|
646
|
-
/** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
|
|
647
|
-
function bindRenderer(ctx: ExtensionContext): void {
|
|
648
|
-
ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
|
|
649
|
-
runtime.bindRenderer(() => tui.requestRender());
|
|
650
|
-
refreshTranscriptRows = () => {
|
|
651
|
-
const visit = (parent: { children?: unknown[] }) => {
|
|
652
|
-
for (const child of parent.children ?? []) {
|
|
653
|
-
if (!child || typeof child !== "object") continue;
|
|
654
|
-
if (isKind(child, ToolExecutionComponent)) {
|
|
655
|
-
const row = child as ToolRow & { updateDisplay?(): void };
|
|
656
|
-
refreshRowShell(row);
|
|
657
|
-
row.updateDisplay?.();
|
|
658
|
-
} else visit(child as { children?: unknown[] });
|
|
659
|
-
}
|
|
660
|
-
};
|
|
661
|
-
const chat = findChat(tui);
|
|
662
|
-
if (chat && chat.children.length > 0) { visit(chat); tui.requestRender(); }
|
|
663
|
-
};
|
|
664
|
-
return { render: () => [], invalidate() {} };
|
|
665
|
-
}, { placement: "belowEditor" });
|
|
666
|
-
refreshTranscriptRows?.();
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function unbindRenderer(ctx: ExtensionContext | undefined): void {
|
|
670
|
-
refreshTranscriptRows = undefined;
|
|
671
|
-
runtime.bindRenderer(undefined);
|
|
672
|
-
ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
673
|
-
}
|
|
674
|
-
|
|
675
654
|
export default function compactTools(pi: ExtensionAPI): void {
|
|
676
655
|
// The cached factory is invoked again after session replacement and reload.
|
|
677
656
|
// Reset configuration memoization, but never change the host's tool registry.
|
|
678
657
|
registeredConfiguration = undefined;
|
|
658
|
+
refreshTranscriptRows = undefined;
|
|
679
659
|
sessionReady = false;
|
|
680
660
|
publicOwners = new WeakMap();
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const thinkingCycle = new ThinkingCycleController(pi);
|
|
661
|
+
publicBases = new WeakMap();
|
|
662
|
+
const thinkingCycle = new ThinkingCycleController(pi, () => runtime.config.style !== "off");
|
|
684
663
|
const progress = new ProgressController(pi);
|
|
685
664
|
const viewport = new ViewportKeeper();
|
|
686
665
|
const silent = new SilentModeController(pi, showReleaseNotice);
|
|
@@ -696,15 +675,66 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
696
675
|
const base = next();
|
|
697
676
|
return publicRowDefinition(name, base as ToolDefinition<any, any, any> | undefined);
|
|
698
677
|
});
|
|
699
|
-
} else invalidateRowDecisions();
|
|
678
|
+
} else invalidateRowDecisions([]);
|
|
679
|
+
const transcript = createTranscriptRenderer({
|
|
680
|
+
runtime, publicRendering, resolve: resolveCustomRow, definition: publicRowDefinition,
|
|
681
|
+
unwrap: (current) => current && publicBases.has(current) ? publicBases.get(current) : current,
|
|
682
|
+
});
|
|
683
|
+
refreshTranscriptRows = transcript.refresh;
|
|
700
684
|
let customRowsWarned = false;
|
|
701
|
-
let
|
|
685
|
+
let sessionContext: ExtensionContext | undefined;
|
|
686
|
+
const reportFailures = (ctx: ExtensionContext | undefined, failures: UIFailure[]) => {
|
|
687
|
+
if (ctx && failures.length > 0) {
|
|
688
|
+
const operations = [...new Set(failures.map(failure => failure.operation))].join(", ");
|
|
689
|
+
const message = `Compact tools: UI refresh/cleanup failed (${operations}). The selected style is saved; affected UI may need /reload.`;
|
|
690
|
+
try { ctx.ui.notify(message, "warning"); }
|
|
691
|
+
catch (error) { console.warn(message, "UI notification also failed:", error); }
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const enableUI = (ctx: ExtensionContext, reload: boolean, resume: boolean, failures: UIFailure[]) => {
|
|
695
|
+
// Observe relayout keys before the thinking controller consumes them.
|
|
696
|
+
attemptUI(failures, "viewport bind", () => viewport.bind(ctx));
|
|
697
|
+
attemptUI(failures, "progress bind", () => progress.bind(ctx, resume));
|
|
698
|
+
attemptUI(failures, "thinking bind", () => thinkingCycle.bind(ctx));
|
|
699
|
+
attemptUI(failures, "silent bind", () => silent.bind(ctx, runtime.config.mode, reload, resume));
|
|
700
|
+
attemptUI(failures, "groups bind", () => groups.bind(ctx));
|
|
701
|
+
transcript.bind(ctx, failures);
|
|
702
|
+
};
|
|
703
|
+
// One ownership/cleanup inventory for live off, session replacement and shutdown.
|
|
704
|
+
// Each action is independent: a host failure must not skip later cleanup steps.
|
|
705
|
+
const detachUI = (options: { preservePhase?: boolean; restoreNative?: boolean; clearTimings?: boolean }, failures: UIFailure[]) => {
|
|
706
|
+
attemptUI(failures, "thinking cleanup", () => thinkingCycle.dispose(options.restoreNative));
|
|
707
|
+
attemptUI(failures, "progress cleanup", () => progress.dispose(options.preservePhase));
|
|
708
|
+
attemptUI(failures, "silent cleanup", () => silent.dispose());
|
|
709
|
+
attemptUI(failures, "groups cleanup", () => groups.dispose());
|
|
710
|
+
attemptUI(failures, "viewport cleanup", () => viewport.dispose());
|
|
711
|
+
attemptUI(failures, "animation cleanup", () => runtime.reset(options.clearTimings === true));
|
|
712
|
+
attemptUI(failures, "renderer cleanup", () => transcript.detach());
|
|
713
|
+
};
|
|
714
|
+
registerCompactToolsCommand(pi, () => runtime.config, (cwd, trusted, ctx) => {
|
|
715
|
+
const failures: UIFailure[] = [];
|
|
716
|
+
const wasOff = runtime.config.style === "off";
|
|
717
|
+
if (!applyStyle(cwd, trusted, failures)) return false;
|
|
718
|
+
const uiContext = sessionContext ?? ctx;
|
|
719
|
+
if (uiContext.mode === "tui") {
|
|
720
|
+
if (runtime.config.style === "off") detachUI({ preservePhase: true, restoreNative: true }, failures);
|
|
721
|
+
else if (wasOff) enableUI(uiContext, true, true, failures);
|
|
722
|
+
// Off->off has no attached thinking context to restore the native label.
|
|
723
|
+
if (wasOff && runtime.config.style === "off") attemptUI(failures, "native thinking", () => uiContext.ui.setHiddenThinkingLabel());
|
|
724
|
+
}
|
|
725
|
+
reportFailures(uiContext, failures);
|
|
726
|
+
return true;
|
|
727
|
+
});
|
|
702
728
|
// Configure UI routing before Pi rebuilds transcript rows. Tool declarations
|
|
703
729
|
// and the active loadout must remain exactly as the host selected them.
|
|
704
730
|
const cwd = process.cwd();
|
|
705
731
|
const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
|
|
706
732
|
configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
|
|
733
|
+
pi.on("session_start", () => loadNativeTimerRenderers());
|
|
707
734
|
pi.on("session_start", (event, ctx) => {
|
|
735
|
+
const failures: UIFailure[] = [];
|
|
736
|
+
detachUI({ restoreNative: true }, failures);
|
|
737
|
+
sessionContext = ctx;
|
|
708
738
|
if (event.reason !== "reload") {
|
|
709
739
|
runtime.clearTimings();
|
|
710
740
|
nestedSummaries.clear();
|
|
@@ -715,33 +745,21 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
715
745
|
if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
|
|
716
746
|
const trusted = ctx.isProjectTrusted();
|
|
717
747
|
(globalThis as TrustSlot)[TRUST_KEY] = { cwd: realDirectory(ctx.cwd), trusted };
|
|
718
|
-
configure(pi, ctx.cwd, trusted);
|
|
748
|
+
configure(pi, ctx.cwd, trusted, failures);
|
|
719
749
|
sessionReady = true;
|
|
720
|
-
unbindRenderer(renderContext);
|
|
721
|
-
renderContext = undefined;
|
|
722
|
-
// Even off must reconcile rows built before trusted config was available.
|
|
723
|
-
if (ctx.mode === "tui") bindRenderer(ctx);
|
|
724
750
|
if (ctx.mode === "tui" && runtime.config.style !== "off") {
|
|
725
|
-
|
|
726
|
-
// First, so its input listener sees relayout keys before the thinking controller consumes them.
|
|
727
|
-
viewport.bind(ctx);
|
|
728
|
-
progress.bind(ctx);
|
|
729
|
-
thinkingCycle.bind(ctx);
|
|
730
|
-
silent.bind(ctx, runtime.config.mode, event.reason === "reload");
|
|
731
|
-
groups.bind(ctx);
|
|
751
|
+
enableUI(ctx, event.reason === "reload", false, failures);
|
|
732
752
|
if (!silent.isEnabled()) showReleaseNotice(ctx);
|
|
733
753
|
if ((runtime.config.custom_tools.enabled || runtime.config.tools.length > 0) && !customRowsAvailable && !customRowsWarned) {
|
|
734
754
|
customRowsWarned = true;
|
|
735
755
|
ctx.ui.notify("Compact tool rendering is unavailable in this version of Pi", "warning");
|
|
736
756
|
}
|
|
737
|
-
} else {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
thinkingCycle.dispose();
|
|
742
|
-
silent.dispose();
|
|
743
|
-
groups.dispose();
|
|
757
|
+
} else if (ctx.mode === "tui") {
|
|
758
|
+
// Startup-off also reconciles rows built using provisional/trusted config,
|
|
759
|
+
// but never attaches animation or the compact controllers.
|
|
760
|
+
transcript.reconcileOnce(ctx, failures);
|
|
744
761
|
}
|
|
762
|
+
reportFailures(ctx, failures);
|
|
745
763
|
});
|
|
746
764
|
// /tree changes the branch without session_start. Fill sibling-branch records
|
|
747
765
|
// before Pi rebuilds its transcript, while retaining richer same-process deltas.
|
|
@@ -761,7 +779,6 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
761
779
|
});
|
|
762
780
|
pi.on("tool_execution_start", (event) => {
|
|
763
781
|
if (madeByTool(event)) {
|
|
764
|
-
runtime.noteNestedCall(event.toolCallId, true);
|
|
765
782
|
nestedSummaries.note(event, false);
|
|
766
783
|
return;
|
|
767
784
|
}
|
|
@@ -771,7 +788,6 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
771
788
|
});
|
|
772
789
|
pi.on("tool_execution_end", (event) => {
|
|
773
790
|
if (madeByTool(event)) {
|
|
774
|
-
runtime.noteNestedCall(event.toolCallId, false);
|
|
775
791
|
if (nestedSummaries.note(event, true)) runtime.repaint();
|
|
776
792
|
return;
|
|
777
793
|
}
|
|
@@ -779,14 +795,10 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
779
795
|
runtime.noteExecutionEnd(event.toolCallId, (event as { durationMs?: number }).durationMs);
|
|
780
796
|
});
|
|
781
797
|
pi.on("session_shutdown", (event) => {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
viewport.dispose();
|
|
787
|
-
unbindRenderer(renderContext);
|
|
788
|
-
renderContext = undefined;
|
|
789
|
-
runtime.reset(event.reason !== "reload");
|
|
798
|
+
const failures: UIFailure[] = [];
|
|
799
|
+
detachUI({ clearTimings: event.reason !== "reload" }, failures);
|
|
800
|
+
reportFailures(sessionContext, failures);
|
|
801
|
+
sessionContext = undefined;
|
|
790
802
|
if (event.reason !== "reload") nestedSummaries.clear();
|
|
791
803
|
// A reload that keeps the extension sets it again before Pi rebuilds the transcript.
|
|
792
804
|
if (legacyResolver) clearRowResolver(legacyResolver);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-compact-tools",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.6",
|
|
4
4
|
"description": "Tool rows for Pi in three styles: Claude, Codex, and Compact. Switch with /compact-tools; includes silent mode and a GitHub Dark theme.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@earendil-works/pi-tui": ">=0.85.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
55
|
-
"@earendil-works/pi-tui": "1.
|
|
54
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
55
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
56
56
|
"@sinonjs/fake-timers": "15.0.0",
|
|
57
57
|
"@types/node": "^24.0.0",
|
|
58
58
|
"@types/sinonjs__fake-timers": "15.0.1",
|
package/release-notes.json
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
{
|
|
2
|
+
"0.16.6": {
|
|
3
|
+
"New": [],
|
|
4
|
+
"Fix": [
|
|
5
|
+
"Correct the SDK smoke test on Pi 0.99–1.1 by checking native codemode timer behavior only on hosts that provide it while retaining live off/on coverage on every supported version.",
|
|
6
|
+
"Require the complete supported Pi and Node compatibility matrix to pass before npm publication."
|
|
7
|
+
],
|
|
8
|
+
"Deprecated": []
|
|
9
|
+
},
|
|
10
|
+
"0.16.5": {
|
|
11
|
+
"New": [],
|
|
12
|
+
"Fix": [
|
|
13
|
+
"Remove duplicate transcript updates so each tool row refreshes once during session UI binding.",
|
|
14
|
+
"Reduce median UI rebind time from 8.89 ms to 4.87 ms in a 200-row component benchmark, without implying full reload or general rendering speedups.",
|
|
15
|
+
"Isolate row refresh failures so other rows and controller cleanup continue, with fallback diagnostics if warning delivery fails.",
|
|
16
|
+
"Detach controller state despite host cleanup errors to prevent compact animation from restarting while off.",
|
|
17
|
+
"Centralize UI cleanup and separate transcript reconciliation from extension setup.",
|
|
18
|
+
"Remove unused execution wrappers and state while preserving timing, codemode summaries, and live off/on switching."
|
|
19
|
+
],
|
|
20
|
+
"Deprecated": []
|
|
21
|
+
},
|
|
22
|
+
"0.16.4": {
|
|
23
|
+
"New": [
|
|
24
|
+
"The off command restores native Pi UI immediately, including during streaming or tool execution, without reload, abort, or restart",
|
|
25
|
+
"Re-enable Compact, Claude, or Codex live from off; existing rows and calls created while off follow the selected style"
|
|
26
|
+
],
|
|
27
|
+
"Fix": [
|
|
28
|
+
"Native and custom renderer shells and component caches reconcile across repeated off/on transitions without replacing author state",
|
|
29
|
+
"Working labels and silent activity resume the current streaming phase; native shell elapsed timers pause when returning to compact rendering"
|
|
30
|
+
],
|
|
31
|
+
"Deprecated": []
|
|
32
|
+
},
|
|
33
|
+
"0.16.3": {
|
|
34
|
+
"New": [
|
|
35
|
+
"Verified on Pi 1.3.0 with its version added to CI; the Pi 0.85.1 minimum is unchanged",
|
|
36
|
+
"Tool indicators, the working label, and silent activity share one animation timer, reducing redundant paint boundaries without changing their average animation rates"
|
|
37
|
+
],
|
|
38
|
+
"Fix": [
|
|
39
|
+
"Replayed read calls with numeric-string offsets retain their correct code line numbers",
|
|
40
|
+
"The off command restores Pi's native thinking display as well as tool rows, working indicator, keyboard behavior, and visibility; active themes and session history are unchanged"
|
|
41
|
+
],
|
|
42
|
+
"Deprecated": []
|
|
43
|
+
},
|
|
2
44
|
"0.16.2": {
|
|
3
45
|
"New": [
|
|
4
46
|
"A concise README and a separate development guide",
|