@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,301 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { renderToolHeader } from "@pi-archimedes/core/tool-render";
|
|
4
|
+
|
|
5
|
+
export interface BashRendererState {
|
|
6
|
+
startedAt?: number | undefined;
|
|
7
|
+
endedAt?: number | undefined;
|
|
8
|
+
interval?: NodeJS.Timeout | undefined;
|
|
9
|
+
timeout?: number | undefined;
|
|
10
|
+
command?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const activeStates = new Set<BashRendererState>();
|
|
14
|
+
|
|
15
|
+
export function clearActiveBashIntervals(): void {
|
|
16
|
+
for (const state of activeStates) {
|
|
17
|
+
if (state.interval) {
|
|
18
|
+
clearInterval(state.interval);
|
|
19
|
+
state.interval = undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
activeStates.clear();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format duration in milliseconds as:
|
|
27
|
+
* - <X.X>s for under 10 seconds (e.g. "1.5s")
|
|
28
|
+
* - <X>s for 10s to 60s (e.g. "15s")
|
|
29
|
+
* - <M>m <S>s for 60s and above (e.g. "1m 5s")
|
|
30
|
+
* - <H>h <M>m <S>s if exceeding 60 minutes
|
|
31
|
+
*/
|
|
32
|
+
export function formatDuration(ms: number): string {
|
|
33
|
+
if (ms < 0) ms = 0;
|
|
34
|
+
const seconds = ms / 1000;
|
|
35
|
+
if (seconds < 10) {
|
|
36
|
+
return `${seconds.toFixed(1)}s`;
|
|
37
|
+
}
|
|
38
|
+
const totalSeconds = Math.floor(seconds);
|
|
39
|
+
if (totalSeconds < 60) {
|
|
40
|
+
return `${totalSeconds}s`;
|
|
41
|
+
}
|
|
42
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
43
|
+
const remainderSeconds = totalSeconds % 60;
|
|
44
|
+
if (totalMinutes < 60) {
|
|
45
|
+
return `${totalMinutes}m ${remainderSeconds}s`;
|
|
46
|
+
}
|
|
47
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
48
|
+
const remainderMinutes = totalMinutes % 60;
|
|
49
|
+
return `${hours}h ${remainderMinutes}m ${remainderSeconds}s`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Component that renders single-line text dynamically truncated to the
|
|
54
|
+
* viewport/terminal width via truncateToWidth.
|
|
55
|
+
*/
|
|
56
|
+
export class TruncatedTextComponent implements Component {
|
|
57
|
+
private text: string = "";
|
|
58
|
+
private theme: Theme | undefined = undefined;
|
|
59
|
+
|
|
60
|
+
constructor(text: string = "", theme?: Theme | undefined) {
|
|
61
|
+
this.text = text;
|
|
62
|
+
this.theme = theme;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setText(text: string): void {
|
|
66
|
+
this.text = text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setTheme(theme?: Theme | undefined): void {
|
|
70
|
+
this.theme = theme;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getContent(): string {
|
|
74
|
+
return this.text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
invalidate(): void {}
|
|
78
|
+
|
|
79
|
+
render(width: number): string[] {
|
|
80
|
+
if (!this.text) return [""];
|
|
81
|
+
const singleLine = this.text.replace(/[\r\n]+/g, " ");
|
|
82
|
+
const ellipsis = this.theme ? this.theme.fg("accent", "…") : "…";
|
|
83
|
+
const truncated = truncateToWidth(singleLine, width, ellipsis);
|
|
84
|
+
const padNeeded = Math.max(0, width - visibleWidth(truncated));
|
|
85
|
+
return [truncated + " ".repeat(padNeeded)];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format a bash command for display in the tool header line.
|
|
91
|
+
* Normalizes newlines to spaces and trims whitespace.
|
|
92
|
+
* If maxLength is specified, truncates via truncateToWidth with an ellipsis.
|
|
93
|
+
*/
|
|
94
|
+
export function formatBashCommand(command?: string, maxLength?: number): string | undefined {
|
|
95
|
+
if (typeof command !== "string") return undefined;
|
|
96
|
+
const oneLine = command.replace(/[\r\n]+/g, " ").trim();
|
|
97
|
+
if (!oneLine) return undefined;
|
|
98
|
+
if (typeof maxLength === "number" && maxLength > 0) {
|
|
99
|
+
return truncateToWidth(oneLine, maxLength, "…");
|
|
100
|
+
}
|
|
101
|
+
return oneLine;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getCallComponent(context: unknown): TruncatedTextComponent {
|
|
105
|
+
const ctx = context as { lastComponent?: unknown } | undefined;
|
|
106
|
+
if (ctx?.lastComponent instanceof TruncatedTextComponent) {
|
|
107
|
+
return ctx.lastComponent;
|
|
108
|
+
}
|
|
109
|
+
return new TruncatedTextComponent("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getTextComponent(context: unknown): Text {
|
|
113
|
+
const ctx = context as { lastComponent?: unknown } | undefined;
|
|
114
|
+
if (ctx?.lastComponent instanceof Text) {
|
|
115
|
+
return ctx.lastComponent;
|
|
116
|
+
}
|
|
117
|
+
return new Text("", 0, 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render the bash tool call line with bold header and command in accent color.
|
|
122
|
+
* Truncates dynamically to the viewport width at render time.
|
|
123
|
+
*
|
|
124
|
+
* Line 1: `bash` (bold toolTitle) + command (accent)
|
|
125
|
+
*/
|
|
126
|
+
export function renderBashCall(args: unknown, theme: Theme, context: unknown): TruncatedTextComponent {
|
|
127
|
+
const ctx = context as {
|
|
128
|
+
executionStarted?: boolean;
|
|
129
|
+
state?: BashRendererState;
|
|
130
|
+
args?: { command?: string; timeout?: number };
|
|
131
|
+
} | undefined;
|
|
132
|
+
|
|
133
|
+
const rawCmd =
|
|
134
|
+
(args as { command?: string; timeout?: number } | undefined)?.command ??
|
|
135
|
+
ctx?.args?.command;
|
|
136
|
+
const timeout =
|
|
137
|
+
(args as { timeout?: number } | undefined)?.timeout ?? ctx?.args?.timeout;
|
|
138
|
+
|
|
139
|
+
if (ctx) {
|
|
140
|
+
if (!ctx.state) {
|
|
141
|
+
ctx.state = {};
|
|
142
|
+
}
|
|
143
|
+
if (ctx.executionStarted && ctx.state.startedAt === undefined) {
|
|
144
|
+
ctx.state.startedAt = Date.now();
|
|
145
|
+
ctx.state.endedAt = undefined;
|
|
146
|
+
}
|
|
147
|
+
if (typeof timeout === "number" && Number.isFinite(timeout)) {
|
|
148
|
+
ctx.state.timeout = timeout;
|
|
149
|
+
}
|
|
150
|
+
if (typeof rawCmd === "string") {
|
|
151
|
+
ctx.state.command = rawCmd;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const comp = getCallComponent(context);
|
|
156
|
+
comp.setTheme(theme);
|
|
157
|
+
const displayCmd = formatBashCommand(rawCmd);
|
|
158
|
+
comp.setText(renderToolHeader("bash", displayCmd, theme));
|
|
159
|
+
return comp;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render the bash tool result line (collapsed or expanded view).
|
|
164
|
+
*/
|
|
165
|
+
export function renderBashResult(
|
|
166
|
+
result: unknown,
|
|
167
|
+
options: { expanded?: boolean; isPartial?: boolean },
|
|
168
|
+
theme: Theme,
|
|
169
|
+
context: unknown,
|
|
170
|
+
): Text {
|
|
171
|
+
const ctx = context as {
|
|
172
|
+
executionStarted?: boolean;
|
|
173
|
+
isError?: boolean;
|
|
174
|
+
state?: BashRendererState;
|
|
175
|
+
args?: { command?: string; timeout?: number };
|
|
176
|
+
invalidate?: () => void;
|
|
177
|
+
} | undefined;
|
|
178
|
+
|
|
179
|
+
const state: BashRendererState = ctx?.state ?? {};
|
|
180
|
+
if (ctx && !ctx.state) {
|
|
181
|
+
ctx.state = state;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if ((ctx?.executionStarted || options.isPartial) && state.startedAt === undefined) {
|
|
185
|
+
state.startedAt = Date.now();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (options.isPartial && !state.interval) {
|
|
189
|
+
if (ctx?.invalidate) {
|
|
190
|
+
state.interval = setInterval(() => {
|
|
191
|
+
ctx.invalidate?.();
|
|
192
|
+
}, 1000);
|
|
193
|
+
activeStates.add(state);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!options.isPartial || ctx?.isError) {
|
|
198
|
+
state.endedAt ??= Date.now();
|
|
199
|
+
if (state.interval) {
|
|
200
|
+
clearInterval(state.interval);
|
|
201
|
+
state.interval = undefined;
|
|
202
|
+
}
|
|
203
|
+
activeStates.delete(state);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const res = result as {
|
|
207
|
+
content?: Array<{ type: string; text?: string }>;
|
|
208
|
+
details?: {
|
|
209
|
+
truncation?: {
|
|
210
|
+
truncated?: boolean;
|
|
211
|
+
outputLines?: number;
|
|
212
|
+
totalLines?: number;
|
|
213
|
+
};
|
|
214
|
+
fullOutputPath?: string;
|
|
215
|
+
exitCode?: number;
|
|
216
|
+
};
|
|
217
|
+
isError?: boolean;
|
|
218
|
+
exitCode?: number;
|
|
219
|
+
} | undefined;
|
|
220
|
+
|
|
221
|
+
const isError = Boolean(ctx?.isError || res?.isError || result instanceof Error);
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const startTime = state.startedAt ?? (state.endedAt ?? now);
|
|
224
|
+
const endTime = state.endedAt ?? now;
|
|
225
|
+
const elapsed = Math.max(0, endTime - startTime);
|
|
226
|
+
|
|
227
|
+
const textComponent = getTextComponent(context);
|
|
228
|
+
|
|
229
|
+
if (!options.expanded) {
|
|
230
|
+
// Collapsed view:
|
|
231
|
+
// `<status> <timer> (timeout: <timeout value>)`
|
|
232
|
+
const statusGlyph = options.isPartial
|
|
233
|
+
? theme.fg("warning", "▸")
|
|
234
|
+
: isError
|
|
235
|
+
? theme.fg("error", "✗")
|
|
236
|
+
: theme.fg("success", "✓");
|
|
237
|
+
|
|
238
|
+
const timeout = ctx?.args?.timeout ?? state.timeout;
|
|
239
|
+
const timeoutSuffix =
|
|
240
|
+
typeof timeout === "number" && Number.isFinite(timeout)
|
|
241
|
+
? theme.fg("dim", ` (timeout: ${timeout}s)`)
|
|
242
|
+
: "";
|
|
243
|
+
|
|
244
|
+
const styledDuration = theme.fg("muted", formatDuration(elapsed));
|
|
245
|
+
|
|
246
|
+
textComponent.setText(`${statusGlyph} ${styledDuration}${timeoutSuffix}`);
|
|
247
|
+
return textComponent;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Expanded view:
|
|
251
|
+
const parts: string[] = [];
|
|
252
|
+
const rawCmd =
|
|
253
|
+
typeof ctx?.args?.command === "string"
|
|
254
|
+
? ctx.args.command
|
|
255
|
+
: (state.command ?? "");
|
|
256
|
+
parts.push(theme.fg("dim", "$ ") + theme.fg("toolOutput", rawCmd));
|
|
257
|
+
|
|
258
|
+
const textBlocks = Array.isArray(res?.content)
|
|
259
|
+
? res.content.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
260
|
+
: [];
|
|
261
|
+
|
|
262
|
+
const rawOutput = textBlocks.map((b) => b.text!).join("\n");
|
|
263
|
+
if (rawOutput.length > 0) {
|
|
264
|
+
const lines = rawOutput.split("\n");
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
parts.push(theme.fg(isError ? "error" : "toolOutput", line));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (res?.details?.truncation?.truncated) {
|
|
271
|
+
const truncation = res.details.truncation;
|
|
272
|
+
parts.push(
|
|
273
|
+
theme.fg(
|
|
274
|
+
"warning",
|
|
275
|
+
`[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines. Full output: ${res.details.fullOutputPath}]`,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const durationLabel = options.isPartial ? "Elapsed" : "Took";
|
|
281
|
+
parts.push(theme.fg("muted", `${durationLabel} ${formatDuration(elapsed)}`));
|
|
282
|
+
|
|
283
|
+
if (!options.isPartial) {
|
|
284
|
+
if (isError) {
|
|
285
|
+
const exitMatch = rawOutput.match(/(?:Command exited with code|exit code:?)\s*(\d+)/i);
|
|
286
|
+
const code = exitMatch
|
|
287
|
+
? exitMatch[1]
|
|
288
|
+
: typeof res?.details?.exitCode === "number"
|
|
289
|
+
? String(res.details.exitCode)
|
|
290
|
+
: typeof res?.exitCode === "number"
|
|
291
|
+
? String(res.exitCode)
|
|
292
|
+
: "1";
|
|
293
|
+
parts.push(theme.fg("error", `✗ Command exited with code ${code}`));
|
|
294
|
+
} else {
|
|
295
|
+
parts.push(theme.fg("success", "✓ Done"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
textComponent.setText(parts.join("\n"));
|
|
300
|
+
return textComponent;
|
|
301
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { registerBashToolOverride } from "./tool.js";
|
|
4
|
+
import { renderBashCall, renderBashResult } from "./renderer.js";
|
|
5
|
+
|
|
6
|
+
describe("registerBashToolOverride", () => {
|
|
7
|
+
it("registers bash tool with custom renderCall and renderResult while preserving definition", () => {
|
|
8
|
+
let capturedTool: any = null;
|
|
9
|
+
const pi = {
|
|
10
|
+
registerTool: vi.fn((tool: any) => {
|
|
11
|
+
capturedTool = tool;
|
|
12
|
+
}),
|
|
13
|
+
} as unknown as ExtensionAPI;
|
|
14
|
+
|
|
15
|
+
registerBashToolOverride(pi, "/test/cwd");
|
|
16
|
+
|
|
17
|
+
expect(pi.registerTool).toHaveBeenCalledTimes(1);
|
|
18
|
+
expect(capturedTool).toBeDefined();
|
|
19
|
+
expect(capturedTool.name).toBe("bash");
|
|
20
|
+
expect(capturedTool.label).toBe("bash");
|
|
21
|
+
expect(capturedTool.parameters).toBeDefined();
|
|
22
|
+
expect(typeof capturedTool.execute).toBe("function");
|
|
23
|
+
expect(capturedTool.renderCall).toBe(renderBashCall);
|
|
24
|
+
expect(capturedTool.renderResult).toBe(renderBashResult);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/bash/tool.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createBashToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { renderBashCall, renderBashResult } from "./renderer.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register custom styled bash tool override.
|
|
7
|
+
* Preserves core execution, parameters, timeouts, and PI_* environment injection
|
|
8
|
+
* while replacing the presentation with Archimedes styled rendering.
|
|
9
|
+
*/
|
|
10
|
+
export function registerBashToolOverride(pi: ExtensionAPI, cwd: string): void {
|
|
11
|
+
const def = createBashToolDefinition(cwd);
|
|
12
|
+
pi.registerTool({
|
|
13
|
+
...def,
|
|
14
|
+
renderCall: renderBashCall,
|
|
15
|
+
renderResult: renderBashResult,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
loadCodemodeModule,
|
|
3
|
+
resolveCodemodeModuleFiles,
|
|
4
|
+
findAgentPackageRoot,
|
|
5
|
+
type CodemodeToolModule,
|
|
6
|
+
} from "./loader.js";
|
|
7
|
+
export {
|
|
8
|
+
renderCodemodeCall,
|
|
9
|
+
renderCodemodeResult,
|
|
10
|
+
formatCodemodeDuration,
|
|
11
|
+
formatCost,
|
|
12
|
+
formatNestedCall,
|
|
13
|
+
getCodemodeOutput,
|
|
14
|
+
parseWallTimeMs,
|
|
15
|
+
clearActiveCodemodeIntervals,
|
|
16
|
+
type CodemodeNestedCall,
|
|
17
|
+
type CodemodeToolDetails,
|
|
18
|
+
type CodemodeRendererState,
|
|
19
|
+
} from "./renderer.js";
|
|
20
|
+
export { registerCodemodeToolOverride } from "./tool.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
findAgentPackageRoot,
|
|
7
|
+
resolveCodemodeModuleFiles,
|
|
8
|
+
loadCodemodeModule,
|
|
9
|
+
} from "./loader.js";
|
|
10
|
+
|
|
11
|
+
const createdDirs: string[] = [];
|
|
12
|
+
|
|
13
|
+
function makeTree(
|
|
14
|
+
rootName: string,
|
|
15
|
+
files: Record<string, string>,
|
|
16
|
+
): string {
|
|
17
|
+
const root = mkdtempSync(path.join(tmpdir(), `archimedes-${rootName}-`));
|
|
18
|
+
createdDirs.push(root);
|
|
19
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
20
|
+
const file = path.join(root, rel);
|
|
21
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
writeFileSync(file, content);
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const dir of createdDirs.splice(0)) {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
vi.unstubAllEnvs();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("findAgentPackageRoot", () => {
|
|
35
|
+
it("finds the package root from a nested dist file", () => {
|
|
36
|
+
const root = makeTree("agent", {
|
|
37
|
+
"package.json": JSON.stringify({ name: "@earendil-works/pi-coding-agent" }),
|
|
38
|
+
"dist/index.js": "",
|
|
39
|
+
"dist/extensions/codemode/tool.js": "",
|
|
40
|
+
});
|
|
41
|
+
expect(
|
|
42
|
+
findAgentPackageRoot(path.join(root, "dist", "extensions", "codemode")),
|
|
43
|
+
).toBe(root);
|
|
44
|
+
expect(findAgentPackageRoot(path.join(root, "dist"))).toBe(root);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns undefined for another package", () => {
|
|
48
|
+
const root = makeTree("other", {
|
|
49
|
+
"package.json": JSON.stringify({ name: "@pi-archimedes/ui" }),
|
|
50
|
+
"src/index.ts": "",
|
|
51
|
+
});
|
|
52
|
+
expect(findAgentPackageRoot(path.join(root, "src"))).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns undefined when there is no package.json at all", () => {
|
|
56
|
+
const root = makeTree("loose", { "some/file.js": "" });
|
|
57
|
+
expect(findAgentPackageRoot(root)).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("resolveCodemodeModuleFiles", () => {
|
|
62
|
+
it("resolves the codemode module from the CLI entry script (argv[1])", () => {
|
|
63
|
+
const root = makeTree("agent", {
|
|
64
|
+
"package.json": JSON.stringify({ name: "@earendil-works/pi-coding-agent" }),
|
|
65
|
+
"dist/bundle/cli.js": "",
|
|
66
|
+
"dist/extensions/codemode/tool.js": "",
|
|
67
|
+
});
|
|
68
|
+
const files = resolveCodemodeModuleFiles(
|
|
69
|
+
path.join(root, "dist", "bundle", "cli.js"),
|
|
70
|
+
);
|
|
71
|
+
expect(files).toContain(
|
|
72
|
+
path.join(root, "dist", "extensions", "codemode", "tool.js"),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns no argv[1] candidate when the entry is not in the agent package", () => {
|
|
77
|
+
const files = resolveCodemodeModuleFiles("/usr/bin/some-other-cli");
|
|
78
|
+
// Only the local-copy candidate (if any) may remain.
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
expect(file).not.toContain("/usr/bin/some-other-cli");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns an empty list without argv[1] and without a local copy", () => {
|
|
85
|
+
// createRequire fallback: the local pi-coding-agent copy in this repo has
|
|
86
|
+
// no dist/extensions/codemode in older versions, but the resolution
|
|
87
|
+
// itself must not throw.
|
|
88
|
+
expect(() => resolveCodemodeModuleFiles(undefined)).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("never builds a dist/dist path for the local-copy candidate", () => {
|
|
92
|
+
// require.resolve() returns …/pi-coding-agent/dist/index.js — its
|
|
93
|
+
// directory IS the dist dir, so joining another `dist` segment would
|
|
94
|
+
// produce a nonexistent …/dist/dist/… path and silently disable the
|
|
95
|
+
// override for launcher entries outside the agent package.
|
|
96
|
+
const files = resolveCodemodeModuleFiles(undefined);
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
expect(file).not.toContain(path.join("dist", "dist"));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("loadCodemodeModule", () => {
|
|
104
|
+
it("loads the module when the file exists and exports the factory", async () => {
|
|
105
|
+
const root = makeTree("agent", {
|
|
106
|
+
"package.json": JSON.stringify({ name: "@earendil-works/pi-coding-agent" }),
|
|
107
|
+
"dist/index.js": "",
|
|
108
|
+
"dist/extensions/codemode/tool.js": `
|
|
109
|
+
export const codemodeSchema = { type: "object" };
|
|
110
|
+
export function createCodemodeToolDefinition(options) {
|
|
111
|
+
return { options, parameters: codemodeSchema };
|
|
112
|
+
}
|
|
113
|
+
`,
|
|
114
|
+
});
|
|
115
|
+
const mod = await loadCodemodeModule(
|
|
116
|
+
path.join(root, "dist", "index.js"),
|
|
117
|
+
);
|
|
118
|
+
expect(mod).toBeDefined();
|
|
119
|
+
expect(mod!.createCodemodeToolDefinition).toBeTypeOf("function");
|
|
120
|
+
expect(mod!.codemodeSchema).toEqual({ type: "object" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns undefined when the module file is missing", async () => {
|
|
124
|
+
const mod = await loadCodemodeModule("/does/not/exist/cli.js");
|
|
125
|
+
expect(mod).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns undefined when the module lacks the factory", async () => {
|
|
129
|
+
const root = makeTree("agent", {
|
|
130
|
+
"package.json": JSON.stringify({ name: "@earendil-works/pi-coding-agent" }),
|
|
131
|
+
"dist/index.js": "",
|
|
132
|
+
"dist/extensions/codemode/tool.js": `
|
|
133
|
+
export const somethingElse = 1;
|
|
134
|
+
`,
|
|
135
|
+
});
|
|
136
|
+
const mod = await loadCodemodeModule(path.join(root, "dist", "index.js"));
|
|
137
|
+
expect(mod).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CodemodeToolModule {
|
|
5
|
+
createCodemodeToolDefinition: (options?: Record<string, unknown>) => unknown;
|
|
6
|
+
codemodeSchema: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const AGENT_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
|
10
|
+
/** Candidate 1: relative to the package ROOT (the CLI-install walk-up). */
|
|
11
|
+
const CODEMODE_MODULE_FROM_ROOT = path.join(
|
|
12
|
+
"dist",
|
|
13
|
+
"extensions",
|
|
14
|
+
"codemode",
|
|
15
|
+
"tool.js",
|
|
16
|
+
);
|
|
17
|
+
/** Candidate 2: relative to the `dist/` directory the package entry lives in. */
|
|
18
|
+
const CODEMODE_MODULE_FROM_DIST = path.join(
|
|
19
|
+
"extensions",
|
|
20
|
+
"codemode",
|
|
21
|
+
"tool.js",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Walk up from `startDir` to the package root (the nearest ancestor holding a
|
|
26
|
+
* package.json) and return it when it is the pi-coding-agent package.
|
|
27
|
+
*/
|
|
28
|
+
export function findAgentPackageRoot(startDir: string): string | undefined {
|
|
29
|
+
let dir = startDir;
|
|
30
|
+
while (true) {
|
|
31
|
+
const pkgPath = path.join(dir, "package.json");
|
|
32
|
+
if (existsSync(pkgPath)) {
|
|
33
|
+
try {
|
|
34
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
35
|
+
if (pkg.name === AGENT_PACKAGE_NAME) {
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Unreadable package.json — keep walking up.
|
|
40
|
+
}
|
|
41
|
+
// A package boundary without a matching name: stop (we are inside some
|
|
42
|
+
// other package's tree, not the agent's).
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const parent = path.dirname(dir);
|
|
46
|
+
if (parent === dir) return undefined;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Candidate files for the codemode tool module, in preference order:
|
|
53
|
+
*
|
|
54
|
+
* 1. The running pi CLI's own install. `process.argv[1]` is the CLI entry
|
|
55
|
+
* script (e.g. `…/pi-coding-agent/dist/bundle/cli.js`); walking up to the
|
|
56
|
+
* package root finds the `dist` the CLI is actually running from. Pi loads
|
|
57
|
+
* extensions with jiti, which shares the native module instance for `.js`
|
|
58
|
+
* files, so when the CLI runs unbundled this is the very same module the
|
|
59
|
+
* CLI's built-in codemode extension loaded — same schema object, same
|
|
60
|
+
* executor.
|
|
61
|
+
* 2. Our own node_modules copy (resolved via `import.meta.resolve`). A
|
|
62
|
+
* fallback for launchers whose argv[1] is not inside a pi-coding-agent
|
|
63
|
+
* install. Older pi versions have no `dist/extensions/codemode` at all,
|
|
64
|
+
* in which case the file does not exist and the candidate is skipped.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveCodemodeModuleFiles(argv1?: string): string[] {
|
|
67
|
+
const candidates: string[] = [];
|
|
68
|
+
const entry = argv1 ?? process.argv[1];
|
|
69
|
+
if (typeof entry === "string" && entry.length > 0) {
|
|
70
|
+
let root: string | undefined;
|
|
71
|
+
try {
|
|
72
|
+
root = findAgentPackageRoot(path.dirname(realpathSync(entry)));
|
|
73
|
+
} catch {
|
|
74
|
+
root = undefined;
|
|
75
|
+
}
|
|
76
|
+
if (root) {
|
|
77
|
+
candidates.push(path.join(root, CODEMODE_MODULE_FROM_ROOT));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
// `import.meta.resolve` (the `import` condition) — the agent package's
|
|
82
|
+
// exports map has no `require` condition, so a CJS `require.resolve`
|
|
83
|
+
// would fail on newer pi versions.
|
|
84
|
+
const agentEntry = new URL(import.meta.resolve(AGENT_PACKAGE_NAME)).pathname;
|
|
85
|
+
// `agentEntry` is `…/pi-coding-agent/dist/index.js` — its directory IS the
|
|
86
|
+
// `dist` dir, so join the module path WITHOUT another `dist` segment.
|
|
87
|
+
candidates.push(path.join(path.dirname(agentEntry), CODEMODE_MODULE_FROM_DIST));
|
|
88
|
+
} catch {
|
|
89
|
+
// No local copy of the agent package — nothing else to try.
|
|
90
|
+
}
|
|
91
|
+
return candidates;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Load the codemode tool module from the running pi install (see
|
|
96
|
+
* {@link resolveCodemodeModuleFiles}). Returns `undefined` when the module
|
|
97
|
+
* cannot be found or loaded (e.g. a pi version without the codemode
|
|
98
|
+
* extension), so the caller can fall back to pi's native rendering.
|
|
99
|
+
*/
|
|
100
|
+
export async function loadCodemodeModule(
|
|
101
|
+
argv1?: string,
|
|
102
|
+
): Promise<CodemodeToolModule | undefined> {
|
|
103
|
+
for (const file of resolveCodemodeModuleFiles(argv1)) {
|
|
104
|
+
if (!existsSync(file)) continue;
|
|
105
|
+
try {
|
|
106
|
+
const mod = await import(file);
|
|
107
|
+
if (typeof mod?.createCodemodeToolDefinition === "function") {
|
|
108
|
+
return mod as CodemodeToolModule;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Try the next candidate.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|