@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/package.json +44 -0
  4. package/src/bash/index.ts +10 -0
  5. package/src/bash/renderer.test.ts +424 -0
  6. package/src/bash/renderer.ts +301 -0
  7. package/src/bash/tool.test.ts +26 -0
  8. package/src/bash/tool.ts +17 -0
  9. package/src/codemode/index.ts +20 -0
  10. package/src/codemode/loader.test.ts +139 -0
  11. package/src/codemode/loader.ts +115 -0
  12. package/src/codemode/renderer.test.ts +717 -0
  13. package/src/codemode/renderer.ts +399 -0
  14. package/src/codemode/tool.test.ts +149 -0
  15. package/src/codemode/tool.ts +96 -0
  16. package/src/config.test.ts +199 -0
  17. package/src/config.ts +129 -0
  18. package/src/editor/index.test.ts +820 -0
  19. package/src/editor/index.ts +380 -0
  20. package/src/editor/spin-quips.test.ts +106 -0
  21. package/src/editor/spin-quips.ts +44 -0
  22. package/src/editor/spin.test.ts +763 -0
  23. package/src/editor/spin.ts +493 -0
  24. package/src/index.test.ts +402 -0
  25. package/src/index.ts +235 -0
  26. package/src/migration.test.ts +256 -0
  27. package/src/migration.ts +94 -0
  28. package/src/settings.test.ts +122 -0
  29. package/src/settings.ts +135 -0
  30. package/src/startup/capture.ts +47 -0
  31. package/src/startup/index.test.ts +175 -0
  32. package/src/startup/index.ts +348 -0
  33. package/src/startup/logo.test.ts +260 -0
  34. package/src/startup/logo.ts +85 -0
  35. package/src/startup/sections.test.ts +333 -0
  36. package/src/startup/sections.ts +306 -0
  37. package/src/startup/version.test.ts +98 -0
  38. package/src/startup/version.ts +26 -0
  39. package/src/thinking/patch.test.ts +857 -0
  40. package/src/thinking/patch.ts +407 -0
  41. package/src/thinking/theme.test.ts +216 -0
  42. package/src/thinking/theme.ts +156 -0
  43. package/src/thinking/transform.test.ts +89 -0
  44. package/src/thinking/transform.ts +21 -0
  45. package/src/thinking/unindent.test.ts +74 -0
  46. 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
+ }