@pi-archimedes/diff 2.10.4 → 2.11.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/package.json +5 -5
- package/src/ansi/codes.test.ts +27 -3
- package/src/ansi/codes.ts +30 -10
- package/src/ansi/colors.test.ts +189 -7
- package/src/ansi/colors.ts +160 -37
- package/src/default-export.test.ts +16 -6
- package/src/index.ts +64 -23
- package/src/log.test.ts +68 -0
- package/src/log.ts +34 -0
- package/src/pi-support.test.ts +113 -0
- package/src/pi-support.ts +98 -0
- package/src/register-diff-tools.test.ts +81 -0
- package/src/tools/edit.test.ts +215 -0
- package/src/tools/edit.ts +97 -61
- package/src/tools/index.ts +8 -3
- package/src/tools/register.test.ts +265 -0
- package/src/tools/write.test.ts +161 -0
- package/src/tools/write.ts +99 -42
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row tests for the Archimedes `edit` presentation.
|
|
3
|
+
*
|
|
4
|
+
* These pin what an `edit` row looks like — the call row while arguments
|
|
5
|
+
* stream in, the same row with the pending diff, and the settled result line —
|
|
6
|
+
* so the move from a `registerTool()` take-over to `registerToolRenderer()` can
|
|
7
|
+
* be checked as *unchanged rendering* rather than re-derived from the code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { createEditTool } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { createEditRenderers, installEditTool } from "./edit.js";
|
|
17
|
+
|
|
18
|
+
// `diff` is standalone and must not import `@pi-archimedes/core` (see
|
|
19
|
+
// `src/pi-support.ts`), so the tests strip escape sequences themselves.
|
|
20
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
21
|
+
|
|
22
|
+
const theme = {
|
|
23
|
+
fg: (token: string, text?: string) =>
|
|
24
|
+
text === undefined ? `[${token}]` : `[${token}:${text}]`,
|
|
25
|
+
bold: (text: string) => `**${text}**`,
|
|
26
|
+
bg: (_token: string, text: string) => text,
|
|
27
|
+
italic: (text: string) => text,
|
|
28
|
+
} as any;
|
|
29
|
+
|
|
30
|
+
/** Install the resolvers and hand back the renderers pi would use for `edit`. */
|
|
31
|
+
function captureTool(cwd: string, home: string) {
|
|
32
|
+
let resolved: any;
|
|
33
|
+
installEditTool(
|
|
34
|
+
{
|
|
35
|
+
registerToolRenderer: (r: any) => {
|
|
36
|
+
resolved = r("edit", () => ({ renderShell: "self" }));
|
|
37
|
+
},
|
|
38
|
+
on: () => () => {},
|
|
39
|
+
} as unknown as ExtensionAPI,
|
|
40
|
+
cwd,
|
|
41
|
+
home,
|
|
42
|
+
);
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeWorkspace() {
|
|
47
|
+
const dir = mkdtempSync(path.join(tmpdir(), "diff-rows-"));
|
|
48
|
+
const file = path.join(dir, "note.txt");
|
|
49
|
+
writeFileSync(file, "one\ntwo\nthree\n");
|
|
50
|
+
return { dir, file, done: () => rmSync(dir, { recursive: true, force: true }) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rows = (component: any) => stripAnsi(component.render(80).join("\n"));
|
|
54
|
+
|
|
55
|
+
/** A render context, plus a promise that resolves when the row asks to redraw. */
|
|
56
|
+
function makeCtx(extra: Record<string, unknown> = {}) {
|
|
57
|
+
let wake: () => void = () => {};
|
|
58
|
+
const painted = new Promise<void>((resolve) => {
|
|
59
|
+
wake = resolve;
|
|
60
|
+
});
|
|
61
|
+
const ctx: any = {
|
|
62
|
+
state: {},
|
|
63
|
+
argsComplete: true,
|
|
64
|
+
outputPad: 1,
|
|
65
|
+
isError: false,
|
|
66
|
+
invalidate: () => wake(),
|
|
67
|
+
...extra,
|
|
68
|
+
};
|
|
69
|
+
return { ctx, painted };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("edit tool row", () => {
|
|
73
|
+
it("shows the tool name and the home-relative path while arguments stream in", () => {
|
|
74
|
+
const { dir, file, done } = makeWorkspace();
|
|
75
|
+
try {
|
|
76
|
+
const def = captureTool(dir, dir);
|
|
77
|
+
const { ctx } = makeCtx({ argsComplete: false });
|
|
78
|
+
const out = rows(def.renderCall({ path: file, edits: [{ oldText: "two", newText: "2" }] }, theme, ctx));
|
|
79
|
+
expect(out).toContain("**edit**");
|
|
80
|
+
expect(out).toContain("note.txt");
|
|
81
|
+
// Nothing of a diff yet — the arguments are still arriving.
|
|
82
|
+
expect(out).not.toContain("two");
|
|
83
|
+
} finally {
|
|
84
|
+
done();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("holds a diff preview under the header once the arguments are complete", () => {
|
|
89
|
+
const { dir, file, done } = makeWorkspace();
|
|
90
|
+
try {
|
|
91
|
+
const def = captureTool(dir, dir);
|
|
92
|
+
const { ctx } = makeCtx();
|
|
93
|
+
const call = def.renderCall({ path: file, edits: [{ oldText: "two", newText: "2" }] }, theme, ctx);
|
|
94
|
+
const out = rows(call);
|
|
95
|
+
expect(out).toContain("**edit**");
|
|
96
|
+
expect(out).toContain("note.txt");
|
|
97
|
+
// The preview is a slot of its own: Shiki colours it asynchronously and
|
|
98
|
+
// the row invalidates when the coloured lines land, so the first paint
|
|
99
|
+
// holds the placeholder (the coloured body is `shiki.test.ts`'s job).
|
|
100
|
+
expect(out).toContain("rendering diff…");
|
|
101
|
+
} finally {
|
|
102
|
+
done();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("summarises a single edit on the result line, with the changed line number", () => {
|
|
107
|
+
const { dir, file, done } = makeWorkspace();
|
|
108
|
+
try {
|
|
109
|
+
const def = captureTool(dir, dir);
|
|
110
|
+
const { ctx } = makeCtx();
|
|
111
|
+
def.renderCall({ path: file, edits: [{ oldText: "two", newText: "2" }] }, theme, ctx);
|
|
112
|
+
def.renderResult(
|
|
113
|
+
{ content: [{ type: "text", text: "ok" }], details: { _type: "editInfo", summary: "[+1:-1]", editLine: 2 } },
|
|
114
|
+
{ expanded: false, isPartial: false },
|
|
115
|
+
theme,
|
|
116
|
+
ctx,
|
|
117
|
+
);
|
|
118
|
+
const out = rows(ctx.state.callBox);
|
|
119
|
+
expect(out).toContain("[+1:-1]");
|
|
120
|
+
expect(out).toContain("at line 2");
|
|
121
|
+
} finally {
|
|
122
|
+
done();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("counts the edits on a multi-edit result line", () => {
|
|
127
|
+
const { dir, file, done } = makeWorkspace();
|
|
128
|
+
try {
|
|
129
|
+
const def = captureTool(dir, dir);
|
|
130
|
+
const { ctx } = makeCtx();
|
|
131
|
+
def.renderCall(
|
|
132
|
+
{
|
|
133
|
+
path: file,
|
|
134
|
+
edits: [
|
|
135
|
+
{ oldText: "one", newText: "1" },
|
|
136
|
+
{ oldText: "two", newText: "2" },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
theme,
|
|
140
|
+
ctx,
|
|
141
|
+
);
|
|
142
|
+
def.renderResult(
|
|
143
|
+
{
|
|
144
|
+
content: [{ type: "text", text: "ok" }],
|
|
145
|
+
details: { _type: "multiEditInfo", summary: "[+2:-2]", editCount: 2, diffLineCount: 4 },
|
|
146
|
+
},
|
|
147
|
+
{ expanded: false, isPartial: false },
|
|
148
|
+
theme,
|
|
149
|
+
ctx,
|
|
150
|
+
);
|
|
151
|
+
const out = rows(ctx.state.callBox);
|
|
152
|
+
expect(out).toContain("2 edits");
|
|
153
|
+
expect(out).toContain("(4 diff lines)");
|
|
154
|
+
} finally {
|
|
155
|
+
done();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shows the failure text on an errored result", () => {
|
|
160
|
+
const { dir, file, done } = makeWorkspace();
|
|
161
|
+
try {
|
|
162
|
+
const def = captureTool(dir, dir);
|
|
163
|
+
const ctx = { state: {}, isError: true, outputPad: 1 } as any;
|
|
164
|
+
def.renderCall({ path: file, edits: [{ oldText: "two", newText: "2" }] }, theme, {
|
|
165
|
+
state: ctx.state,
|
|
166
|
+
argsComplete: false,
|
|
167
|
+
outputPad: 1,
|
|
168
|
+
isError: true,
|
|
169
|
+
invalidate: () => {},
|
|
170
|
+
});
|
|
171
|
+
def.renderResult(
|
|
172
|
+
{ content: [{ type: "text", text: "oldText not found" }], details: undefined },
|
|
173
|
+
{ expanded: false, isPartial: false },
|
|
174
|
+
theme,
|
|
175
|
+
ctx,
|
|
176
|
+
);
|
|
177
|
+
expect(rows(ctx.state.callBox)).toContain("oldText not found");
|
|
178
|
+
} finally {
|
|
179
|
+
done();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("edit row padding", () => {
|
|
185
|
+
// The row draws its own shell (`renderShell: "self"`), so the inset is ours to
|
|
186
|
+
// choose — but pi still tells the renderer how much tool output is padded
|
|
187
|
+
// here, and that number is what the row must respect.
|
|
188
|
+
const renderers = createEditRenderers("/project", "/home");
|
|
189
|
+
const callArgs = { path: "/project/src/a.ts", edits: [{ oldText: "x", newText: "y" }] };
|
|
190
|
+
|
|
191
|
+
function padXFor(outputPad: number): number {
|
|
192
|
+
const state: any = {};
|
|
193
|
+
renderers.renderCall!(
|
|
194
|
+
callArgs as any,
|
|
195
|
+
theme as any,
|
|
196
|
+
{
|
|
197
|
+
argsComplete: true,
|
|
198
|
+
cwd: "/project",
|
|
199
|
+
cwdOrParent: "/project",
|
|
200
|
+
hasUI: false,
|
|
201
|
+
outputPad,
|
|
202
|
+
state,
|
|
203
|
+
} as any,
|
|
204
|
+
);
|
|
205
|
+
return (state.callBox as any).paddingX;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("keeps its own one-column inset where pi's output shell adds none", () => {
|
|
209
|
+
expect(padXFor(0)).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("grows to pi's output padding when pi asks for more", () => {
|
|
213
|
+
expect(padXFor(2)).toBe(2);
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/tools/edit.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Edit tool
|
|
1
|
+
/** Edit tool presentation: a diff-drawing row plus the result details it reads. */
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Box, Spacer, Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { parseDiff } from "../core/diff.js";
|
|
7
7
|
import * as Ansi from "../ansi/index.js";
|
|
@@ -10,82 +10,96 @@ import { lang } from "../shiki.js";
|
|
|
10
10
|
import { MAX_PREVIEW_LINES } from "../render/index.js";
|
|
11
11
|
import { DiffComponent } from "../diff-component.js";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
export function registerEditTool(
|
|
15
|
-
pi: ExtensionAPI,
|
|
16
|
-
cwd: string,
|
|
17
|
-
home: string,
|
|
18
|
-
createEditTool: (cwd: string) => any,
|
|
19
|
-
): void {
|
|
20
|
-
const sp = (p: string) => Ansi.shortPath(cwd, home, p);
|
|
21
|
-
const origEdit = createEditTool(cwd);
|
|
22
|
-
|
|
23
|
-
function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
|
|
24
|
-
if (Array.isArray(input?.edits)) {
|
|
25
|
-
return input.edits
|
|
26
|
-
.map((edit: any) => ({
|
|
27
|
-
oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
|
|
28
|
-
newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
|
|
29
|
-
}))
|
|
30
|
-
.filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
|
|
31
|
-
}
|
|
32
|
-
const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
|
|
33
|
-
const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
|
|
34
|
-
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
35
|
-
}
|
|
13
|
+
interface EditOperation { oldText: string; newText: string }
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
15
|
+
function getEditOperations(input: any): EditOperation[] {
|
|
16
|
+
if (Array.isArray(input?.edits)) {
|
|
17
|
+
return input.edits
|
|
18
|
+
.map((edit: any) => ({
|
|
19
|
+
oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
|
|
20
|
+
newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
|
|
21
|
+
}))
|
|
22
|
+
.filter((edit: EditOperation) => edit.oldText && edit.oldText !== edit.newText);
|
|
42
23
|
}
|
|
24
|
+
const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
|
|
25
|
+
const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
|
|
26
|
+
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
27
|
+
}
|
|
43
28
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const fp = params.path ?? params.file_path ?? "";
|
|
51
|
-
const operations = getEditOperations(params);
|
|
52
|
-
const result = await origEdit.execute(tid, params, sig, upd, ctx);
|
|
53
|
-
|
|
54
|
-
if (operations.length === 0) return result;
|
|
29
|
+
function summarizeEditOperations(operations: EditOperation[]) {
|
|
30
|
+
const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
|
|
31
|
+
const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
|
|
32
|
+
const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
|
|
33
|
+
return { diffs, totalAdded, totalRemoved, summary: Ansi.summarize(totalAdded, totalRemoved) };
|
|
34
|
+
}
|
|
55
35
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
36
|
+
/**
|
|
37
|
+
* What the `edit` row needs from a settled result, computed from the call's
|
|
38
|
+
* arguments and the file as it is now (the call has run by then). Returned as
|
|
39
|
+
* `{ details }` for pi's `tool_result` event, which may replace the result's
|
|
40
|
+
* details — the same values the row read when this package re-registered the
|
|
41
|
+
* tool and wrapped its `execute`.
|
|
42
|
+
*/
|
|
43
|
+
export function editResultDetails(event: {
|
|
44
|
+
input: Record<string, unknown>;
|
|
45
|
+
isError: boolean;
|
|
46
|
+
}): { details: unknown } | undefined {
|
|
47
|
+
if (event.isError) return undefined;
|
|
48
|
+
const operations = getEditOperations(event.input);
|
|
49
|
+
if (operations.length === 0) return undefined;
|
|
50
|
+
|
|
51
|
+
const { diffs, summary } = summarizeEditOperations(operations);
|
|
52
|
+
if (operations.length === 1) {
|
|
53
|
+
const fp = String((event.input as any).path ?? (event.input as any).file_path ?? "");
|
|
54
|
+
let editLine = 0;
|
|
55
|
+
try {
|
|
56
|
+
if (fp && existsSync(fp)) {
|
|
57
|
+
const f = readFileSync(fp, "utf-8");
|
|
58
|
+
const idx = f.indexOf(operations[0]!.newText);
|
|
59
|
+
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
68
60
|
}
|
|
61
|
+
} catch {
|
|
62
|
+
editLine = 0;
|
|
63
|
+
}
|
|
64
|
+
return { details: { _type: "editInfo", summary, editLine } };
|
|
65
|
+
}
|
|
69
66
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return result;
|
|
67
|
+
return {
|
|
68
|
+
details: {
|
|
69
|
+
_type: "multiEditInfo",
|
|
70
|
+
summary,
|
|
71
|
+
editCount: operations.length,
|
|
72
|
+
diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
|
|
77
73
|
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The Archimedes `edit` renderers, closed over the paths they shorten against. */
|
|
78
|
+
export function createEditRenderers(cwd: string, home: string): ToolRenderers {
|
|
79
|
+
const sp = (p: string) => Ansi.shortPath(cwd, home, p);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
renderShell: "self",
|
|
78
83
|
|
|
79
84
|
renderCall(args: any, theme: any, ctx: any) {
|
|
80
85
|
const fp = args?.path ?? args?.file_path ?? "";
|
|
81
86
|
const operations = getEditOperations(args);
|
|
82
87
|
|
|
88
|
+
// `outputPad` is the user's `outputPad` setting (0 or 1 on pi 1.2.0) and
|
|
89
|
+
// says nothing about the shell: under `renderShell: "self"` pi composes the
|
|
90
|
+
// row in a plain container and applies none of it itself, so the inset here
|
|
91
|
+
// is ours to decide. The row keeps its own one-column inset as the floor —
|
|
92
|
+
// its background is a Box, which needs the inset to read as a block — and
|
|
93
|
+
// grows to whatever pi asks for rather than hard-coding a number.
|
|
94
|
+
const padX = Math.max(1, ctx.outputPad ?? 1);
|
|
95
|
+
|
|
83
96
|
// Box with padding + background — matches built-in edit tool pattern
|
|
84
97
|
const box = ctx.state.callBox as Box | undefined;
|
|
85
98
|
if (box) {
|
|
86
99
|
box.setBgFn((s: string) => theme.bg("toolPendingBg", s));
|
|
100
|
+
box.setPaddingX(padX);
|
|
87
101
|
}
|
|
88
|
-
const shell = box ?? new Box(
|
|
102
|
+
const shell = box ?? new Box(padX, 1, (s: string) => theme.bg("toolPendingBg", s));
|
|
89
103
|
if (!box) ctx.state.callBox = shell;
|
|
90
104
|
|
|
91
105
|
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
@@ -186,5 +200,27 @@ export function registerEditTool(
|
|
|
186
200
|
ctx.state._resKey = key;
|
|
187
201
|
return box ? empty : t;
|
|
188
202
|
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Draw `edit` through pi's renderer resolution and attach the result details its
|
|
208
|
+
* row reads (ADR 0029). `next()` is spread first so anything the built-in
|
|
209
|
+
* definition declares besides the two renderers — its `renderShell` today —
|
|
210
|
+
* survives resolution instead of being silently dropped by us.
|
|
211
|
+
*
|
|
212
|
+
* Called once per extension load: pi keeps a list of resolvers and event
|
|
213
|
+
* handlers per extension, so calling it from `session_start` would stack a
|
|
214
|
+
* duplicate on every `/reload`.
|
|
215
|
+
*/
|
|
216
|
+
export function installEditTool(pi: ExtensionAPI, cwd: string, home: string): void {
|
|
217
|
+
const renderers = createEditRenderers(cwd, home);
|
|
218
|
+
pi.registerToolRenderer((toolName, next) =>
|
|
219
|
+
toolName === "edit" ? { ...next(), ...renderers } : next(),
|
|
220
|
+
);
|
|
221
|
+
pi.on("tool_result", (event) => {
|
|
222
|
+
if (event.toolName !== "edit") return undefined;
|
|
223
|
+
return editResultDetails(event);
|
|
189
224
|
});
|
|
190
225
|
}
|
|
226
|
+
|
package/src/tools/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
/** Tool
|
|
1
|
+
/** Tool presentation — barrel re-export. */
|
|
2
2
|
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { installEditTool, createEditRenderers, editResultDetails } from "./edit.js";
|
|
4
|
+
export {
|
|
5
|
+
installWriteTool,
|
|
6
|
+
createWriteRenderers,
|
|
7
|
+
writeResultDetails,
|
|
8
|
+
readDisplacedContent,
|
|
9
|
+
} from "./write.js";
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration contract for the Archimedes `edit` / `write` presentation
|
|
3
|
+
* (ADR 0029).
|
|
4
|
+
*
|
|
5
|
+
* Both tools used to be re-registered with `pi.registerTool()` purely to draw
|
|
6
|
+
* them differently and to attach the diff details the renderers read. They are
|
|
7
|
+
* now a renderer resolver plus a `tool_result` handler, so these tests pin:
|
|
8
|
+
* nothing is registered, resolution answers only for the tool each installer
|
|
9
|
+
* owns, the built-in's `renderShell` choice survives resolution, and the result
|
|
10
|
+
* details the rows depend on still arrive.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
19
|
+
import { installEditTool } from "./edit.js";
|
|
20
|
+
import { installWriteTool } from "./write.js";
|
|
21
|
+
|
|
22
|
+
/** A `pi` handle that records what was registered. */
|
|
23
|
+
function fakePi() {
|
|
24
|
+
const renderers: ((name: string, next: () => any) => any)[] = [];
|
|
25
|
+
const handlers: Record<string, ((event: any) => any)[]> = {};
|
|
26
|
+
const pi = {
|
|
27
|
+
registerTool: (t: any) => {
|
|
28
|
+
registered.push(t);
|
|
29
|
+
},
|
|
30
|
+
registerToolRenderer: (r: (name: string, next: () => any) => any) => {
|
|
31
|
+
renderers.push(r);
|
|
32
|
+
},
|
|
33
|
+
on: (event: string, handler: (e: any) => any) => {
|
|
34
|
+
(handlers[event] ??= []).push(handler);
|
|
35
|
+
return () => {};
|
|
36
|
+
},
|
|
37
|
+
} as unknown as ExtensionAPI;
|
|
38
|
+
const registered: any[] = [];
|
|
39
|
+
return { pi, renderers, handlers, registered };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NATIVE = Object.freeze({ native: true, renderShell: "default" });
|
|
43
|
+
|
|
44
|
+
function resolve(
|
|
45
|
+
resolver: ((name: string, next: () => any) => any) | undefined,
|
|
46
|
+
toolName: string,
|
|
47
|
+
) {
|
|
48
|
+
let nextCalls = 0;
|
|
49
|
+
const result = resolver?.(toolName, () => {
|
|
50
|
+
nextCalls++;
|
|
51
|
+
return NATIVE;
|
|
52
|
+
});
|
|
53
|
+
return { result, nextCalls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeWorkspace(contents = "one\ntwo\nthree\n") {
|
|
57
|
+
const dir = mkdtempSync(path.join(tmpdir(), "diff-reg-"));
|
|
58
|
+
const file = path.join(dir, "note.txt");
|
|
59
|
+
writeFileSync(file, contents);
|
|
60
|
+
return { dir, file, done: () => rmSync(dir, { recursive: true, force: true }) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const emit = (
|
|
64
|
+
handlers: Record<string, ((event: any) => any)[]>,
|
|
65
|
+
event: string,
|
|
66
|
+
payload: any,
|
|
67
|
+
) => handlers[event]?.map((h) => h(payload)).find((r) => r !== undefined);
|
|
68
|
+
|
|
69
|
+
describe("edit registration", () => {
|
|
70
|
+
it("registers no tool and resolves only `edit`", () => {
|
|
71
|
+
const { pi, renderers, registered } = fakePi();
|
|
72
|
+
installEditTool(pi, "/work", "/home");
|
|
73
|
+
|
|
74
|
+
expect(registered).toHaveLength(0);
|
|
75
|
+
expect(renderers).toHaveLength(1);
|
|
76
|
+
expect(typeof resolve(renderers[0], "edit").result).toBe("object");
|
|
77
|
+
expect(resolve(renderers[0], "write")).toEqual({ result: NATIVE, nextCalls: 1 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps the built-in's own renderShell while replacing the renderers", () => {
|
|
81
|
+
const { pi, renderers } = fakePi();
|
|
82
|
+
installEditTool(pi, "/work", "/home");
|
|
83
|
+
|
|
84
|
+
const { result } = resolve(renderers[0], "edit");
|
|
85
|
+
expect(typeof result.renderCall).toBe("function");
|
|
86
|
+
expect(typeof result.renderResult).toBe("function");
|
|
87
|
+
// pi's edit definition draws its own shell; resolution must not lose it.
|
|
88
|
+
expect(result.renderShell).toBe("self");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("attaches a one-edit summary and the changed line to the result", () => {
|
|
92
|
+
const { dir, file, done } = makeWorkspace("one\ntwo\nthree\n");
|
|
93
|
+
try {
|
|
94
|
+
const { pi, handlers } = fakePi();
|
|
95
|
+
installEditTool(pi, dir, dir);
|
|
96
|
+
writeFileSync(file, "one\n2\nthree\n");
|
|
97
|
+
|
|
98
|
+
const out = emit(handlers, "tool_result", {
|
|
99
|
+
type: "tool_result",
|
|
100
|
+
toolName: "edit",
|
|
101
|
+
toolCallId: "c1",
|
|
102
|
+
isError: false,
|
|
103
|
+
input: { path: file, edits: [{ oldText: "two", newText: "2" }] },
|
|
104
|
+
content: [{ type: "text", text: "ok" }],
|
|
105
|
+
details: undefined,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(out?.details?._type).toBe("editInfo");
|
|
109
|
+
expect(out?.details?.editLine).toBe(2);
|
|
110
|
+
expect(out?.details?.summary).toBeTruthy();
|
|
111
|
+
} finally {
|
|
112
|
+
done();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("counts several edits in one call", () => {
|
|
117
|
+
const { dir, file, done } = makeWorkspace();
|
|
118
|
+
try {
|
|
119
|
+
const { pi, handlers } = fakePi();
|
|
120
|
+
installEditTool(pi, dir, dir);
|
|
121
|
+
|
|
122
|
+
const out = emit(handlers, "tool_result", {
|
|
123
|
+
type: "tool_result",
|
|
124
|
+
toolName: "edit",
|
|
125
|
+
toolCallId: "c2",
|
|
126
|
+
isError: false,
|
|
127
|
+
input: {
|
|
128
|
+
path: file,
|
|
129
|
+
edits: [
|
|
130
|
+
{ oldText: "one", newText: "1" },
|
|
131
|
+
{ oldText: "two", newText: "2" },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
content: [{ type: "text", text: "ok" }],
|
|
135
|
+
details: undefined,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(out?.details?._type).toBe("multiEditInfo");
|
|
139
|
+
expect(out?.details?.editCount).toBe(2);
|
|
140
|
+
} finally {
|
|
141
|
+
done();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("leaves a failed edit alone", () => {
|
|
146
|
+
const { dir, file, done } = makeWorkspace();
|
|
147
|
+
try {
|
|
148
|
+
const { pi, handlers } = fakePi();
|
|
149
|
+
installEditTool(pi, dir, dir);
|
|
150
|
+
|
|
151
|
+
const out = emit(handlers, "tool_result", {
|
|
152
|
+
type: "tool_result",
|
|
153
|
+
toolName: "edit",
|
|
154
|
+
toolCallId: "c3",
|
|
155
|
+
isError: true,
|
|
156
|
+
input: { path: file, edits: [{ oldText: "nope", newText: "x" }] },
|
|
157
|
+
content: [{ type: "text", text: "oldText not found" }],
|
|
158
|
+
details: undefined,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(out).toBeUndefined();
|
|
162
|
+
} finally {
|
|
163
|
+
done();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("write registration", () => {
|
|
169
|
+
it("registers no tool and resolves only `write`", () => {
|
|
170
|
+
const { pi, renderers, registered } = fakePi();
|
|
171
|
+
installWriteTool(pi, "/work", "/home", Text as any);
|
|
172
|
+
|
|
173
|
+
expect(registered).toHaveLength(0);
|
|
174
|
+
expect(resolve(renderers[0], "write").result?.renderShell).toBe("default");
|
|
175
|
+
expect(resolve(renderers[0], "edit")).toEqual({ result: NATIVE, nextCalls: 1 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("diffs the replaced file against the content it displaces", () => {
|
|
179
|
+
const { dir, file, done } = makeWorkspace("one\ntwo\n");
|
|
180
|
+
try {
|
|
181
|
+
const { pi, handlers } = fakePi();
|
|
182
|
+
installWriteTool(pi, dir, dir, Text as any);
|
|
183
|
+
|
|
184
|
+
emit(handlers, "tool_call", {
|
|
185
|
+
type: "tool_call",
|
|
186
|
+
toolName: "write",
|
|
187
|
+
toolCallId: "w1",
|
|
188
|
+
input: { path: file, content: "one\n2\n" },
|
|
189
|
+
});
|
|
190
|
+
writeFileSync(file, "one\n2\n");
|
|
191
|
+
const out = emit(handlers, "tool_result", {
|
|
192
|
+
type: "tool_result",
|
|
193
|
+
toolName: "write",
|
|
194
|
+
toolCallId: "w1",
|
|
195
|
+
isError: false,
|
|
196
|
+
input: { path: file, content: "one\n2\n" },
|
|
197
|
+
content: [{ type: "text", text: "wrote" }],
|
|
198
|
+
details: undefined,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(out?.details?._type).toBe("diff");
|
|
202
|
+
expect(out?.details?.summary).toBeTruthy();
|
|
203
|
+
} finally {
|
|
204
|
+
done();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("reports a new file by line count", () => {
|
|
209
|
+
const { dir, done } = makeWorkspace("");
|
|
210
|
+
try {
|
|
211
|
+
const fresh = path.join(dir, "fresh.txt");
|
|
212
|
+
const { pi, handlers } = fakePi();
|
|
213
|
+
installWriteTool(pi, dir, dir, Text as any);
|
|
214
|
+
|
|
215
|
+
emit(handlers, "tool_call", {
|
|
216
|
+
type: "tool_call",
|
|
217
|
+
toolName: "write",
|
|
218
|
+
toolCallId: "w2",
|
|
219
|
+
input: { path: fresh, content: "a\nb\nc\n" },
|
|
220
|
+
});
|
|
221
|
+
const out = emit(handlers, "tool_result", {
|
|
222
|
+
type: "tool_result",
|
|
223
|
+
toolName: "write",
|
|
224
|
+
toolCallId: "w2",
|
|
225
|
+
isError: false,
|
|
226
|
+
input: { path: fresh, content: "a\nb\nc\n" },
|
|
227
|
+
content: [{ type: "text", text: "wrote" }],
|
|
228
|
+
details: undefined,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(out?.details?._type).toBe("new");
|
|
232
|
+
expect(out?.details?.lines).toBe(4);
|
|
233
|
+
} finally {
|
|
234
|
+
done();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("says so when the written content matches what was there", () => {
|
|
239
|
+
const { dir, file, done } = makeWorkspace("same\n");
|
|
240
|
+
try {
|
|
241
|
+
const { pi, handlers } = fakePi();
|
|
242
|
+
installWriteTool(pi, dir, dir, Text as any);
|
|
243
|
+
|
|
244
|
+
emit(handlers, "tool_call", {
|
|
245
|
+
type: "tool_call",
|
|
246
|
+
toolName: "write",
|
|
247
|
+
toolCallId: "w3",
|
|
248
|
+
input: { path: file, content: "same\n" },
|
|
249
|
+
});
|
|
250
|
+
const out = emit(handlers, "tool_result", {
|
|
251
|
+
type: "tool_result",
|
|
252
|
+
toolName: "write",
|
|
253
|
+
toolCallId: "w3",
|
|
254
|
+
isError: false,
|
|
255
|
+
input: { path: file, content: "same\n" },
|
|
256
|
+
content: [{ type: "text", text: "wrote" }],
|
|
257
|
+
details: undefined,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(out?.details?._type).toBe("noChange");
|
|
261
|
+
} finally {
|
|
262
|
+
done();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|