@xynogen/pix-edit 0.2.16 → 0.2.18
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 +8 -5
- package/skills/pix-edit.md +46 -0
- package/src/edit.ts +140 -658
- package/src/extension.ts +8 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-edit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "Pi tool — precise text replacement edit with diff rendering",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
|
-
"!src/**/*.test.*",
|
|
17
16
|
"README.md",
|
|
18
|
-
"LICENSE"
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"skills"
|
|
19
19
|
],
|
|
20
20
|
"pi": {
|
|
21
21
|
"extensions": [
|
|
22
22
|
"src/extension.ts"
|
|
23
|
+
],
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
23
26
|
]
|
|
24
27
|
},
|
|
25
28
|
"keywords": [
|
|
@@ -39,8 +42,8 @@
|
|
|
39
42
|
"access": "public"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
|
-
"@xynogen/pix-pretty": "^1.
|
|
43
|
-
"@xynogen/pix-runtime": "^0.
|
|
45
|
+
"@xynogen/pix-pretty": "^2.1.0",
|
|
46
|
+
"@xynogen/pix-runtime": "^0.20.0"
|
|
44
47
|
},
|
|
45
48
|
"peerDependencies": {
|
|
46
49
|
"typebox": "*",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pix-edit
|
|
3
|
+
description: How to use `edit` without retries — hash anchors from a fresh `read`, one non-overlapping batch per file, and the exact-text fallback. Use before multi-hunk edits, after an edit failed or applied only partly, or when a file may have changed.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
# edit — hash-anchor edits
|
|
7
|
+
|
|
8
|
+
`edit` was the second most error-prone tool in history (898 errors in 11,315 calls). Nearly all came from four causes below.
|
|
9
|
+
|
|
10
|
+
## Formats
|
|
11
|
+
|
|
12
|
+
Hash anchors (preferred), from the `LINE#HASH` prefixes that `read` returns:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{ "path": "src/a.ts", "edits": [
|
|
16
|
+
{ "op": "replace", "pos": "12#A1F", "end": "14#C03", "lines": ["new line 12", "new line 13"] },
|
|
17
|
+
{ "op": "insert_after", "pos": "40#9B2", "lines": ["added"] }
|
|
18
|
+
] }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `op`: `replace`, `insert_before`, or `insert_after`. `end` is for `replace` only.
|
|
22
|
+
- `lines` holds raw source lines: no `LINE#HASH|` prefix and no newline characters. An empty array deletes.
|
|
23
|
+
|
|
24
|
+
Exact text (fallback only after a hash edit fails): `{ "oldText": "...", "newText": "..." }`. `oldText` must match exactly once.
|
|
25
|
+
|
|
26
|
+
## Avoid the four common failures
|
|
27
|
+
|
|
28
|
+
| Error in history | Count | Fix |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| "Edit without read" | 293 | `read` the region in this session before editing it. |
|
|
31
|
+
| "target not found" / stale anchor | 77+ | The file changed. Use the "Current anchors" from the last edit result, or `read` again. Never guess a hash. |
|
|
32
|
+
| "PARTIAL APPLY" | 100+ | Edits in one call must not overlap and must all target the original file. Split dependent changes into separate calls. |
|
|
33
|
+
| schema: `edits.N` must be object / additional properties / `op` | 59 | Never mix hash and exact-text items in one call. Do not add fields such as `text` or `content`. |
|
|
34
|
+
|
|
35
|
+
## Pattern
|
|
36
|
+
|
|
37
|
+
1. `read` the lines you will change (use `offset`/`limit`, or `ast` `outline` then `symbol` for code).
|
|
38
|
+
2. Put every independent change to that file in one `edit` call.
|
|
39
|
+
3. Use the "Current anchors" in the result for the next edit to the same region.
|
|
40
|
+
4. Read the "LSP after edit" note. Fix new errors before moving on.
|
|
41
|
+
|
|
42
|
+
## Do not
|
|
43
|
+
|
|
44
|
+
- Use `sed -i`, `perl -pi`, or a heredoc in `bash` to change files.
|
|
45
|
+
- Use `write` to change a few lines of an existing file. `write` is for new files and complete rewrites.
|
|
46
|
+
- Retry the same failed edit unchanged. Read the error, then fix the anchor or the format.
|
package/src/edit.ts
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
import { constants } from "node:fs";
|
|
2
|
-
import { access, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
6
|
-
import type {
|
|
7
|
-
AgentToolUpdateCallback,
|
|
8
|
-
EditToolInput,
|
|
9
|
-
ExtensionContext,
|
|
10
|
-
ToolRenderResultOptions,
|
|
11
|
-
} from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
|
|
14
1
|
import { MAX_RENDER_LINES } from "@xynogen/pix-pretty/config";
|
|
15
2
|
import type { ToolContext } from "@xynogen/pix-pretty/context";
|
|
16
3
|
import { parseDiff } from "@xynogen/pix-pretty/diff";
|
|
@@ -22,13 +9,12 @@ import {
|
|
|
22
9
|
summarize,
|
|
23
10
|
} from "@xynogen/pix-pretty/diff-render";
|
|
24
11
|
import { lang } from "@xynogen/pix-pretty/lang";
|
|
12
|
+
import { toolCallRenderer, toolCardRenderer } from "@xynogen/pix-pretty/tool-card";
|
|
25
13
|
import type {
|
|
26
14
|
EditOperation,
|
|
27
15
|
EditParams,
|
|
28
16
|
EditRenderState,
|
|
29
17
|
PiPrettyApi,
|
|
30
|
-
RenderContextLike,
|
|
31
|
-
ThemeLike,
|
|
32
18
|
ToolFactory,
|
|
33
19
|
ToolResultLike,
|
|
34
20
|
} from "@xynogen/pix-pretty/types";
|
|
@@ -36,28 +22,32 @@ import {
|
|
|
36
22
|
BODY_PAD,
|
|
37
23
|
dotJoin,
|
|
38
24
|
fillToolBackground,
|
|
39
|
-
formatToolCallTitle,
|
|
40
|
-
frameToolResult,
|
|
41
25
|
getErrorMessage,
|
|
42
|
-
getTextContent,
|
|
43
|
-
hideCollapsedToolCall,
|
|
44
26
|
isTextContent,
|
|
45
|
-
|
|
46
|
-
renderToolError,
|
|
27
|
+
renderTitle,
|
|
47
28
|
setResultDetails,
|
|
48
29
|
termW,
|
|
49
|
-
unframeToolResult,
|
|
50
30
|
} from "@xynogen/pix-pretty/utils";
|
|
51
|
-
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
52
31
|
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
type
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
import {
|
|
32
|
+
type EditInput,
|
|
33
|
+
editPath,
|
|
34
|
+
editSchema,
|
|
35
|
+
editText,
|
|
36
|
+
type HashParams,
|
|
37
|
+
prepareEditArguments,
|
|
38
|
+
} from "@xynogen/pix-runtime/files";
|
|
39
|
+
import type {
|
|
40
|
+
AgentToolUpdateCallback,
|
|
41
|
+
EditToolInput,
|
|
42
|
+
ExtensionContext,
|
|
43
|
+
} from "@xynogen/pix-runtime/host";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
editPath,
|
|
47
|
+
freshAnchors,
|
|
48
|
+
prepareEditArguments,
|
|
49
|
+
prepareHashArguments,
|
|
50
|
+
} from "@xynogen/pix-runtime/files";
|
|
61
51
|
|
|
62
52
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
63
53
|
|
|
@@ -95,447 +85,6 @@ export function getEditOperations(input: EditParams): EditOperation[] {
|
|
|
95
85
|
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
96
86
|
}
|
|
97
87
|
|
|
98
|
-
type HashEdit = {
|
|
99
|
-
op: "replace" | "insert_before" | "insert_after";
|
|
100
|
-
pos: string;
|
|
101
|
-
end?: string;
|
|
102
|
-
lines: string[];
|
|
103
|
-
};
|
|
104
|
-
type HashParams = { path: string; edits: HashEdit[] };
|
|
105
|
-
type ExactParams = { path: string; edits: EditOperation[] };
|
|
106
|
-
type EditInput = HashParams | ExactParams;
|
|
107
|
-
|
|
108
|
-
const hashFields = {
|
|
109
|
-
pos: Type.String({
|
|
110
|
-
pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
|
|
111
|
-
description: "Original LINE#HASH anchor from read",
|
|
112
|
-
}),
|
|
113
|
-
lines: Type.Array(
|
|
114
|
-
Type.String({
|
|
115
|
-
description:
|
|
116
|
-
"One literal source line per item. No newline or LINE#HASH| prefix. Empty array deletes.",
|
|
117
|
-
}),
|
|
118
|
-
),
|
|
119
|
-
};
|
|
120
|
-
const hashEdits = Type.Array(
|
|
121
|
-
Type.Union([
|
|
122
|
-
Type.Object(
|
|
123
|
-
{
|
|
124
|
-
op: Type.Literal("replace"),
|
|
125
|
-
...hashFields,
|
|
126
|
-
end: Type.Optional(
|
|
127
|
-
Type.String({
|
|
128
|
-
pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
|
|
129
|
-
description: "Inclusive end anchor. Omit for one line.",
|
|
130
|
-
}),
|
|
131
|
-
),
|
|
132
|
-
},
|
|
133
|
-
{ additionalProperties: false },
|
|
134
|
-
),
|
|
135
|
-
Type.Object(
|
|
136
|
-
{
|
|
137
|
-
op: Type.Union([Type.Literal("insert_before"), Type.Literal("insert_after")]),
|
|
138
|
-
...hashFields,
|
|
139
|
-
},
|
|
140
|
-
{ additionalProperties: false },
|
|
141
|
-
),
|
|
142
|
-
]),
|
|
143
|
-
{ minItems: 1 },
|
|
144
|
-
);
|
|
145
|
-
const exactEdits = Type.Array(
|
|
146
|
-
Type.Object(
|
|
147
|
-
{
|
|
148
|
-
oldText: Type.String({
|
|
149
|
-
minLength: 1,
|
|
150
|
-
description:
|
|
151
|
-
"Fallback only after a hash-anchor edit fails. Exact current source text; must match once.",
|
|
152
|
-
}),
|
|
153
|
-
newText: Type.String({ description: "Replacement text. Empty string deletes." }),
|
|
154
|
-
},
|
|
155
|
-
{ additionalProperties: false },
|
|
156
|
-
),
|
|
157
|
-
{ minItems: 1 },
|
|
158
|
-
);
|
|
159
|
-
const path = Type.String({ minLength: 1 });
|
|
160
|
-
const editSchema = Type.Object(
|
|
161
|
-
{
|
|
162
|
-
path,
|
|
163
|
-
// ponytail: union whole arrays so the schema rejects mixed formats before execution.
|
|
164
|
-
edits: Type.Union([hashEdits, exactEdits], {
|
|
165
|
-
description:
|
|
166
|
-
"Try hash-anchor edits first. Use exact-text edits only after a hash-anchor edit fails. Never mix formats.",
|
|
167
|
-
}),
|
|
168
|
-
},
|
|
169
|
-
{ additionalProperties: false },
|
|
170
|
-
);
|
|
171
|
-
// One branch each, so a bad call reports only the errors of the format it meant.
|
|
172
|
-
const hashSchema = Type.Object({ path, edits: hashEdits }, { additionalProperties: false });
|
|
173
|
-
const exactSchema = Type.Object({ path, edits: exactEdits }, { additionalProperties: false });
|
|
174
|
-
|
|
175
|
-
const parseJson = (value: string): unknown => {
|
|
176
|
-
try {
|
|
177
|
-
return JSON.parse(value);
|
|
178
|
-
} catch {
|
|
179
|
-
return value;
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
export function prepareHashArguments(input: unknown): unknown {
|
|
184
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
|
|
185
|
-
const args = { ...input } as Record<string, unknown>;
|
|
186
|
-
if (typeof args.edits === "string") args.edits = parseJson(args.edits);
|
|
187
|
-
if (args.edits && typeof args.edits === "object" && !Array.isArray(args.edits))
|
|
188
|
-
args.edits = [args.edits];
|
|
189
|
-
// Top recorded failure: one edit item sent as a JSON string.
|
|
190
|
-
if (Array.isArray(args.edits))
|
|
191
|
-
args.edits = args.edits.map((item) => {
|
|
192
|
-
if (typeof item !== "string") return item;
|
|
193
|
-
const parsed = parseJson(item);
|
|
194
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : item;
|
|
195
|
-
});
|
|
196
|
-
return args;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const EDIT_FIX = "Fix: resend only the broken field; keep one format per call.";
|
|
200
|
-
|
|
201
|
-
function editFix(format: "hash" | "exact", path: string, problem: string): string {
|
|
202
|
-
if (/\.(pos|end)$/.test(path) && problem.startsWith("expected to match"))
|
|
203
|
-
return "LINE#HASH from read, hash is uppercase hex (12#A3F)";
|
|
204
|
-
if (/\.op$/.test(path)) return "op is replace, insert_before or insert_after";
|
|
205
|
-
if (/\.lines$/.test(path) && problem === "missing")
|
|
206
|
-
return "missing; add lines: [] of literal source lines ([] deletes)";
|
|
207
|
-
if (/\.lines(\.\d+)?$/.test(path)) return "lines is an array of strings, one per line";
|
|
208
|
-
if (/\.oldText$/.test(path) && problem !== "missing")
|
|
209
|
-
return "oldText is non-empty exact current source text";
|
|
210
|
-
if (problem === "unknown field") {
|
|
211
|
-
if (!path.startsWith("edits.")) return "unknown field; send only path and edits";
|
|
212
|
-
return format === "hash"
|
|
213
|
-
? "unknown field; hash items take op, pos, end?, lines"
|
|
214
|
-
: "unknown field; exact items take oldText, newText";
|
|
215
|
-
}
|
|
216
|
-
if (/^edits\.\d+$/.test(path)) return `${problem}; each edit is one object`;
|
|
217
|
-
if (path === "edits") return `${problem}; edits is a non-empty array of objects`;
|
|
218
|
-
return problem;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Normalize, then check with the host validator. A bad call fails with a few
|
|
223
|
-
* lines that name the field and the fix, for the format the call meant.
|
|
224
|
-
*/
|
|
225
|
-
export function prepareEditArguments(input: unknown): unknown {
|
|
226
|
-
const args = prepareHashArguments(input);
|
|
227
|
-
const edits = (args as { edits?: unknown } | undefined)?.edits;
|
|
228
|
-
const items = Array.isArray(edits) ? edits : [];
|
|
229
|
-
const kind = (item: unknown) =>
|
|
230
|
-
item && typeof item === "object"
|
|
231
|
-
? ["op", "pos", "end", "lines"].some((k) => k in item)
|
|
232
|
-
? "hash"
|
|
233
|
-
: ["oldText", "newText"].some((k) => k in item)
|
|
234
|
-
? "exact"
|
|
235
|
-
: undefined
|
|
236
|
-
: undefined;
|
|
237
|
-
const kinds = items.map(kind);
|
|
238
|
-
const at = (k: string) => kinds.flatMap((x, i) => (x === k ? [i] : [])).join(",");
|
|
239
|
-
if (kinds.includes("hash") && kinds.includes("exact"))
|
|
240
|
-
throw new Error(
|
|
241
|
-
badArgs(
|
|
242
|
-
"edit",
|
|
243
|
-
[`- edits: mixed formats (hash at [${at("hash")}], exact at [${at("exact")}])`],
|
|
244
|
-
EDIT_FIX,
|
|
245
|
-
),
|
|
246
|
-
);
|
|
247
|
-
const format = kinds.includes("exact") ? "exact" : "hash";
|
|
248
|
-
checkArgs(
|
|
249
|
-
"edit",
|
|
250
|
-
format === "hash" ? hashSchema : exactSchema,
|
|
251
|
-
args,
|
|
252
|
-
(path, problem) => editFix(format, path, problem),
|
|
253
|
-
EDIT_FIX,
|
|
254
|
-
);
|
|
255
|
-
return args;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ponytail: pix-ask has a copy of this host-error reformatter (clip/badArgs/checkArgs). Move it
|
|
259
|
-
// to pix-runtime when a third tool needs it (public API addition, minor bump).
|
|
260
|
-
const clip = (text: string, max: number) =>
|
|
261
|
-
text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
262
|
-
|
|
263
|
-
function badArgs(tool: string, rows: string[], footer: string): string {
|
|
264
|
-
const head =
|
|
265
|
-
rows.length > 5 ? `${tool}: ${rows.length} bad arguments, first 5` : `${tool}: bad arguments`;
|
|
266
|
-
return [head, ...rows.slice(0, 5), footer].join("\n");
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** Host-validate `args`; on failure throw a short field/fix list, not the full-argument echo. */
|
|
270
|
-
function checkArgs(
|
|
271
|
-
tool: string,
|
|
272
|
-
schema: object,
|
|
273
|
-
args: unknown,
|
|
274
|
-
fix: (path: string, problem: string, value: unknown) => string,
|
|
275
|
-
footer: string,
|
|
276
|
-
): void {
|
|
277
|
-
try {
|
|
278
|
-
validateToolArguments(
|
|
279
|
-
{ name: tool, description: "", parameters: schema },
|
|
280
|
-
{ type: "toolCall", id: "", name: tool, arguments: args as never },
|
|
281
|
-
);
|
|
282
|
-
return;
|
|
283
|
-
} catch (error) {
|
|
284
|
-
// ponytail: reads pi-ai's " - path: problem" rows; an unknown format rethrows the host error.
|
|
285
|
-
const text = (error instanceof Error ? error.message : String(error)).split(
|
|
286
|
-
"\n\nReceived arguments:",
|
|
287
|
-
)[0];
|
|
288
|
-
const seen = new Set<string>();
|
|
289
|
-
const rows = [...(text ?? "").matchAll(/^ {2}- (\S+): (.+)$/gm)].map(
|
|
290
|
-
([, path = "", problem = ""]) => {
|
|
291
|
-
const value =
|
|
292
|
-
path === "root"
|
|
293
|
-
? args
|
|
294
|
-
: path
|
|
295
|
-
.split(".")
|
|
296
|
-
.reduce<unknown>(
|
|
297
|
-
(v, k) =>
|
|
298
|
-
v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined,
|
|
299
|
-
args,
|
|
300
|
-
);
|
|
301
|
-
const shown =
|
|
302
|
-
value === undefined ? "" : ` ${clip(JSON.stringify(value) ?? String(value), 40)}`;
|
|
303
|
-
// State a repeated fix once, so five rows stay a few hundred bytes.
|
|
304
|
-
const advice = fix(path, problem, value);
|
|
305
|
-
const said = seen.has(advice);
|
|
306
|
-
seen.add(advice);
|
|
307
|
-
return `- ${path.replace(/\.(\d+)/g, "[$1]")}${shown}: ${said ? "same fix" : advice}`;
|
|
308
|
-
},
|
|
309
|
-
);
|
|
310
|
-
if (!rows.length) throw error;
|
|
311
|
-
throw new Error(badArgs(tool, rows, footer));
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function editPath(path: string, cwd: string): string {
|
|
316
|
-
let value = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ").replace(/^@/, "");
|
|
317
|
-
if (
|
|
318
|
-
process.platform === "win32" &&
|
|
319
|
-
value.startsWith("/") &&
|
|
320
|
-
!value.startsWith("//") &&
|
|
321
|
-
!value.includes("\\")
|
|
322
|
-
) {
|
|
323
|
-
value = value.replace(
|
|
324
|
-
/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i,
|
|
325
|
-
(_all, drive: string, tail: string | undefined) =>
|
|
326
|
-
`${drive.toUpperCase()}:\\${(tail ?? "").replaceAll("/", "\\")}`,
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
value = expandHome(value);
|
|
330
|
-
if (value.startsWith("file://")) value = fileURLToPath(value);
|
|
331
|
-
return resolve(cwd, value);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
335
|
-
const args = prepareHashArguments(input) as HashParams;
|
|
336
|
-
if (
|
|
337
|
-
!args ||
|
|
338
|
-
typeof args.path !== "string" ||
|
|
339
|
-
!args.path ||
|
|
340
|
-
!Array.isArray(args.edits) ||
|
|
341
|
-
!args.edits.length
|
|
342
|
-
)
|
|
343
|
-
throw new Error("Invalid hashline edit input");
|
|
344
|
-
if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
|
|
345
|
-
throw new Error("Legacy exact-text fields are not supported");
|
|
346
|
-
const source = decodeLines(bytes);
|
|
347
|
-
// Stale/out-of-bounds anchors carry the current lines around the target so the
|
|
348
|
-
// model can retry at once instead of spending a read round-trip.
|
|
349
|
-
const nearby = (line: number) => {
|
|
350
|
-
const from = Math.max(1, Math.min(line, source.lines.length) - 2);
|
|
351
|
-
const rows = source.lines.slice(from - 1, from + 4).map((l, i) => {
|
|
352
|
-
const text = l.text.length > 200 ? `${l.text.slice(0, 200)}…` : l.text;
|
|
353
|
-
return `${anchor(from + i, l.text)}|${text}`;
|
|
354
|
-
});
|
|
355
|
-
return rows.length ? `\nCurrent lines:\n${rows.join("\n")}` : "";
|
|
356
|
-
};
|
|
357
|
-
const check = (value: unknown) => {
|
|
358
|
-
const parsed = parseAnchor(value);
|
|
359
|
-
const line = source.lines[parsed.line - 1];
|
|
360
|
-
if (!line)
|
|
361
|
-
throw new Error(
|
|
362
|
-
`Anchor out of bounds: ${String(value)} (file has ${source.lines.length} lines).${nearby(parsed.line)}`,
|
|
363
|
-
);
|
|
364
|
-
if (hashLine(line.text) !== parsed.hash)
|
|
365
|
-
throw new Error(
|
|
366
|
-
`Stale anchor: ${String(value)}. Use the current anchors below, or read again if the target moved.${nearby(parsed.line)}`,
|
|
367
|
-
);
|
|
368
|
-
return parsed.line;
|
|
369
|
-
};
|
|
370
|
-
const replacements: Array<{ start: number; end: number; lines: string[] }> = [];
|
|
371
|
-
const gaps = new Map<number, string[]>();
|
|
372
|
-
for (const edit of args.edits) {
|
|
373
|
-
if (
|
|
374
|
-
!edit ||
|
|
375
|
-
typeof edit !== "object" ||
|
|
376
|
-
Object.keys(edit).some((key) => !["op", "pos", "end", "lines"].includes(key))
|
|
377
|
-
)
|
|
378
|
-
throw new Error("Legacy or invalid edit fields");
|
|
379
|
-
if (
|
|
380
|
-
!["replace", "insert_before", "insert_after"].includes(edit.op) ||
|
|
381
|
-
!Array.isArray(edit.lines) ||
|
|
382
|
-
edit.lines.some(
|
|
383
|
-
(line) =>
|
|
384
|
-
typeof line !== "string" ||
|
|
385
|
-
/[\r\n\0]|^[1-9]\d*#[0-9A-F]{3}\|/.test(line) ||
|
|
386
|
-
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(line),
|
|
387
|
-
)
|
|
388
|
-
)
|
|
389
|
-
throw new Error("Invalid edit operation or source lines");
|
|
390
|
-
const start = check(edit.pos);
|
|
391
|
-
if (edit.op === "replace") {
|
|
392
|
-
const end = edit.end === undefined ? start : check(edit.end);
|
|
393
|
-
if (end < start) throw new Error("Reversed replacement range");
|
|
394
|
-
replacements.push({ start, end, lines: edit.lines });
|
|
395
|
-
} else {
|
|
396
|
-
if (edit.end !== undefined) throw new Error("end is only allowed for replace");
|
|
397
|
-
const gap = edit.op === "insert_before" ? start - 1 : start;
|
|
398
|
-
gaps.set(gap, [...(gaps.get(gap) ?? []), ...edit.lines]);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
replacements.sort((a, b) => a.start - b.start);
|
|
402
|
-
for (let i = 0; i < replacements.length; i++) {
|
|
403
|
-
const range = replacements[i]!;
|
|
404
|
-
if (i && replacements[i - 1]!.end >= range.start)
|
|
405
|
-
throw new Error("Overlapping replacement ranges");
|
|
406
|
-
for (const gap of gaps.keys())
|
|
407
|
-
if (gap >= range.start && gap < range.end)
|
|
408
|
-
throw new Error("Insertion overlaps a replacement range");
|
|
409
|
-
}
|
|
410
|
-
const output: SourceLine[] = [];
|
|
411
|
-
// 1-based output line spans each op wrote, for the fresh anchors in the result.
|
|
412
|
-
const spans: Array<[number, number]> = [];
|
|
413
|
-
const ops: Array<EditOperation & { editLine: number }> = [];
|
|
414
|
-
const append = (lines: string[]) =>
|
|
415
|
-
output.push(...lines.map((text) => ({ text, ending: source.delimiter })));
|
|
416
|
-
let rangeIndex = 0;
|
|
417
|
-
for (let gap = 0; gap <= source.lines.length; ) {
|
|
418
|
-
const insertion = gaps.get(gap);
|
|
419
|
-
if (insertion?.length) {
|
|
420
|
-
const context = source.lines[Math.max(0, gap - 1)]!.text;
|
|
421
|
-
ops.push({
|
|
422
|
-
oldText: context,
|
|
423
|
-
newText:
|
|
424
|
-
gap === 0 ? [...insertion, context].join("\n") : [context, ...insertion].join("\n"),
|
|
425
|
-
editLine: Math.max(1, output.length + (gap === 0 ? 1 : 0)),
|
|
426
|
-
});
|
|
427
|
-
spans.push([output.length + 1, output.length + insertion.length]);
|
|
428
|
-
append(insertion);
|
|
429
|
-
}
|
|
430
|
-
if (gap === source.lines.length) break;
|
|
431
|
-
const range = replacements[rangeIndex];
|
|
432
|
-
if (range?.start === gap + 1) {
|
|
433
|
-
ops.push({
|
|
434
|
-
oldText: source.lines
|
|
435
|
-
.slice(gap, range.end)
|
|
436
|
-
.map((line) => line.text)
|
|
437
|
-
.join("\n"),
|
|
438
|
-
newText: range.lines.join("\n"),
|
|
439
|
-
editLine: output.length + 1,
|
|
440
|
-
});
|
|
441
|
-
// A deletion has no new lines; mark the line that now sits in its place.
|
|
442
|
-
spans.push([output.length + 1, output.length + Math.max(1, range.lines.length)]);
|
|
443
|
-
append(range.lines);
|
|
444
|
-
gap = range.end;
|
|
445
|
-
rangeIndex++;
|
|
446
|
-
} else {
|
|
447
|
-
output.push({ ...source.lines[gap]! });
|
|
448
|
-
gap++;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
for (let i = 0; i < output.length; i++) {
|
|
452
|
-
if (i === output.length - 1)
|
|
453
|
-
output[i]!.ending = source.finalNewline ? output[i]!.ending || source.delimiter : "";
|
|
454
|
-
else if (!output[i]!.ending) output[i]!.ending = source.delimiter;
|
|
455
|
-
}
|
|
456
|
-
return {
|
|
457
|
-
content: source.bom + output.map((line) => line.text + line.ending).join(""),
|
|
458
|
-
ops,
|
|
459
|
-
spans,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function applyExactEdits(bytes: Buffer, args: ExactParams) {
|
|
464
|
-
if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
|
|
465
|
-
throw new Error("Invalid exact-text edit fields");
|
|
466
|
-
const source = decodeLines(bytes);
|
|
467
|
-
const original = source.lines.map((line) => line.text + line.ending).join("");
|
|
468
|
-
// ponytail: exact bytes only. Add newline normalization only if callers need it.
|
|
469
|
-
const ranges = args.edits
|
|
470
|
-
.map((edit) => {
|
|
471
|
-
if (
|
|
472
|
-
!edit ||
|
|
473
|
-
typeof edit !== "object" ||
|
|
474
|
-
Object.keys(edit).some((key) => !["oldText", "newText"].includes(key)) ||
|
|
475
|
-
typeof edit.oldText !== "string" ||
|
|
476
|
-
!edit.oldText ||
|
|
477
|
-
typeof edit.newText !== "string" ||
|
|
478
|
-
[edit.oldText, edit.newText].some((text) =>
|
|
479
|
-
/\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text),
|
|
480
|
-
)
|
|
481
|
-
)
|
|
482
|
-
throw new Error("Invalid exact-text edit");
|
|
483
|
-
const start = original.indexOf(edit.oldText);
|
|
484
|
-
if (start < 0) throw new Error("oldText was not found. Use exact source text.");
|
|
485
|
-
if (original.indexOf(edit.oldText, start + 1) >= 0)
|
|
486
|
-
throw new Error("oldText matches more than once. Include more context.");
|
|
487
|
-
return { ...edit, start, end: start + edit.oldText.length };
|
|
488
|
-
})
|
|
489
|
-
.sort((a, b) => a.start - b.start);
|
|
490
|
-
let content = "";
|
|
491
|
-
let offset = 0;
|
|
492
|
-
const ops: Array<EditOperation & { editLine: number }> = [];
|
|
493
|
-
const spans: Array<[number, number]> = [];
|
|
494
|
-
const lineCount = (text: string) => text.match(/\r\n|\r|\n/g)?.length ?? 0;
|
|
495
|
-
for (const range of ranges) {
|
|
496
|
-
if (range.start < offset) throw new Error("Overlapping exact-text edits");
|
|
497
|
-
content += original.slice(offset, range.start);
|
|
498
|
-
const editLine = lineCount(content) + 1;
|
|
499
|
-
ops.push({ oldText: range.oldText, newText: range.newText, editLine });
|
|
500
|
-
spans.push([editLine, editLine + lineCount(range.newText)]);
|
|
501
|
-
content += range.newText;
|
|
502
|
-
offset = range.end;
|
|
503
|
-
}
|
|
504
|
-
return { content: source.bom + content + original.slice(offset), ops, spans };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** Context lines around each changed span, and the cap on anchored lines per result. */
|
|
508
|
-
const FRESH_CONTEXT = 2;
|
|
509
|
-
const FRESH_MAX_LINES = 20;
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Current `LINE#HASH|text` rows around the changed spans, so a chained edit on
|
|
513
|
-
* the same file needs no `read` between calls (SWE-agent shows the edited
|
|
514
|
-
* window after every edit). Gaps between spans are marked with `…`.
|
|
515
|
-
*/
|
|
516
|
-
export function freshAnchors(content: string, spans: Array<[number, number]>): string {
|
|
517
|
-
const lines = decodeLines(Buffer.from(content, "utf8")).lines;
|
|
518
|
-
const wanted = new Set<number>();
|
|
519
|
-
for (const [from, to] of spans)
|
|
520
|
-
for (
|
|
521
|
-
let n = Math.max(1, from - FRESH_CONTEXT);
|
|
522
|
-
n <= Math.min(lines.length, to + FRESH_CONTEXT);
|
|
523
|
-
n++
|
|
524
|
-
)
|
|
525
|
-
wanted.add(n);
|
|
526
|
-
const picked = [...wanted].sort((a, b) => a - b);
|
|
527
|
-
const shown = picked.slice(0, FRESH_MAX_LINES);
|
|
528
|
-
const rows: string[] = [];
|
|
529
|
-
for (const [i, n] of shown.entries()) {
|
|
530
|
-
if (i && n !== (shown[i - 1] ?? 0) + 1) rows.push("…");
|
|
531
|
-
const text = lines[n - 1]?.text ?? "";
|
|
532
|
-
rows.push(`${anchor(n, text)}|${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
|
|
533
|
-
}
|
|
534
|
-
if (picked.length > shown.length)
|
|
535
|
-
rows.push(`… ${picked.length - shown.length} more changed lines; read for anchors`);
|
|
536
|
-
return rows.join("\n");
|
|
537
|
-
}
|
|
538
|
-
|
|
539
88
|
export function summarizeEditOperations(operations: EditOperation[]) {
|
|
540
89
|
const diffs = operations.map((e) => parseDiff(e.oldText, e.newText));
|
|
541
90
|
const totalAdded = diffs.reduce((sum, d) => sum + d.added, 0);
|
|
@@ -567,7 +116,7 @@ export function registerEditTool(
|
|
|
567
116
|
parameters: editSchema,
|
|
568
117
|
promptSnippet: "Edit with hash anchors first; exact text only after failure",
|
|
569
118
|
promptGuidelines: [
|
|
570
|
-
|
|
119
|
+
'For edit, try hash anchors from read first. Only if the hash-anchor edit fails, fall back to oldText/newText. If the file changed, read the affected region again before using current source text; never guess anchors or overwrite unseen changes. After a failed or partial edit, load read_skills(name="pix-edit", full=true).',
|
|
571
120
|
],
|
|
572
121
|
prepareArguments: prepareEditArguments,
|
|
573
122
|
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
|
@@ -588,40 +137,18 @@ export function registerEditTool(
|
|
|
588
137
|
try {
|
|
589
138
|
if (!fp) throw new Error("path must be a nonempty string");
|
|
590
139
|
const absolutePath = editPath(fp, toolCtx?.cwd || cwd);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const applied = args.edits.every(
|
|
604
|
-
(edit) => edit && typeof edit === "object" && "oldText" in edit,
|
|
605
|
-
)
|
|
606
|
-
? applyExactEdits(bytes, args as ExactParams)
|
|
607
|
-
: applyHashEdits(bytes, args);
|
|
608
|
-
operations = applied.ops;
|
|
609
|
-
abort();
|
|
610
|
-
// ponytail: in-place writes preserve links and mode, not crash atomicity. Use a separate atomic-write design if needed.
|
|
611
|
-
await writeFile(absolutePath, applied.content, "utf8");
|
|
612
|
-
abort();
|
|
613
|
-
const fresh = freshAnchors(applied.content, applied.spans);
|
|
614
|
-
const text = `Edited ${fp}: ${operations.length} operations.`;
|
|
615
|
-
return {
|
|
616
|
-
content: [
|
|
617
|
-
{
|
|
618
|
-
type: "text",
|
|
619
|
-
text: fresh ? `${text}\nCurrent anchors:\n${fresh}` : text,
|
|
620
|
-
},
|
|
621
|
-
],
|
|
622
|
-
details: undefined,
|
|
623
|
-
};
|
|
624
|
-
});
|
|
140
|
+
const applied = await editText(absolutePath, params, undefined, sig);
|
|
141
|
+
operations = applied.ops;
|
|
142
|
+
const text = `Edited ${fp}: ${operations.length} operations.`;
|
|
143
|
+
result = {
|
|
144
|
+
content: [
|
|
145
|
+
{
|
|
146
|
+
type: "text",
|
|
147
|
+
text: applied.fresh ? `${text}\nCurrent anchors:\n${applied.fresh}` : text,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
details: undefined,
|
|
151
|
+
};
|
|
625
152
|
} catch (error) {
|
|
626
153
|
const text = getErrorMessage(error);
|
|
627
154
|
if (sig?.aborted || /aborted/i.test(text)) throw error;
|
|
@@ -675,71 +202,39 @@ export function registerEditTool(
|
|
|
675
202
|
return result;
|
|
676
203
|
},
|
|
677
204
|
|
|
678
|
-
renderCall(
|
|
679
|
-
|
|
680
|
-
theme
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const fp = args?.path ?? args?.file_path ?? "";
|
|
685
|
-
const operations = getEditOperations(args);
|
|
686
|
-
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
687
|
-
if (
|
|
688
|
-
hideCollapsedToolCall(renderCtx.state as CollapseState, renderCtx.expanded, (value) =>
|
|
689
|
-
text.setText(value),
|
|
690
|
-
)
|
|
691
|
-
)
|
|
692
|
-
return text;
|
|
693
|
-
const hdr = `${formatToolCallTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
|
|
205
|
+
renderCall: toolCallRenderer<EditParams & Partial<HashParams>, EditRenderState>(
|
|
206
|
+
TextComponent,
|
|
207
|
+
(args, theme, renderCtx) => {
|
|
208
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
209
|
+
const operations = getEditOperations(args);
|
|
210
|
+
const hdr = `${renderTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
|
|
694
211
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
212
|
+
if (Array.isArray(args.edits) && args.edits.some((edit) => "pos" in edit))
|
|
213
|
+
return fillToolBackground(
|
|
214
|
+
dotJoin([hdr, theme.fg("muted", `${args.edits.length} operations`)]),
|
|
215
|
+
);
|
|
216
|
+
if (operations.length === 0) return fillToolBackground(hdr);
|
|
217
|
+
|
|
218
|
+
const { summary } = summarizeEditOperations(operations);
|
|
219
|
+
const coloredSummary = renderDiffSummary(summary, theme);
|
|
220
|
+
const paint = (s: string) => theme.fg("muted", s);
|
|
221
|
+
const suffix = dotJoin(
|
|
222
|
+
[
|
|
223
|
+
hdr,
|
|
224
|
+
operations.length > 1 && theme.fg("muted", `${operations.length} edits`),
|
|
225
|
+
theme.fg("muted", "estimate"),
|
|
226
|
+
coloredSummary,
|
|
227
|
+
],
|
|
228
|
+
paint,
|
|
698
229
|
);
|
|
699
|
-
return
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
text.setText(fillToolBackground(hdr));
|
|
703
|
-
return text;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
const { summary } = summarizeEditOperations(operations);
|
|
707
|
-
const coloredSummary = renderDiffSummary(summary, theme);
|
|
708
|
-
const paint = (s: string) => theme.fg("muted", s);
|
|
709
|
-
const suffix = dotJoin(
|
|
710
|
-
[
|
|
711
|
-
hdr,
|
|
712
|
-
operations.length > 1 && theme.fg("muted", `${operations.length} edits`),
|
|
713
|
-
theme.fg("muted", "estimate"),
|
|
714
|
-
coloredSummary,
|
|
715
|
-
],
|
|
716
|
-
paint,
|
|
717
|
-
);
|
|
718
|
-
text.setText(fillToolBackground(suffix));
|
|
719
|
-
return text;
|
|
720
|
-
},
|
|
721
|
-
|
|
722
|
-
renderResult(
|
|
723
|
-
result: ToolResultLike,
|
|
724
|
-
_opt: ToolRenderResultOptions,
|
|
725
|
-
theme: ThemeLike,
|
|
726
|
-
renderCtx: RenderContextLike<EditRenderState>,
|
|
727
|
-
) {
|
|
728
|
-
resolveBaseBackground(theme);
|
|
729
|
-
const text = unframeToolResult(renderCtx.lastComponent ?? new TextComponent("", 0, 0));
|
|
730
|
-
const d = result.details as Record<string, unknown> | undefined;
|
|
731
|
-
const isPartial = _opt?.isPartial === true;
|
|
732
|
-
const completed = () => frameToolResult(text, theme, renderCtx.isError);
|
|
733
|
-
const structuredError =
|
|
734
|
-
renderCtx.isError && (d?._type === "editInfo" || d?._type === "multiEditInfo");
|
|
735
|
-
if (renderCtx.isError && (!structuredError || isPartial)) {
|
|
736
|
-
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
737
|
-
return isPartial ? text : completed();
|
|
738
|
-
}
|
|
230
|
+
return fillToolBackground(suffix);
|
|
231
|
+
},
|
|
232
|
+
),
|
|
739
233
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
234
|
+
renderResult: toolCardRenderer<Record<string, unknown>, EditRenderState>(TextComponent, {
|
|
235
|
+
name: "edit",
|
|
236
|
+
owns: (d) => d?._type === "editInfo" || d?._type === "multiEditInfo",
|
|
237
|
+
collapsed(d) {
|
|
743
238
|
const summary =
|
|
744
239
|
d?._type === "editInfo"
|
|
745
240
|
? (d.summary as string)
|
|
@@ -752,98 +247,85 @@ export function registerEditTool(
|
|
|
752
247
|
const ops = d.ops as Array<Record<string, unknown>> | undefined;
|
|
753
248
|
filePath = String(ops?.[0]?.filePath ?? "");
|
|
754
249
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
renderSplit(diff, d.language as string | undefined, MAX_RENDER_LINES, dc)
|
|
788
|
-
.then((rendered) => {
|
|
789
|
-
if (renderCtx.state._edk !== key) return;
|
|
790
|
-
renderCtx.state._edt = rendered;
|
|
791
|
-
renderCtx.invalidate();
|
|
792
|
-
})
|
|
793
|
-
.catch(() => {
|
|
794
|
-
if (renderCtx.state._edk !== key) return;
|
|
795
|
-
renderCtx.state._edt = `${BODY_PAD}${d.summary}`;
|
|
796
|
-
renderCtx.invalidate();
|
|
797
|
-
});
|
|
250
|
+
return { target: sp(filePath), summary };
|
|
251
|
+
},
|
|
252
|
+
body({ d, result, text, theme, ctx: renderCtx }) {
|
|
253
|
+
// Single edit — full split diff
|
|
254
|
+
if (d?._type === "editInfo") {
|
|
255
|
+
const key = `ed:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${(d.oldContent as string).length}:${(d.newContent as string).length}:${d.language ?? ""}`;
|
|
256
|
+
if (renderCtx.toolCallId) trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
|
|
257
|
+
if (renderCtx.state._edk !== key) {
|
|
258
|
+
renderCtx.state._edk = key;
|
|
259
|
+
// ponytail: call already shows `edit <file> <summary>`; don't repeat summary+loc header — gutter carries absolute line
|
|
260
|
+
renderCtx.state._edt = `${BODY_PAD}${theme.fg("muted", "rendering diff…")}`;
|
|
261
|
+
const dc = resolveDiffColors(theme);
|
|
262
|
+
const diff = parseDiff(
|
|
263
|
+
d.oldContent as string,
|
|
264
|
+
d.newContent as string,
|
|
265
|
+
3,
|
|
266
|
+
d.editLine as number,
|
|
267
|
+
);
|
|
268
|
+
renderSplit(diff, d.language as string | undefined, MAX_RENDER_LINES, dc)
|
|
269
|
+
.then((rendered) => {
|
|
270
|
+
if (renderCtx.state._edk !== key) return;
|
|
271
|
+
renderCtx.state._edt = rendered;
|
|
272
|
+
renderCtx.invalidate();
|
|
273
|
+
})
|
|
274
|
+
.catch(() => {
|
|
275
|
+
if (renderCtx.state._edk !== key) return;
|
|
276
|
+
renderCtx.state._edt = `${BODY_PAD}${d.summary}`;
|
|
277
|
+
renderCtx.invalidate();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.summary}`);
|
|
281
|
+
return undefined;
|
|
798
282
|
}
|
|
799
|
-
text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.summary}`);
|
|
800
|
-
return isPartial ? text : completed();
|
|
801
|
-
}
|
|
802
283
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
284
|
+
// Multi-edit — stacked diffs
|
|
285
|
+
if (d?._type === "multiEditInfo") {
|
|
286
|
+
const key = `med:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.editCount}:${d.diffLineCount}`;
|
|
287
|
+
if (renderCtx.toolCallId) trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
|
|
288
|
+
if (renderCtx.state._edk !== key) {
|
|
289
|
+
renderCtx.state._edk = key;
|
|
290
|
+
// ponytail: call already shows summary; render diffs directly
|
|
291
|
+
renderCtx.state._edt = `${BODY_PAD}${theme.fg("muted", "rendering diff…")}`;
|
|
292
|
+
const dc = resolveDiffColors(theme);
|
|
293
|
+
Promise.all(
|
|
294
|
+
(
|
|
295
|
+
d.ops as Array<{
|
|
296
|
+
oldContent: string;
|
|
297
|
+
newContent: string;
|
|
298
|
+
language?: string;
|
|
299
|
+
editLine?: number;
|
|
300
|
+
}>
|
|
301
|
+
).map((op) => {
|
|
302
|
+
const diff = parseDiff(op.oldContent, op.newContent, 3, op.editLine ?? 0);
|
|
303
|
+
return renderSplit(diff, op.language, MAX_RENDER_LINES, dc);
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
.then((rendered) => {
|
|
307
|
+
if (renderCtx.state._edk !== key) return;
|
|
308
|
+
const body = rendered.join(`\n${`${BODY_PAD}${theme.fg("muted", "···")}`}\n`);
|
|
309
|
+
renderCtx.state._edt = body;
|
|
310
|
+
renderCtx.invalidate();
|
|
311
|
+
})
|
|
312
|
+
.catch(() => {
|
|
313
|
+
if (renderCtx.state._edk !== key) return;
|
|
314
|
+
renderCtx.state._edt = `${BODY_PAD}${d.editCount} edits ${d.summary}`;
|
|
315
|
+
renderCtx.invalidate();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.editCount} edits ${d.summary}`);
|
|
319
|
+
return undefined;
|
|
836
320
|
}
|
|
837
|
-
text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.editCount} edits ${d.summary}`);
|
|
838
|
-
return isPartial ? text : completed();
|
|
839
|
-
}
|
|
840
321
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
322
|
+
const fallback = result.content?.[0];
|
|
323
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "edited";
|
|
324
|
+
text.setText(
|
|
325
|
+
fillToolBackground(`${BODY_PAD}${theme.fg("dim", String(fallbackText).slice(0, 120))}`),
|
|
326
|
+
);
|
|
327
|
+
return undefined;
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
848
330
|
});
|
|
849
331
|
}
|
package/src/extension.ts
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEditToolDefinition,
|
|
3
|
-
createEditTool as createEditToolFallback,
|
|
4
|
-
type EditToolInput,
|
|
5
|
-
type ExtensionAPI,
|
|
6
|
-
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
8
1
|
import { CursorStore, fffState } from "@xynogen/pix-pretty/fff";
|
|
9
2
|
import { attachResizeListener, trackInvalidator } from "@xynogen/pix-pretty/resize";
|
|
10
|
-
import
|
|
3
|
+
import { Text } from "@xynogen/pix-pretty/tui";
|
|
11
4
|
import { linkedShortPath, viewportTextConstructor } from "@xynogen/pix-pretty/utils";
|
|
12
5
|
import { initHashline } from "@xynogen/pix-runtime/hashline";
|
|
6
|
+
import {
|
|
7
|
+
createEditToolDefinition,
|
|
8
|
+
createEditTool as createEditToolFallback,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
} from "@xynogen/pix-runtime/host";
|
|
13
11
|
import { once } from "@xynogen/pix-runtime/once";
|
|
14
12
|
import { homeDir } from "@xynogen/pix-runtime/paths";
|
|
15
13
|
import { registerEditTool } from "./edit.ts";
|
|
16
14
|
|
|
17
15
|
export default async function pixEditExtension(pi: ExtensionAPI): Promise<void> {
|
|
18
16
|
await initHashline();
|
|
19
|
-
const prettyPi = pi as unknown as PiPrettyApi;
|
|
20
17
|
once(pi, "pix-edit", () => {
|
|
21
|
-
const createEditTool =
|
|
22
|
-
createEditToolFallback) as unknown as ToolFactory<EditToolInput>;
|
|
18
|
+
const createEditTool = createEditToolDefinition ?? createEditToolFallback;
|
|
23
19
|
if (!createEditTool) return;
|
|
24
20
|
|
|
25
21
|
const cwd = process.cwd();
|
|
@@ -28,7 +24,7 @@ export default async function pixEditExtension(pi: ExtensionAPI): Promise<void>
|
|
|
28
24
|
attachResizeListener();
|
|
29
25
|
|
|
30
26
|
registerEditTool(
|
|
31
|
-
|
|
27
|
+
pi,
|
|
32
28
|
createEditTool,
|
|
33
29
|
{
|
|
34
30
|
cwd,
|