@xynogen/pix-edit 0.1.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 +43 -0
- package/src/edit.test.ts +63 -0
- package/src/edit.ts +291 -0
- package/src/extension.ts +50 -0
- package/src/index.ts +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-edit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi tool — precise text replacement edit with diff rendering",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"src/extension.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"edit"
|
|
25
|
+
],
|
|
26
|
+
"author": "xynogen",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
31
|
+
"directory": "packages/pix-edit"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@xynogen/pix-pretty": "*"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
41
|
+
"@earendil-works/pi-tui": "*"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/edit.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
getEditOperations,
|
|
4
|
+
registerEditTool,
|
|
5
|
+
summarizeEditOperations,
|
|
6
|
+
} from "./edit";
|
|
7
|
+
|
|
8
|
+
class MockTextComponent {
|
|
9
|
+
private text = "";
|
|
10
|
+
setText(v: string) {
|
|
11
|
+
this.text = v;
|
|
12
|
+
}
|
|
13
|
+
getText() {
|
|
14
|
+
return this.text;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("registerEditTool", () => {
|
|
19
|
+
it("registers a tool named 'edit'", () => {
|
|
20
|
+
const tools: string[] = [];
|
|
21
|
+
registerEditTool(
|
|
22
|
+
{ registerTool: (t: any) => tools.push(t.name) } as any,
|
|
23
|
+
() => ({ execute: async () => ({ content: [] }) }) as any,
|
|
24
|
+
{
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
sp: (p: string) => p,
|
|
27
|
+
TextComponent: MockTextComponent as any,
|
|
28
|
+
fffState: {} as any,
|
|
29
|
+
cursorStore: {} as any,
|
|
30
|
+
},
|
|
31
|
+
(_id: string, _inv: () => void) => {},
|
|
32
|
+
);
|
|
33
|
+
expect(tools).toEqual(["edit"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("getEditOperations", () => {
|
|
38
|
+
it("extracts array edits", () => {
|
|
39
|
+
const ops = getEditOperations({
|
|
40
|
+
path: "f.ts",
|
|
41
|
+
edits: [{ oldText: "a", newText: "b" }],
|
|
42
|
+
} as any);
|
|
43
|
+
expect(ops).toEqual([{ oldText: "a", newText: "b" }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("filters ops where old === new", () => {
|
|
47
|
+
const ops = getEditOperations({
|
|
48
|
+
path: "f.ts",
|
|
49
|
+
edits: [{ oldText: "x", newText: "x" }],
|
|
50
|
+
} as any);
|
|
51
|
+
expect(ops).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("summarizeEditOperations", () => {
|
|
56
|
+
it("returns a summary string", () => {
|
|
57
|
+
const { summary } = summarizeEditOperations([
|
|
58
|
+
{ oldText: "a\nb", newText: "c\nd\ne" },
|
|
59
|
+
]);
|
|
60
|
+
expect(typeof summary).toBe("string");
|
|
61
|
+
expect(summary.length).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/edit.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import type {
|
|
3
|
+
AgentToolUpdateCallback,
|
|
4
|
+
EditToolInput,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
ToolRenderResultOptions,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import { MAX_RENDER_LINES } from "@xynogen/pix-pretty/config";
|
|
10
|
+
import type { ToolContext } from "@xynogen/pix-pretty/context";
|
|
11
|
+
import { parseDiff } from "@xynogen/pix-pretty/diff";
|
|
12
|
+
import {
|
|
13
|
+
diffThemeCacheKey,
|
|
14
|
+
renderSplit,
|
|
15
|
+
resolveDiffColors,
|
|
16
|
+
summarize,
|
|
17
|
+
} from "@xynogen/pix-pretty/diff-render";
|
|
18
|
+
import { lang } from "@xynogen/pix-pretty/lang";
|
|
19
|
+
import type {
|
|
20
|
+
EditOperation,
|
|
21
|
+
EditParams,
|
|
22
|
+
EditRenderState,
|
|
23
|
+
PiPrettyApi,
|
|
24
|
+
RenderContextLike,
|
|
25
|
+
ThemeLike,
|
|
26
|
+
ToolFactory,
|
|
27
|
+
ToolResultLike,
|
|
28
|
+
} from "@xynogen/pix-pretty/types";
|
|
29
|
+
import {
|
|
30
|
+
fillToolBackground,
|
|
31
|
+
getTextContent,
|
|
32
|
+
isTextContent,
|
|
33
|
+
renderToolError,
|
|
34
|
+
setResultDetails,
|
|
35
|
+
termW,
|
|
36
|
+
} from "@xynogen/pix-pretty/utils";
|
|
37
|
+
|
|
38
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function getEditOperations(input: EditParams): EditOperation[] {
|
|
41
|
+
if (Array.isArray(input?.edits)) {
|
|
42
|
+
return input.edits
|
|
43
|
+
.map((e) => ({
|
|
44
|
+
oldText:
|
|
45
|
+
typeof e?.oldText === "string"
|
|
46
|
+
? e.oldText
|
|
47
|
+
: typeof e?.old_text === "string"
|
|
48
|
+
? e.old_text
|
|
49
|
+
: "",
|
|
50
|
+
newText:
|
|
51
|
+
typeof e?.newText === "string"
|
|
52
|
+
? e.newText
|
|
53
|
+
: typeof e?.new_text === "string"
|
|
54
|
+
? e.new_text
|
|
55
|
+
: "",
|
|
56
|
+
}))
|
|
57
|
+
.filter((e) => e.oldText && e.oldText !== e.newText);
|
|
58
|
+
}
|
|
59
|
+
const oldText =
|
|
60
|
+
typeof input?.oldText === "string"
|
|
61
|
+
? input.oldText
|
|
62
|
+
: typeof input?.old_text === "string"
|
|
63
|
+
? input.old_text
|
|
64
|
+
: "";
|
|
65
|
+
const newText =
|
|
66
|
+
typeof input?.newText === "string"
|
|
67
|
+
? input.newText
|
|
68
|
+
: typeof input?.new_text === "string"
|
|
69
|
+
? input.new_text
|
|
70
|
+
: "";
|
|
71
|
+
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function summarizeEditOperations(operations: EditOperation[]) {
|
|
75
|
+
const diffs = operations.map((e) => parseDiff(e.oldText, e.newText));
|
|
76
|
+
const totalAdded = diffs.reduce((sum, d) => sum + d.added, 0);
|
|
77
|
+
const totalRemoved = diffs.reduce((sum, d) => sum + d.removed, 0);
|
|
78
|
+
return {
|
|
79
|
+
diffs,
|
|
80
|
+
totalAdded,
|
|
81
|
+
totalRemoved,
|
|
82
|
+
summary: summarize(totalAdded, totalRemoved),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Tool ───────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export function registerEditTool(
|
|
89
|
+
pi: PiPrettyApi,
|
|
90
|
+
createEditTool: ToolFactory<EditToolInput>,
|
|
91
|
+
ctx: ToolContext,
|
|
92
|
+
trackInvalidator: (id: string, inv: () => void) => void,
|
|
93
|
+
): void {
|
|
94
|
+
const { cwd, sp, TextComponent } = ctx;
|
|
95
|
+
const origEdit = createEditTool(cwd);
|
|
96
|
+
|
|
97
|
+
pi.registerTool({
|
|
98
|
+
...origEdit,
|
|
99
|
+
name: "edit",
|
|
100
|
+
|
|
101
|
+
async execute(
|
|
102
|
+
tid: string,
|
|
103
|
+
params: EditParams,
|
|
104
|
+
sig: AbortSignal | undefined,
|
|
105
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
106
|
+
toolCtx: ExtensionContext,
|
|
107
|
+
) {
|
|
108
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
109
|
+
const operations = getEditOperations(params);
|
|
110
|
+
const fileLang = lang(fp);
|
|
111
|
+
|
|
112
|
+
const result = (await origEdit.execute(
|
|
113
|
+
tid,
|
|
114
|
+
params as unknown as Parameters<typeof origEdit.execute>[1],
|
|
115
|
+
sig,
|
|
116
|
+
upd,
|
|
117
|
+
toolCtx,
|
|
118
|
+
)) as ToolResultLike;
|
|
119
|
+
|
|
120
|
+
if (operations.length === 0) return result;
|
|
121
|
+
|
|
122
|
+
const { diffs, summary } = summarizeEditOperations(operations);
|
|
123
|
+
|
|
124
|
+
if (operations.length === 1) {
|
|
125
|
+
let editLine = 0;
|
|
126
|
+
try {
|
|
127
|
+
if (fp && existsSync(fp)) {
|
|
128
|
+
const f = readFileSync(fp, "utf-8");
|
|
129
|
+
const idx = f.indexOf(operations[0].newText);
|
|
130
|
+
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
editLine = 0;
|
|
134
|
+
}
|
|
135
|
+
setResultDetails(result, {
|
|
136
|
+
_type: "editInfo",
|
|
137
|
+
summary,
|
|
138
|
+
editLine,
|
|
139
|
+
oldContent: operations[0].oldText,
|
|
140
|
+
newContent: operations[0].newText,
|
|
141
|
+
language: fileLang,
|
|
142
|
+
filePath: fp,
|
|
143
|
+
});
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setResultDetails(result, {
|
|
148
|
+
_type: "multiEditInfo",
|
|
149
|
+
summary,
|
|
150
|
+
editCount: operations.length,
|
|
151
|
+
diffLineCount: diffs.reduce((sum, d) => sum + d.lines.length, 0),
|
|
152
|
+
ops: operations.map((op) => ({
|
|
153
|
+
oldContent: op.oldText,
|
|
154
|
+
newContent: op.newText,
|
|
155
|
+
language: fileLang,
|
|
156
|
+
filePath: fp,
|
|
157
|
+
})),
|
|
158
|
+
});
|
|
159
|
+
return result;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
renderCall(
|
|
163
|
+
args: EditParams,
|
|
164
|
+
theme: ThemeLike,
|
|
165
|
+
renderCtx: RenderContextLike<EditRenderState>,
|
|
166
|
+
) {
|
|
167
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
168
|
+
const operations = getEditOperations(args);
|
|
169
|
+
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
170
|
+
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
171
|
+
|
|
172
|
+
if (operations.length === 0) {
|
|
173
|
+
text.setText(fillToolBackground(hdr));
|
|
174
|
+
return text;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { summary } = summarizeEditOperations(operations);
|
|
178
|
+
const suffix =
|
|
179
|
+
operations.length === 1
|
|
180
|
+
? summary
|
|
181
|
+
: `${operations.length} edits ${summary}`;
|
|
182
|
+
text.setText(fillToolBackground(`${hdr} ${theme.fg("muted", suffix)}`));
|
|
183
|
+
return text;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
renderResult(
|
|
187
|
+
result: ToolResultLike,
|
|
188
|
+
_opt: ToolRenderResultOptions,
|
|
189
|
+
theme: ThemeLike,
|
|
190
|
+
renderCtx: RenderContextLike<EditRenderState>,
|
|
191
|
+
) {
|
|
192
|
+
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
193
|
+
if (renderCtx.isError) {
|
|
194
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
195
|
+
return text;
|
|
196
|
+
}
|
|
197
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
198
|
+
|
|
199
|
+
// Single edit — full split diff
|
|
200
|
+
if (d?._type === "editInfo") {
|
|
201
|
+
const key = `ed:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${(d.oldContent as string).length}:${(d.newContent as string).length}:${d.language ?? ""}`;
|
|
202
|
+
if (renderCtx.toolCallId)
|
|
203
|
+
trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
|
|
204
|
+
if (renderCtx.state._edk !== key) {
|
|
205
|
+
renderCtx.state._edk = key;
|
|
206
|
+
const loc =
|
|
207
|
+
(d.editLine as number) > 0
|
|
208
|
+
? ` ${theme.fg("muted", `at line ${d.editLine}`)}`
|
|
209
|
+
: "";
|
|
210
|
+
renderCtx.state._edt = ` ${d.summary}${loc}\n${theme.fg("muted", " rendering diff…")}`;
|
|
211
|
+
const dc = resolveDiffColors(theme);
|
|
212
|
+
const diff = parseDiff(
|
|
213
|
+
d.oldContent as string,
|
|
214
|
+
d.newContent as string,
|
|
215
|
+
);
|
|
216
|
+
renderSplit(
|
|
217
|
+
diff,
|
|
218
|
+
d.language as string | undefined,
|
|
219
|
+
MAX_RENDER_LINES,
|
|
220
|
+
dc,
|
|
221
|
+
)
|
|
222
|
+
.then((rendered) => {
|
|
223
|
+
if (renderCtx.state._edk !== key) return;
|
|
224
|
+
const loc2 =
|
|
225
|
+
(d.editLine as number) > 0
|
|
226
|
+
? ` ${theme.fg("muted", `at line ${d.editLine}`)}`
|
|
227
|
+
: "";
|
|
228
|
+
renderCtx.state._edt = ` ${d.summary}${loc2}\n${rendered}`;
|
|
229
|
+
renderCtx.invalidate();
|
|
230
|
+
})
|
|
231
|
+
.catch(() => {
|
|
232
|
+
if (renderCtx.state._edk !== key) return;
|
|
233
|
+
renderCtx.state._edt = ` ${d.summary}`;
|
|
234
|
+
renderCtx.invalidate();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
text.setText(renderCtx.state._edt ?? ` ${d.summary}`);
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Multi-edit — stacked diffs
|
|
242
|
+
if (d?._type === "multiEditInfo") {
|
|
243
|
+
const key = `med:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.editCount}:${d.diffLineCount}`;
|
|
244
|
+
if (renderCtx.toolCallId)
|
|
245
|
+
trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
|
|
246
|
+
if (renderCtx.state._edk !== key) {
|
|
247
|
+
renderCtx.state._edk = key;
|
|
248
|
+
renderCtx.state._edt = ` ${d.editCount} edits ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
249
|
+
const dc = resolveDiffColors(theme);
|
|
250
|
+
Promise.all(
|
|
251
|
+
(
|
|
252
|
+
d.ops as Array<{
|
|
253
|
+
oldContent: string;
|
|
254
|
+
newContent: string;
|
|
255
|
+
language?: string;
|
|
256
|
+
}>
|
|
257
|
+
).map((op) => {
|
|
258
|
+
const diff = parseDiff(op.oldContent, op.newContent);
|
|
259
|
+
return renderSplit(diff, op.language, MAX_RENDER_LINES, dc);
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
.then((rendered) => {
|
|
263
|
+
if (renderCtx.state._edk !== key) return;
|
|
264
|
+
const body = rendered.join(`\n${theme.fg("muted", " ···")}\n`);
|
|
265
|
+
renderCtx.state._edt = ` ${d.editCount} edits ${d.summary}\n${body}`;
|
|
266
|
+
renderCtx.invalidate();
|
|
267
|
+
})
|
|
268
|
+
.catch(() => {
|
|
269
|
+
if (renderCtx.state._edk !== key) return;
|
|
270
|
+
renderCtx.state._edt = ` ${d.editCount} edits ${d.summary}`;
|
|
271
|
+
renderCtx.invalidate();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
text.setText(
|
|
275
|
+
renderCtx.state._edt ?? ` ${d.editCount} edits ${d.summary}`,
|
|
276
|
+
);
|
|
277
|
+
return text;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const fallback = result.content?.[0];
|
|
281
|
+
const fallbackText =
|
|
282
|
+
fallback && isTextContent(fallback) ? fallback.text : "edited";
|
|
283
|
+
text.setText(
|
|
284
|
+
fillToolBackground(
|
|
285
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
return text;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CursorStore, fffState } from "@xynogen/pix-pretty/fff";
|
|
2
|
+
import {
|
|
3
|
+
attachResizeListener,
|
|
4
|
+
trackInvalidator,
|
|
5
|
+
} from "@xynogen/pix-pretty/resize";
|
|
6
|
+
import type {
|
|
7
|
+
PiPrettyApi,
|
|
8
|
+
PiPrettySdk,
|
|
9
|
+
TextComponentCtor,
|
|
10
|
+
} from "@xynogen/pix-pretty/types";
|
|
11
|
+
import { shortPath } from "@xynogen/pix-pretty/utils";
|
|
12
|
+
|
|
13
|
+
import { registerEditTool } from "./edit.js";
|
|
14
|
+
|
|
15
|
+
export default function pixEditExtension(pi: PiPrettyApi): void {
|
|
16
|
+
let sdk: PiPrettySdk;
|
|
17
|
+
try {
|
|
18
|
+
sdk = require("@earendil-works/pi-coding-agent");
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
|
|
24
|
+
if (!createEditTool) return;
|
|
25
|
+
|
|
26
|
+
let TextComponent: TextComponentCtor;
|
|
27
|
+
try {
|
|
28
|
+
TextComponent = require("@earendil-works/pi-tui").Text;
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cwd = process.cwd();
|
|
34
|
+
const home = process.env.HOME ?? "";
|
|
35
|
+
|
|
36
|
+
attachResizeListener();
|
|
37
|
+
|
|
38
|
+
registerEditTool(
|
|
39
|
+
pi,
|
|
40
|
+
createEditTool,
|
|
41
|
+
{
|
|
42
|
+
cwd,
|
|
43
|
+
sp: (p: string) => shortPath(cwd, home, p),
|
|
44
|
+
TextComponent,
|
|
45
|
+
fffState,
|
|
46
|
+
cursorStore: new CursorStore(),
|
|
47
|
+
},
|
|
48
|
+
trackInvalidator,
|
|
49
|
+
);
|
|
50
|
+
}
|