@pi-archimedes/diff 2.10.4 → 2.11.1
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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row tests for the Archimedes `write` presentation.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart to `edit.test.ts`: what a `write` row looks like while its
|
|
5
|
+
* arguments stream, and for each settled outcome the result renderer knows —
|
|
6
|
+
* new file, replaced file, no change, failure. Written before the move from a
|
|
7
|
+
* `registerTool()` take-over to `registerToolRenderer()`, so that migration can
|
|
8
|
+
* be shown to change resolution only, not rendering.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import { installWriteTool } from "./write.js";
|
|
18
|
+
|
|
19
|
+
// `diff` is standalone and must not import `@pi-archimedes/core` (see
|
|
20
|
+
// `src/pi-support.ts`), so the tests strip escape sequences themselves.
|
|
21
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
22
|
+
|
|
23
|
+
const theme = {
|
|
24
|
+
fg: (token: string, text?: string) =>
|
|
25
|
+
text === undefined ? `[${token}]` : `[${token}:${text}]`,
|
|
26
|
+
bold: (text: string) => `**${text}**`,
|
|
27
|
+
bg: (_token: string, text: string) => text,
|
|
28
|
+
italic: (text: string) => text,
|
|
29
|
+
} as any;
|
|
30
|
+
|
|
31
|
+
/** Install the resolvers and hand back the renderers pi would use for `write`. */
|
|
32
|
+
function captureTool(cwd: string, home: string) {
|
|
33
|
+
let resolved: any;
|
|
34
|
+
installWriteTool(
|
|
35
|
+
{
|
|
36
|
+
registerToolRenderer: (r: any) => {
|
|
37
|
+
resolved = r("write", () => ({ renderShell: "default" }));
|
|
38
|
+
},
|
|
39
|
+
on: () => () => {},
|
|
40
|
+
} as unknown as ExtensionAPI,
|
|
41
|
+
cwd,
|
|
42
|
+
home,
|
|
43
|
+
Text as any,
|
|
44
|
+
);
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeWorkspace() {
|
|
49
|
+
const dir = mkdtempSync(path.join(tmpdir(), "diff-rows-"));
|
|
50
|
+
const existing = path.join(dir, "note.txt");
|
|
51
|
+
writeFileSync(existing, "one\ntwo\n");
|
|
52
|
+
return {
|
|
53
|
+
dir,
|
|
54
|
+
existing,
|
|
55
|
+
fresh: path.join(dir, "fresh.txt"),
|
|
56
|
+
done: () => rmSync(dir, { recursive: true, force: true }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const row = (component: any) => stripAnsi(component.render(80).join("\n"));
|
|
61
|
+
|
|
62
|
+
describe("write tool row", () => {
|
|
63
|
+
it("labels the row `write` for an existing file and `create` for a new one", () => {
|
|
64
|
+
const { dir, existing, fresh, done } = makeWorkspace();
|
|
65
|
+
try {
|
|
66
|
+
const def = captureTool(dir, dir);
|
|
67
|
+
const written = row(
|
|
68
|
+
def.renderCall({ path: existing, content: "x" }, theme, {
|
|
69
|
+
state: {},
|
|
70
|
+
argsComplete: true,
|
|
71
|
+
outputPad: 1,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
expect(written).toContain("**write**");
|
|
75
|
+
expect(written).toContain("note.txt");
|
|
76
|
+
|
|
77
|
+
const created = row(
|
|
78
|
+
def.renderCall({ path: fresh, content: "x" }, theme, {
|
|
79
|
+
state: {},
|
|
80
|
+
argsComplete: true,
|
|
81
|
+
outputPad: 1,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
expect(created).toContain("**create**");
|
|
85
|
+
expect(created).toContain("fresh.txt");
|
|
86
|
+
} finally {
|
|
87
|
+
done();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("counts the incoming lines while the content is still streaming", () => {
|
|
92
|
+
const { dir, existing, done } = makeWorkspace();
|
|
93
|
+
try {
|
|
94
|
+
const def = captureTool(dir, dir);
|
|
95
|
+
const out = row(
|
|
96
|
+
def.renderCall({ path: existing, content: "a\nb\nc" }, theme, {
|
|
97
|
+
state: {},
|
|
98
|
+
argsComplete: false,
|
|
99
|
+
outputPad: 1,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
expect(out).toContain("(3 lines…)");
|
|
103
|
+
} finally {
|
|
104
|
+
done();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports a new file with its line count", () => {
|
|
109
|
+
const { dir, fresh, done } = makeWorkspace();
|
|
110
|
+
try {
|
|
111
|
+
const def = captureTool(dir, dir);
|
|
112
|
+
const out = row(
|
|
113
|
+
def.renderResult(
|
|
114
|
+
{ content: [{ type: "text", text: "wrote" }], details: { _type: "new", lines: 4, content: "", filePath: fresh } },
|
|
115
|
+
{ expanded: false, isPartial: false },
|
|
116
|
+
theme,
|
|
117
|
+
{ state: {}, isError: false, outputPad: 1 },
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
expect(out).toContain("[success:✓ new file (4 lines)]");
|
|
121
|
+
} finally {
|
|
122
|
+
done();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("says so when the write changed nothing", () => {
|
|
127
|
+
const { dir, fresh, done } = makeWorkspace();
|
|
128
|
+
try {
|
|
129
|
+
const def = captureTool(dir, dir);
|
|
130
|
+
const out = row(
|
|
131
|
+
def.renderResult(
|
|
132
|
+
{ content: [{ type: "text", text: "wrote" }], details: { _type: "noChange" } },
|
|
133
|
+
{ expanded: false, isPartial: false },
|
|
134
|
+
theme,
|
|
135
|
+
{ state: {}, isError: false, outputPad: 1 },
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
expect(out).toContain("✓ no changes");
|
|
139
|
+
} finally {
|
|
140
|
+
done();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("shows the failure text on an errored result", () => {
|
|
145
|
+
const { dir, fresh, done } = makeWorkspace();
|
|
146
|
+
try {
|
|
147
|
+
const def = captureTool(dir, dir);
|
|
148
|
+
const out = row(
|
|
149
|
+
def.renderResult(
|
|
150
|
+
{ content: [{ type: "text", text: "EACCES: permission denied" }], details: undefined },
|
|
151
|
+
{ expanded: false, isPartial: false },
|
|
152
|
+
theme,
|
|
153
|
+
{ state: {}, isError: true, outputPad: 1 },
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
expect(out).toContain("EACCES: permission denied");
|
|
157
|
+
} finally {
|
|
158
|
+
done();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/tools/write.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Write tool
|
|
1
|
+
/** Write 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,
|
|
4
|
+
import type { ExtensionAPI, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { Component } from "@earendil-works/pi-tui";
|
|
6
6
|
import { parseDiff } from "../core/diff.js";
|
|
7
7
|
import * as Ansi from "../ansi/index.js";
|
|
@@ -10,51 +10,67 @@ import { lang, hlBlock } from "../shiki.js";
|
|
|
10
10
|
import { DiffComponent } from "../diff-component.js";
|
|
11
11
|
import { MAX_RENDER_LINES } from "../render/index.js";
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The content a `write` call displaces, read while the call is still about to
|
|
15
|
+
* run. `null` means there was no file — which is what separates "new file" from
|
|
16
|
+
* "replaced file" on the row.
|
|
17
|
+
*/
|
|
18
|
+
export function readDisplacedContent(input: Record<string, unknown>): string | null {
|
|
19
|
+
const fp = String((input as any).path ?? (input as any).file_path ?? "");
|
|
20
|
+
try {
|
|
21
|
+
return fp && existsSync(fp) ? readFileSync(fp, "utf-8") : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the `write` row needs from a settled result. The content being written
|
|
29
|
+
* is in the call's arguments; the content it replaced had to be read before the
|
|
30
|
+
* write happened, so it comes from {@link readDisplacedContent}. Returned as
|
|
31
|
+
* `{ details }` for pi's `tool_result` event — the same values the row read
|
|
32
|
+
* when this package re-registered the tool and wrapped its `execute`.
|
|
33
|
+
*/
|
|
34
|
+
export function writeResultDetails(
|
|
35
|
+
old: string | null,
|
|
36
|
+
input: Record<string, unknown>,
|
|
37
|
+
): { details: unknown } {
|
|
38
|
+
const fp = String((input as any).path ?? (input as any).file_path ?? "");
|
|
39
|
+
const content = typeof (input as any).content === "string" ? ((input as any).content as string) : "";
|
|
40
|
+
|
|
41
|
+
if (old !== null && old !== content) {
|
|
42
|
+
const diff = parseDiff(old, content);
|
|
43
|
+
return {
|
|
44
|
+
details: {
|
|
45
|
+
_type: "diff",
|
|
46
|
+
summary: Ansi.summarize(diff.added, diff.removed),
|
|
47
|
+
diff,
|
|
48
|
+
language: lang(fp),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (old === null) {
|
|
53
|
+
return {
|
|
54
|
+
details: {
|
|
55
|
+
_type: "new",
|
|
56
|
+
lines: content ? content.split("\n").length : 0,
|
|
57
|
+
content,
|
|
58
|
+
filePath: fp,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { details: { _type: "noChange" } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The Archimedes `write` renderers, closed over the paths they shorten against. */
|
|
66
|
+
export function createWriteRenderers(
|
|
16
67
|
cwd: string,
|
|
17
68
|
home: string,
|
|
18
|
-
createWriteTool: (cwd: string) => any,
|
|
19
69
|
TextComponent: new (text?: string, paddingX?: number, paddingY?: number) => Component,
|
|
20
|
-
):
|
|
70
|
+
): ToolRenderers {
|
|
21
71
|
const sp = (p: string) => Ansi.shortPath(cwd, home, p);
|
|
22
|
-
const origWrite = createWriteTool(cwd);
|
|
23
|
-
|
|
24
|
-
pi.registerTool({
|
|
25
|
-
...origWrite,
|
|
26
|
-
name: "write",
|
|
27
|
-
|
|
28
|
-
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
29
|
-
const fp = params.path ?? params.file_path ?? "";
|
|
30
|
-
let old: string | null = null;
|
|
31
|
-
try {
|
|
32
|
-
if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
|
|
33
|
-
} catch {
|
|
34
|
-
old = null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const result = await origWrite.execute(tid, params, sig, upd, ctx);
|
|
38
|
-
const content = params.content ?? "";
|
|
39
|
-
|
|
40
|
-
if (old !== null && old !== content) {
|
|
41
|
-
const diff = parseDiff(old, content);
|
|
42
|
-
const lg = lang(fp);
|
|
43
|
-
(result as any).details = {
|
|
44
|
-
_type: "diff",
|
|
45
|
-
summary: Ansi.summarize(diff.added, diff.removed),
|
|
46
|
-
diff,
|
|
47
|
-
language: lg,
|
|
48
|
-
};
|
|
49
|
-
} else if (old === null) {
|
|
50
|
-
const lineCount = content ? content.split("\n").length : 0;
|
|
51
|
-
(result as any).details = { _type: "new", lines: lineCount, content, filePath: fp };
|
|
52
|
-
} else if (old === content) {
|
|
53
|
-
(result as any).details = { _type: "noChange" };
|
|
54
|
-
}
|
|
55
|
-
return result;
|
|
56
|
-
},
|
|
57
72
|
|
|
73
|
+
return {
|
|
58
74
|
renderCall(args: any, theme: any, ctx: any) {
|
|
59
75
|
const fp = args?.path ?? args?.file_path ?? "";
|
|
60
76
|
const isNew = !fp || !existsSync(fp);
|
|
@@ -144,5 +160,46 @@ export function registerWriteTool(
|
|
|
144
160
|
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
|
|
145
161
|
return text;
|
|
146
162
|
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Draw `write` through pi's renderer resolution and attach the result details
|
|
168
|
+
* its row reads (ADR 0029).
|
|
169
|
+
*
|
|
170
|
+
* The content a write replaces exists only until it runs, so it is read on
|
|
171
|
+
* `tool_call` — which fires before execution and hands the same `toolCallId`
|
|
172
|
+
* `tool_result` does — and paired up when the result lands. A call that never
|
|
173
|
+
* settles (blocked, or aborted before execution) leaves its entry behind, so
|
|
174
|
+
* the map is bounded rather than trusted to drain.
|
|
175
|
+
*/
|
|
176
|
+
export function installWriteTool(
|
|
177
|
+
pi: ExtensionAPI,
|
|
178
|
+
cwd: string,
|
|
179
|
+
home: string,
|
|
180
|
+
TextComponent: new (text?: string, paddingX?: number, paddingY?: number) => Component,
|
|
181
|
+
): void {
|
|
182
|
+
const renderers = createWriteRenderers(cwd, home, TextComponent);
|
|
183
|
+
pi.registerToolRenderer((toolName, next) =>
|
|
184
|
+
toolName === "write" ? { ...next(), ...renderers } : next(),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const displaced = new Map<string, string | null>();
|
|
188
|
+
pi.on("tool_call", (event) => {
|
|
189
|
+
if (event.toolName !== "write") return undefined;
|
|
190
|
+
if (displaced.size > 64) displaced.clear();
|
|
191
|
+
displaced.set(event.toolCallId, readDisplacedContent(event.input));
|
|
192
|
+
return undefined;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("tool_result", (event) => {
|
|
196
|
+
if (event.toolName !== "write") return undefined;
|
|
197
|
+
if (event.isError) {
|
|
198
|
+
displaced.delete(event.toolCallId);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const old = displaced.get(event.toolCallId) ?? null;
|
|
202
|
+
displaced.delete(event.toolCallId);
|
|
203
|
+
return writeResultDetails(old, event.input);
|
|
147
204
|
});
|
|
148
205
|
}
|