@pi-archimedes/ui 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +44 -0
- package/src/bash/index.ts +10 -0
- package/src/bash/renderer.test.ts +424 -0
- package/src/bash/renderer.ts +301 -0
- package/src/bash/tool.test.ts +26 -0
- package/src/bash/tool.ts +17 -0
- package/src/codemode/index.ts +20 -0
- package/src/codemode/loader.test.ts +139 -0
- package/src/codemode/loader.ts +115 -0
- package/src/codemode/renderer.test.ts +717 -0
- package/src/codemode/renderer.ts +399 -0
- package/src/codemode/tool.test.ts +149 -0
- package/src/codemode/tool.ts +96 -0
- package/src/config.test.ts +199 -0
- package/src/config.ts +129 -0
- package/src/editor/index.test.ts +820 -0
- package/src/editor/index.ts +380 -0
- package/src/editor/spin-quips.test.ts +106 -0
- package/src/editor/spin-quips.ts +44 -0
- package/src/editor/spin.test.ts +763 -0
- package/src/editor/spin.ts +493 -0
- package/src/index.test.ts +402 -0
- package/src/index.ts +235 -0
- package/src/migration.test.ts +256 -0
- package/src/migration.ts +94 -0
- package/src/settings.test.ts +122 -0
- package/src/settings.ts +135 -0
- package/src/startup/capture.ts +47 -0
- package/src/startup/index.test.ts +175 -0
- package/src/startup/index.ts +348 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/logo.ts +85 -0
- package/src/startup/sections.test.ts +333 -0
- package/src/startup/sections.ts +306 -0
- package/src/startup/version.test.ts +98 -0
- package/src/startup/version.ts +26 -0
- package/src/thinking/patch.test.ts +857 -0
- package/src/thinking/patch.ts +407 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/theme.ts +156 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/transform.ts +21 -0
- package/src/thinking/unindent.test.ts +74 -0
- package/src/thinking/unindent.ts +74 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { highlightCode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Container,
|
|
5
|
+
getImageDimensions,
|
|
6
|
+
imageFallback,
|
|
7
|
+
Text,
|
|
8
|
+
type Component,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { renderToolHeader } from "@pi-archimedes/core/tool-render";
|
|
11
|
+
|
|
12
|
+
/** Nested-call args preview budget, collapsed (the native renderer uses 80). */
|
|
13
|
+
const COLLAPSED_CALL_ARGS_CHARS = 80;
|
|
14
|
+
/** Collapsed call-list budget: the last 5 calls, with an expand hint. */
|
|
15
|
+
const COLLAPSED_CALL_COUNT = 5;
|
|
16
|
+
|
|
17
|
+
/** The executor's result header, stripped before the output is displayed. */
|
|
18
|
+
const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/;
|
|
19
|
+
|
|
20
|
+
export interface CodemodeNestedCall {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/** Compact JSON of the arguments, truncated for display. */
|
|
24
|
+
args: string;
|
|
25
|
+
status: "running" | "ok" | "error" | "cancelled";
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
/** Error text, truncated for display. */
|
|
28
|
+
error?: string;
|
|
29
|
+
/** Cost in USD of a `models.*` call that reported usage. */
|
|
30
|
+
cost?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CodemodeToolDetails {
|
|
34
|
+
calls: CodemodeNestedCall[];
|
|
35
|
+
/** Temp file with the full text output, when the output was truncated. */
|
|
36
|
+
fullOutputPath?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CodemodeRendererState {
|
|
40
|
+
startedAt?: number | undefined;
|
|
41
|
+
endedAt?: number | undefined;
|
|
42
|
+
interval?: NodeJS.Timeout | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const activeStates = new Set<CodemodeRendererState>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Clear live-timer intervals of in-flight codemode rows. Called on
|
|
49
|
+
* session_shutdown so a cancelled script does not keep invalidating a
|
|
50
|
+
* component that no longer exists.
|
|
51
|
+
*/
|
|
52
|
+
export function clearActiveCodemodeIntervals(): void {
|
|
53
|
+
for (const state of activeStates) {
|
|
54
|
+
if (state.interval) {
|
|
55
|
+
clearInterval(state.interval);
|
|
56
|
+
state.interval = undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
activeStates.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Format a duration in milliseconds: `<X>ms` under a second, `<X.X>s`
|
|
64
|
+
* otherwise (the native codemode renderer's format).
|
|
65
|
+
*/
|
|
66
|
+
export function formatCodemodeDuration(ms: number | undefined): string {
|
|
67
|
+
if (ms === undefined) return "";
|
|
68
|
+
if (ms < 0) ms = 0;
|
|
69
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cents for larger amounts, two significant digits for the fractions of a
|
|
74
|
+
* cent classifier calls cost (the native renderer's format).
|
|
75
|
+
*/
|
|
76
|
+
export function formatCost(cost: number): string {
|
|
77
|
+
return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Status glyph for a nested tool call.
|
|
82
|
+
*/
|
|
83
|
+
function statusIcon(
|
|
84
|
+
status: CodemodeNestedCall["status"],
|
|
85
|
+
theme: Theme,
|
|
86
|
+
): string {
|
|
87
|
+
switch (status) {
|
|
88
|
+
case "running":
|
|
89
|
+
return theme.fg("warning", "…");
|
|
90
|
+
case "ok":
|
|
91
|
+
return theme.fg("success", "✓");
|
|
92
|
+
case "error":
|
|
93
|
+
return theme.fg("error", "✗");
|
|
94
|
+
case "cancelled":
|
|
95
|
+
return theme.fg("muted", "⊘");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One nested tool call row:
|
|
101
|
+
* `<glyph> <name> <args> <duration> <cost>` — args truncated to
|
|
102
|
+
* COLLAPSED_CALL_ARGS_CHARS when not expanded; an expanded failed call
|
|
103
|
+
* appends its error text indented below.
|
|
104
|
+
*/
|
|
105
|
+
export function formatNestedCall(
|
|
106
|
+
call: CodemodeNestedCall,
|
|
107
|
+
theme: Theme,
|
|
108
|
+
expanded: boolean,
|
|
109
|
+
): string {
|
|
110
|
+
const args =
|
|
111
|
+
!expanded && call.args.length > COLLAPSED_CALL_ARGS_CHARS
|
|
112
|
+
? `${call.args.slice(0, COLLAPSED_CALL_ARGS_CHARS - 3)}...`
|
|
113
|
+
: call.args;
|
|
114
|
+
let line = `${statusIcon(call.status, theme)} ${theme.fg("toolTitle", call.name)}`;
|
|
115
|
+
if (args) line += ` ${theme.fg("muted", args)}`;
|
|
116
|
+
const duration = formatCodemodeDuration(call.durationMs);
|
|
117
|
+
if (duration) line += ` ${theme.fg("dim", duration)}`;
|
|
118
|
+
if (call.cost) line += ` ${theme.fg("dim", formatCost(call.cost))}`;
|
|
119
|
+
if (expanded && call.error) {
|
|
120
|
+
line += `\n ${theme.fg("error", call.error.split("\n").join("\n "))}`;
|
|
121
|
+
}
|
|
122
|
+
return line;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The script output as display text: text blocks joined, the executor's
|
|
127
|
+
* "Script completed/failed" header dropped, and a fallback indicator line
|
|
128
|
+
* for image blocks that cannot be shown inline.
|
|
129
|
+
*/
|
|
130
|
+
export function getCodemodeOutput(
|
|
131
|
+
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
132
|
+
): string {
|
|
133
|
+
const textBlocks = content.filter(
|
|
134
|
+
(b) => b.type === "text" && typeof b.text === "string",
|
|
135
|
+
);
|
|
136
|
+
const [first, ...rest] = textBlocks;
|
|
137
|
+
const hasHeader = first !== undefined && SCRIPT_HEADER.test(first.text!);
|
|
138
|
+
const text = (hasHeader ? rest : textBlocks).map((b) => b.text!).join("\n");
|
|
139
|
+
// The renderer is text-only: image blocks are never rendered as images,
|
|
140
|
+
// so they ALWAYS get a fallback indicator (mime + dimensions when known),
|
|
141
|
+
// regardless of the terminal's inline-image support.
|
|
142
|
+
const imageBlocks = content.filter((b) => b.type === "image");
|
|
143
|
+
if (imageBlocks.length > 0) {
|
|
144
|
+
const indicators = imageBlocks
|
|
145
|
+
.map((img) => {
|
|
146
|
+
const mimeType = img.mimeType ?? "image/unknown";
|
|
147
|
+
const dims =
|
|
148
|
+
img.data && img.mimeType
|
|
149
|
+
? (getImageDimensions(img.data, img.mimeType) ?? undefined)
|
|
150
|
+
: undefined;
|
|
151
|
+
return imageFallback(mimeType, dims);
|
|
152
|
+
})
|
|
153
|
+
.join("\n");
|
|
154
|
+
return text ? `${text}\n${indicators}` : indicators;
|
|
155
|
+
}
|
|
156
|
+
return text;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Track the script's wall clock for the collapsed row: start on the first
|
|
161
|
+
* render after execution began, end when the result settles (or errors).
|
|
162
|
+
* Mirrors the bash renderer's timer lifecycle.
|
|
163
|
+
*
|
|
164
|
+
* `isPartial` means "still in flight": the CALL renderer passes `true`
|
|
165
|
+
* (a call is in flight until its result settles) — passing `false` there
|
|
166
|
+
* would record `endedAt` at call-render time and freeze the elapsed timer
|
|
167
|
+
* for the rest of the script. The interval is (re)created only while the
|
|
168
|
+
* result has not settled yet (`endedAt === undefined`), so a re-render of a
|
|
169
|
+
* settled call never spawns a permanent interval.
|
|
170
|
+
*/
|
|
171
|
+
function updateTiming(
|
|
172
|
+
ctx: {
|
|
173
|
+
executionStarted?: boolean;
|
|
174
|
+
isError?: boolean;
|
|
175
|
+
state?: CodemodeRendererState;
|
|
176
|
+
invalidate?: () => void;
|
|
177
|
+
} | undefined,
|
|
178
|
+
isPartial: boolean,
|
|
179
|
+
): void {
|
|
180
|
+
const state: CodemodeRendererState = ctx?.state ?? {};
|
|
181
|
+
if (ctx && !ctx.state) ctx.state = state;
|
|
182
|
+
|
|
183
|
+
if ((ctx?.executionStarted || isPartial) && state.startedAt === undefined) {
|
|
184
|
+
state.startedAt = Date.now();
|
|
185
|
+
}
|
|
186
|
+
if (isPartial && !state.interval && state.endedAt === undefined) {
|
|
187
|
+
if (ctx?.invalidate) {
|
|
188
|
+
state.interval = setInterval(() => {
|
|
189
|
+
ctx.invalidate?.();
|
|
190
|
+
}, 1000);
|
|
191
|
+
activeStates.add(state);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!isPartial || ctx?.isError) {
|
|
195
|
+
state.endedAt ??= Date.now();
|
|
196
|
+
if (state.interval) {
|
|
197
|
+
clearInterval(state.interval);
|
|
198
|
+
state.interval = undefined;
|
|
199
|
+
}
|
|
200
|
+
activeStates.delete(state);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The script's wall time in ms, parsed from the result header when present
|
|
206
|
+
* (more accurate than the renderer's timer, which spans TUI updates).
|
|
207
|
+
*/
|
|
208
|
+
export function parseWallTimeMs(
|
|
209
|
+
content: Array<{ type: string; text?: string }>,
|
|
210
|
+
): number | undefined {
|
|
211
|
+
const first = content[0];
|
|
212
|
+
if (first?.type !== "text" || typeof first.text !== "string") return undefined;
|
|
213
|
+
const match = first.text.match(
|
|
214
|
+
/^Script (completed|failed)\nWall time ([\d.]+) seconds/,
|
|
215
|
+
);
|
|
216
|
+
if (!match) return undefined;
|
|
217
|
+
const seconds = Number.parseFloat(match[2]!);
|
|
218
|
+
return Number.isFinite(seconds) ? seconds * 1000 : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render the codemode tool call: the bold `codemode` header. Collapsed, the
|
|
223
|
+
* header stands alone — the script (syntax-highlighted) shows when expanded.
|
|
224
|
+
*/
|
|
225
|
+
export function renderCodemodeCall(
|
|
226
|
+
args: unknown,
|
|
227
|
+
theme: Theme,
|
|
228
|
+
context: unknown,
|
|
229
|
+
): Component {
|
|
230
|
+
const ctx = context as {
|
|
231
|
+
executionStarted?: boolean;
|
|
232
|
+
state?: CodemodeRendererState;
|
|
233
|
+
args?: { code?: string };
|
|
234
|
+
} | undefined;
|
|
235
|
+
|
|
236
|
+
// The call is in flight until its result settles — pass `true` so the
|
|
237
|
+
// timer is not finalized at call-render time (see updateTiming).
|
|
238
|
+
updateTiming(ctx, true);
|
|
239
|
+
|
|
240
|
+
// Native semantics: a missing or empty `code` renders the header alone;
|
|
241
|
+
// a present-but-non-string `code` renders the invalid-arg marker.
|
|
242
|
+
const rawCode =
|
|
243
|
+
(args as { code?: unknown } | undefined)?.code ?? ctx?.args?.code;
|
|
244
|
+
let code: string | null;
|
|
245
|
+
if (typeof rawCode === "string") {
|
|
246
|
+
code = rawCode;
|
|
247
|
+
} else if (rawCode === undefined || rawCode === null) {
|
|
248
|
+
code = "";
|
|
249
|
+
} else {
|
|
250
|
+
code = null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const container = new Container();
|
|
254
|
+
const title = renderToolHeader("codemode", undefined, theme);
|
|
255
|
+
|
|
256
|
+
if (code === null) {
|
|
257
|
+
container.addChild(new Text(`${title} ${theme.fg("error", "[invalid arg]")}`, 0, 0));
|
|
258
|
+
return container;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
container.addChild(new Text(title, 0, 0));
|
|
262
|
+
|
|
263
|
+
// Empty script: header alone (the native renderer's behavior).
|
|
264
|
+
if (code.length === 0) return container;
|
|
265
|
+
|
|
266
|
+
// Collapsed: header alone — the script is the expandable detail.
|
|
267
|
+
if (!(context as { expanded?: boolean }).expanded) return container;
|
|
268
|
+
|
|
269
|
+
// The `// @options:` line is part of the script, so options show as-is.
|
|
270
|
+
const highlighted = highlightCode(
|
|
271
|
+
code.replace(/\t/g, " ").replace(/\r/g, "").trimEnd(),
|
|
272
|
+
"javascript",
|
|
273
|
+
).join("\n");
|
|
274
|
+
container.addChild(new Text(highlighted, 0, 0));
|
|
275
|
+
return container;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Render the codemode tool result.
|
|
280
|
+
*
|
|
281
|
+
* Collapsed: the nested tool calls (last 5, args truncated) — the script's
|
|
282
|
+
* activity at a glance, no summary line. A script that made no calls falls
|
|
283
|
+
* back to one `<glyph> <duration>` line so its outcome is still visible.
|
|
284
|
+
* Expanded: every nested call (with per-call status, duration, and model
|
|
285
|
+
* cost), the script output without the executor's header, the full-output
|
|
286
|
+
* path when truncated, and the timing + status.
|
|
287
|
+
*/
|
|
288
|
+
export function renderCodemodeResult(
|
|
289
|
+
result: unknown,
|
|
290
|
+
options: { expanded?: boolean; isPartial?: boolean },
|
|
291
|
+
theme: Theme,
|
|
292
|
+
context: unknown,
|
|
293
|
+
): Component {
|
|
294
|
+
const ctx = context as {
|
|
295
|
+
executionStarted?: boolean;
|
|
296
|
+
isError?: boolean;
|
|
297
|
+
state?: CodemodeRendererState;
|
|
298
|
+
invalidate?: () => void;
|
|
299
|
+
} | undefined;
|
|
300
|
+
|
|
301
|
+
updateTiming(ctx, Boolean(options.isPartial));
|
|
302
|
+
|
|
303
|
+
const res = result as {
|
|
304
|
+
content?: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
|
305
|
+
details?: CodemodeToolDetails;
|
|
306
|
+
} | undefined;
|
|
307
|
+
const content = Array.isArray(res?.content) ? res.content : [];
|
|
308
|
+
const calls = res?.details?.calls ?? [];
|
|
309
|
+
const isError = Boolean(ctx?.isError);
|
|
310
|
+
|
|
311
|
+
const state: CodemodeRendererState = ctx?.state ?? {};
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
const wallTime = parseWallTimeMs(content);
|
|
314
|
+
const timerMs =
|
|
315
|
+
state.startedAt !== undefined
|
|
316
|
+
? Math.max(0, (state.endedAt ?? now) - state.startedAt)
|
|
317
|
+
: undefined;
|
|
318
|
+
const durationMs = wallTime ?? timerMs;
|
|
319
|
+
|
|
320
|
+
const container = new Container();
|
|
321
|
+
|
|
322
|
+
// Collapsed view.
|
|
323
|
+
if (!options.expanded) {
|
|
324
|
+
if (calls.length > 0) {
|
|
325
|
+
const shown = calls.slice(-COLLAPSED_CALL_COUNT);
|
|
326
|
+
const callLines = shown.map((call) => formatNestedCall(call, theme, false));
|
|
327
|
+
if (shown.length < calls.length) {
|
|
328
|
+
callLines.unshift(
|
|
329
|
+
theme.fg(
|
|
330
|
+
"muted",
|
|
331
|
+
`... (${calls.length - shown.length} earlier calls, ctrl+o to expand)`,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
// A failure must never be hidden behind success glyphs: when the
|
|
336
|
+
// script failed after successful calls, append the failure marker.
|
|
337
|
+
if (!options.isPartial && isError) {
|
|
338
|
+
callLines.push(theme.fg("error", "✗ Script failed"));
|
|
339
|
+
}
|
|
340
|
+
container.addChild(new Text(callLines.join("\n"), 0, 0));
|
|
341
|
+
} else {
|
|
342
|
+
// No calls: a single status line so the script's outcome is visible.
|
|
343
|
+
const glyph = options.isPartial
|
|
344
|
+
? theme.fg("warning", "▸")
|
|
345
|
+
: isError
|
|
346
|
+
? theme.fg("error", "✗")
|
|
347
|
+
: theme.fg("success", "✓");
|
|
348
|
+
const duration = formatCodemodeDuration(durationMs);
|
|
349
|
+
container.addChild(new Text(`${glyph} ${theme.fg("muted", duration)}`, 0, 0));
|
|
350
|
+
}
|
|
351
|
+
return container;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Expanded view.
|
|
355
|
+
const parts: string[] = [];
|
|
356
|
+
|
|
357
|
+
if (calls.length > 0) {
|
|
358
|
+
const lines = calls.map((call) => formatNestedCall(call, theme, true));
|
|
359
|
+
const priced = calls.filter((call) => call.cost);
|
|
360
|
+
if (priced.length > 1) {
|
|
361
|
+
const total = priced.reduce((sum, call) => sum + (call.cost ?? 0), 0);
|
|
362
|
+
lines.push(theme.fg("muted", `Model calls: ${formatCost(total)}`));
|
|
363
|
+
}
|
|
364
|
+
parts.push(lines.join("\n"));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!options.isPartial) {
|
|
368
|
+
const output = getCodemodeOutput(content).trim();
|
|
369
|
+
if (output.length > 0) {
|
|
370
|
+
const color = isError ? "error" : "toolOutput";
|
|
371
|
+
parts.push(
|
|
372
|
+
output.split("\n").map((line) => theme.fg(color, line)).join("\n"),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (res?.details?.fullOutputPath) {
|
|
376
|
+
parts.push(theme.fg("muted", `Full output: ${res.details.fullOutputPath}`));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (durationMs !== undefined) {
|
|
381
|
+
const label = options.isPartial ? "Elapsed" : "Took";
|
|
382
|
+
parts.push(
|
|
383
|
+
theme.fg("muted", `${label} ${formatCodemodeDuration(durationMs)}`),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!options.isPartial) {
|
|
388
|
+
parts.push(
|
|
389
|
+
isError
|
|
390
|
+
? theme.fg("error", "✗ Script failed")
|
|
391
|
+
: theme.fg("success", "✓ Done"),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (parts.length > 0) {
|
|
396
|
+
container.addChild(new Text(parts.join("\n\n"), 0, 0));
|
|
397
|
+
}
|
|
398
|
+
return container;
|
|
399
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { registerCodemodeToolOverride } from "./tool.js";
|
|
3
|
+
|
|
4
|
+
const nativeSchema = { type: "object", properties: { code: {} } };
|
|
5
|
+
const defFromFactory = {
|
|
6
|
+
name: "codemode",
|
|
7
|
+
label: "codemode",
|
|
8
|
+
description: "native description",
|
|
9
|
+
parameters: { type: "object", properties: { code: {} } },
|
|
10
|
+
execute: async () => ({}),
|
|
11
|
+
prepareLoadout: (loadout: unknown) => loadout,
|
|
12
|
+
exposure: "model-only",
|
|
13
|
+
constrainedSampling: { type: "grammar" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function makePi(
|
|
17
|
+
overrides: {
|
|
18
|
+
allTools?: Array<{ name: string; parameters?: unknown; namespace?: unknown }>;
|
|
19
|
+
getSettings?: () => {
|
|
20
|
+
codemode?: { mode?: string; inlineBudget?: unknown };
|
|
21
|
+
};
|
|
22
|
+
} = {},
|
|
23
|
+
) {
|
|
24
|
+
const registerTool = vi.fn();
|
|
25
|
+
const appendEntry = vi.fn();
|
|
26
|
+
const pi = {
|
|
27
|
+
registerTool,
|
|
28
|
+
appendEntry,
|
|
29
|
+
getAllTools: vi.fn(() => overrides.allTools ?? []),
|
|
30
|
+
...(overrides.getSettings ? { getSettings: overrides.getSettings } : {}),
|
|
31
|
+
};
|
|
32
|
+
return { pi, registerTool, appendEntry } as const;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type MockModule = {
|
|
36
|
+
createCodemodeToolDefinition: (options: Record<string, unknown>) => unknown;
|
|
37
|
+
codemodeSchema: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// `vi.mock` is hoisted above the imports, so the mutable module handle must
|
|
41
|
+
// live in a hoisted scope (referencing a plain `let` would hit the TDZ when
|
|
42
|
+
// the mock factory first runs during the static import of ./tool.js).
|
|
43
|
+
const state = vi.hoisted(() => ({
|
|
44
|
+
module: undefined as MockModule | undefined,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
vi.mock("./loader.js", () => ({
|
|
48
|
+
loadCodemodeModule: vi.fn(async () => state.module),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
state.module = {
|
|
53
|
+
createCodemodeToolDefinition: (options) => ({ ...defFromFactory, options }),
|
|
54
|
+
codemodeSchema: nativeSchema,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("registerCodemodeToolOverride", () => {
|
|
59
|
+
it("registers the native definition with the Archimedes renderers", async () => {
|
|
60
|
+
const { pi, registerTool } = makePi();
|
|
61
|
+
const ok = await registerCodemodeToolOverride(pi as never);
|
|
62
|
+
|
|
63
|
+
expect(ok).toBe(true);
|
|
64
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
65
|
+
const registered = registerTool.mock.calls[0]![0] as Record<string, unknown>;
|
|
66
|
+
// Native definition fields are preserved.
|
|
67
|
+
expect(registered.name).toBe("codemode");
|
|
68
|
+
expect(registered.execute).toBe(defFromFactory.execute);
|
|
69
|
+
expect(registered.prepareLoadout).toBe(defFromFactory.prepareLoadout);
|
|
70
|
+
expect(registered.exposure).toBe("model-only");
|
|
71
|
+
// The built-in registers codemode inactive; the override must match.
|
|
72
|
+
expect(registered.defaultActive).toBe(false);
|
|
73
|
+
// The presentation is replaced.
|
|
74
|
+
expect(typeof registered.renderCall).toBe("function");
|
|
75
|
+
expect(typeof registered.renderResult).toBe("function");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves the running CLI's schema reference when the built-in is still registered", async () => {
|
|
79
|
+
const { pi, registerTool } = makePi({
|
|
80
|
+
allTools: [{ name: "codemode", parameters: nativeSchema }],
|
|
81
|
+
});
|
|
82
|
+
await registerCodemodeToolOverride(pi as never);
|
|
83
|
+
|
|
84
|
+
const registered = registerTool.mock.calls[0]![0] as Record<string, unknown>;
|
|
85
|
+
expect(registered.parameters).toBe(nativeSchema);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps the factory schema when no built-in registration is visible", async () => {
|
|
89
|
+
const { pi, registerTool } = makePi();
|
|
90
|
+
await registerCodemodeToolOverride(pi as never);
|
|
91
|
+
|
|
92
|
+
const registered = registerTool.mock.calls[0]![0] as Record<string, unknown>;
|
|
93
|
+
expect(registered.parameters).toBe(defFromFactory.parameters);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("wires appendEntry, models, and the settings-backed options", async () => {
|
|
97
|
+
const settings = { codemode: { mode: "only", inlineBudget: 1500 } };
|
|
98
|
+
const { pi, registerTool, appendEntry } = makePi({
|
|
99
|
+
getSettings: () => settings,
|
|
100
|
+
});
|
|
101
|
+
await registerCodemodeToolOverride(pi as never);
|
|
102
|
+
|
|
103
|
+
const registered = registerTool.mock.calls[0]![0] as Record<string, unknown>;
|
|
104
|
+
const options = (registered as { options?: Record<string, unknown> }).options!;
|
|
105
|
+
const getMode = options.getMode as () => string;
|
|
106
|
+
const getInlineBudget = options.getInlineBudget as () => number | undefined;
|
|
107
|
+
expect(options.models).toBe(true);
|
|
108
|
+
expect(typeof options.appendEntry).toBe("function");
|
|
109
|
+
expect(typeof options.getToolNamespace).toBe("function");
|
|
110
|
+
expect(getMode()).toBe("only");
|
|
111
|
+
expect(getInlineBudget()).toBe(1500);
|
|
112
|
+
|
|
113
|
+
// appendEntry forwards to pi.appendEntry.
|
|
114
|
+
await (options.appendEntry as (t: string, d: unknown) => void)(
|
|
115
|
+
"codemode-store",
|
|
116
|
+
{ set: {} },
|
|
117
|
+
);
|
|
118
|
+
expect(appendEntry).toHaveBeenCalledWith("codemode-store", { set: {} });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("defaults mode to on and inlineBudget to undefined without settings", async () => {
|
|
122
|
+
const { pi, registerTool } = makePi();
|
|
123
|
+
await registerCodemodeToolOverride(pi as never);
|
|
124
|
+
|
|
125
|
+
const registered = registerTool.mock.calls[0]![0] as Record<string, unknown>;
|
|
126
|
+
const options = (registered as { options?: Record<string, unknown> }).options!;
|
|
127
|
+
expect((options.getMode as () => string)()).toBe("on");
|
|
128
|
+
expect((options.getInlineBudget as () => number | undefined)()).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("treats a non-finite inlineBudget as undefined", async () => {
|
|
132
|
+
const { pi, registerTool } = makePi({
|
|
133
|
+
getSettings: () => ({ codemode: { inlineBudget: Number.NaN } }),
|
|
134
|
+
});
|
|
135
|
+
await registerCodemodeToolOverride(pi as never);
|
|
136
|
+
|
|
137
|
+
const registered = registerTool.mock.calls[0]![0] as Record<string, unknown>;
|
|
138
|
+
const options = (registered as { options?: Record<string, unknown> }).options!;
|
|
139
|
+
expect((options.getInlineBudget as () => number | undefined)()).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns false and registers nothing when the module cannot be loaded", async () => {
|
|
143
|
+
state.module = undefined;
|
|
144
|
+
const { pi, registerTool } = makePi();
|
|
145
|
+
const ok = await registerCodemodeToolOverride(pi as never);
|
|
146
|
+
expect(ok).toBe(false);
|
|
147
|
+
expect(registerTool).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadCodemodeModule } from "./loader.js";
|
|
3
|
+
import {
|
|
4
|
+
renderCodemodeCall,
|
|
5
|
+
renderCodemodeResult,
|
|
6
|
+
clearActiveCodemodeIntervals,
|
|
7
|
+
} from "./renderer.js";
|
|
8
|
+
|
|
9
|
+
export { clearActiveCodemodeIntervals } from "./renderer.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register an Archimedes-styled override of the `codemode` tool.
|
|
13
|
+
*
|
|
14
|
+
* `codemode` is a replaceable pi built-in extension: registering a tool with
|
|
15
|
+
* the same name takes over completely (pi omits the built-in one), so the
|
|
16
|
+
* override must reproduce the built-in's full definition — the QuickJS
|
|
17
|
+
* sandbox executor, the dynamic `prepareLoadout` description, the
|
|
18
|
+
* `model-only` exposure, and `defaultActive: false` (the built-in registers
|
|
19
|
+
* codemode inactive; it is activated via `--tools`, `defaultTools`, or MCP
|
|
20
|
+
* auto-enable). Only the presentation is replaced.
|
|
21
|
+
*
|
|
22
|
+
* The definition is built from the running CLI's own copy of the codemode
|
|
23
|
+
* module (see {@link loadCodemodeModule}) so the executor matches what the
|
|
24
|
+
* CLI expects. The `parameters` reference is preserved from the built-in's
|
|
25
|
+
* registration when it is still visible (`pi.getAllTools()`): the CLI's
|
|
26
|
+
* MCP and tool-search extensions recognise the codemode tool by schema
|
|
27
|
+
* identity (`isCodemodeTool`), and a fresh schema object from a second
|
|
28
|
+
* module instance would defeat those checks.
|
|
29
|
+
*
|
|
30
|
+
* Returns `false` when the codemode module cannot be loaded (e.g. a pi
|
|
31
|
+
* version without the codemode extension) — pi's native rendering then
|
|
32
|
+
* stands in.
|
|
33
|
+
*
|
|
34
|
+
* Note: the take-over makes pi print a startup notice ("built-in extension
|
|
35
|
+
* `codemode` was not loaded") — the same notice a third-party MCP extension
|
|
36
|
+
* triggers when it replaces the built-in MCP extension. It is informational:
|
|
37
|
+
* the tool still works, now with this package's renderers. Set
|
|
38
|
+
* `codemodeToolStyling` to `false` to run the native tool instead.
|
|
39
|
+
*/
|
|
40
|
+
export async function registerCodemodeToolOverride(
|
|
41
|
+
pi: ExtensionAPI,
|
|
42
|
+
): Promise<boolean> {
|
|
43
|
+
const mod = await loadCodemodeModule();
|
|
44
|
+
if (!mod) return false;
|
|
45
|
+
|
|
46
|
+
const def = mod.createCodemodeToolDefinition({
|
|
47
|
+
appendEntry: (customType: string, data: unknown) =>
|
|
48
|
+
pi.appendEntry(customType, data),
|
|
49
|
+
models: true,
|
|
50
|
+
getToolNamespace: (name: string) =>
|
|
51
|
+
(pi.getAllTools().find((t) => t.name === name) as {
|
|
52
|
+
namespace?: unknown;
|
|
53
|
+
} | undefined)?.namespace,
|
|
54
|
+
// `getSettings` is newer than the oldest pi this package type-checks
|
|
55
|
+
// against; read it defensively (the built-in extension's wiring).
|
|
56
|
+
getMode: () =>
|
|
57
|
+
readSettings(pi)?.codemode?.mode === "only" ? "only" : "on",
|
|
58
|
+
getInlineBudget: () => {
|
|
59
|
+
const budget = readSettings(pi)?.codemode?.inlineBudget;
|
|
60
|
+
return typeof budget === "number" && Number.isFinite(budget) && budget >= 0
|
|
61
|
+
? budget
|
|
62
|
+
: undefined;
|
|
63
|
+
},
|
|
64
|
+
}) as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
// The built-in (when loaded) is still registered at this point: keep its
|
|
67
|
+
// schema object reference so `isCodemodeTool` identity checks keep working.
|
|
68
|
+
const native = pi.getAllTools().find((t) => t.name === "codemode");
|
|
69
|
+
if (native?.parameters !== undefined) {
|
|
70
|
+
def.parameters = native.parameters;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pi.registerTool({
|
|
74
|
+
...def,
|
|
75
|
+
renderCall: renderCodemodeCall,
|
|
76
|
+
renderResult: renderCodemodeResult,
|
|
77
|
+
defaultActive: false,
|
|
78
|
+
} as never);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readSettings(
|
|
83
|
+
pi: ExtensionAPI,
|
|
84
|
+
): { codemode?: { mode?: string; inlineBudget?: unknown } } | undefined {
|
|
85
|
+
const getSettings = (
|
|
86
|
+
pi as unknown as { getSettings?: () => unknown }
|
|
87
|
+
).getSettings;
|
|
88
|
+
if (typeof getSettings !== "function") return undefined;
|
|
89
|
+
try {
|
|
90
|
+
return getSettings.call(pi) as
|
|
91
|
+
| { codemode?: { mode?: string; inlineBudget?: unknown } }
|
|
92
|
+
| undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|