pi-edit-file 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/package.json +42 -4
- package/src/core.ts +1522 -0
- package/src/extension.ts +544 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit_file — block-based file editing tool for pi.
|
|
3
|
+
*
|
|
4
|
+
* One flat patch string instead of target_edit's 7-variant anyOf ops schema
|
|
5
|
+
* (models kept mixing quick_edit/target_edit schemas → 8.2% validation errors,
|
|
6
|
+
* fixed at the source by this tool).
|
|
7
|
+
*
|
|
8
|
+
* See src/core.ts for the parser/resolver/applier and
|
|
9
|
+
* block-edit-plan.md for rationale. Name decided: edit_file.
|
|
10
|
+
*
|
|
11
|
+
* Also withdraws pi's built-in `edit` tool (exact text replacement), so edit_file
|
|
12
|
+
* is the only edit path, and overrides the built-in `write` tool: overwriting an
|
|
13
|
+
* existing file renders the same diff (per-line syntax highlighting, word-level
|
|
14
|
+
* emphasis, line backgrounds) instead of the content preview.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import {
|
|
19
|
+
createWriteToolDefinition,
|
|
20
|
+
getLanguageFromPath,
|
|
21
|
+
highlightCode,
|
|
22
|
+
withFileMutationQueue,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
25
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { isAbsolute, resolve } from "node:path";
|
|
27
|
+
import {
|
|
28
|
+
MAX_FILE_BYTES,
|
|
29
|
+
EditError,
|
|
30
|
+
unifiedDiff,
|
|
31
|
+
type Hunk,
|
|
32
|
+
chainSkeleton,
|
|
33
|
+
formatHunkReports,
|
|
34
|
+
hunkSummary,
|
|
35
|
+
isNoOpHunk,
|
|
36
|
+
applyHunks,
|
|
37
|
+
parsePatch,
|
|
38
|
+
resolveHunks,
|
|
39
|
+
sequentialDiffs,
|
|
40
|
+
truncateDiff,
|
|
41
|
+
parseUnifiedDiff,
|
|
42
|
+
patchDelimiter,
|
|
43
|
+
reportCaveats,
|
|
44
|
+
insertTip,
|
|
45
|
+
appliedLine,
|
|
46
|
+
} from "./core.ts";
|
|
47
|
+
|
|
48
|
+
const TOOL_NAME = "edit_file";
|
|
49
|
+
|
|
50
|
+
/** pi's built-in exact-text edit tool; withdrawn by editFileExtension(). */
|
|
51
|
+
const BUILTIN_EDIT_TOOL = "edit";
|
|
52
|
+
|
|
53
|
+
const DESCRIPTION = `Edit a file with one or more block patches in a single 'patch' string.
|
|
54
|
+
|
|
55
|
+
Three shapes — pick the shortest one that expresses the edit:
|
|
56
|
+
|
|
57
|
+
1) replace, anchored (NNN is a 1-based line hint, not strict):
|
|
58
|
+
42 @@@
|
|
59
|
+
const timeout = 1000
|
|
60
|
+
@@@
|
|
61
|
+
const timeout = 5000
|
|
62
|
+
@@@
|
|
63
|
+
|
|
64
|
+
2) replace by content only — omit the number and the block must occur exactly once in the file:
|
|
65
|
+
@@@
|
|
66
|
+
const timeout = 1000
|
|
67
|
+
@@@
|
|
68
|
+
const timeout = 5000
|
|
69
|
+
@@@
|
|
70
|
+
|
|
71
|
+
3) insert lines with an empty old block: "NNN @@@" inserts BEFORE line NNN, "NNN+ @@@" inserts AFTER it:
|
|
72
|
+
120+ @@@
|
|
73
|
+
@@@
|
|
74
|
+
export const maxRetries = 3
|
|
75
|
+
@@@
|
|
76
|
+
|
|
77
|
+
- Replace: NNN is where the old block starts in the file — a hint, not a strict address; the block itself is found by content (see Matching below). It may be omitted entirely, and then the block must occur exactly once.
|
|
78
|
+
- The patch is a JSON string: one patch line is one file line. Never type "\\n" yourself — write real line breaks. Backticks, \${...} and quotes need no escaping.
|
|
79
|
+
- @@@: delimiter, 3+ repetitions of one char from @ # % $ ~ ^ = +. The EXACT run from the first header is the call's delimiter on every line; a content line that equals it splits the block — escalate to a longer run (####) for the whole call (then "@@@" stays content).
|
|
80
|
+
- Empty old block → insert, empty new block → delete, otherwise → replace. "NNN+ @@@" is inserts only; a replace anchors its matched block itself. A hunk whose old block equals its new block is a no-op: in a batch it is reported as SKIPPED, and a patch made only of no-ops is rejected with nothing written.
|
|
81
|
+
|
|
82
|
+
Matching (per hunk, against the ORIGINAL file content): exact block match, then trimmed lines, then whitespace-collapsed lines, each time across the WHOLE file. The block must match exactly once — several matches make the patch fail with the candidate lines listed, and no hint chooses between them; extend the block with surrounding lines instead. NNN is only where you expect the block: the distance to the matched RANGE (0 when the hint falls inside the block) is reported as "hint N off by K". Every hunk of one call is matched against that same original text: the out-numbers of an earlier hunk of the same call are NOT valid hints for a later one, they are for a follow-up call. All hunks must match; overlapping hunks are rejected; the whole edit is atomic — on any failure the batch is rejected as a whole, nothing is written, and the report states the fate of every hunk.
|
|
83
|
+
|
|
84
|
+
The report opens with "applied — N of M hunk(s), file written" on success and "batch rejected — 0 of M hunk(s) applied, nothing was written to the file" on failure, then one line per hunk, e.g. "hunk 1: replace src 69-72 → out 69-73 (4 → 5 lines), exact match, hint 20 off by 49": src = line numbers in the original file, out = in the resulting file. A hint that was off gets a "note:" line above the per-hunk reports, stating that the block was found by content and is unique; "indentation: …" warns that the inserted lines use a different indent style than the file. The last line may also point out a shorter form ("NNN+ @@@") when an insert was written as a replace.
|
|
85
|
+
|
|
86
|
+
A hunk whose old block exactly equals its new block (no-op) is skipped with a note; the rest of the batch applies.
|
|
87
|
+
|
|
88
|
+
Concatenate multiple hunks in one patch. Example — change line 42, then insert after line 120:
|
|
89
|
+
|
|
90
|
+
42 @@@
|
|
91
|
+
const timeout = 1000
|
|
92
|
+
@@@
|
|
93
|
+
const timeout = 5000
|
|
94
|
+
@@@
|
|
95
|
+
120+ @@@
|
|
96
|
+
@@@
|
|
97
|
+
export const maxRetries = 3
|
|
98
|
+
@@@
|
|
99
|
+
|
|
100
|
+
Returns a per-hunk summary (line ranges, old→new line counts) and a unified diff.`;
|
|
101
|
+
|
|
102
|
+
const PARAMETERS = Type.Object({
|
|
103
|
+
path: Type.String({ description: "Path to the file (relative to cwd or absolute)" }),
|
|
104
|
+
patch: Type.String({ description: "One or more edit blocks (see tool description for the format)" }),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
interface ReadResult {
|
|
108
|
+
raw: string;
|
|
109
|
+
crlf: boolean;
|
|
110
|
+
finalNewline: boolean;
|
|
111
|
+
lines: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readLines(absPath: string): ReadResult {
|
|
115
|
+
const raw = readFileSync(absPath, "utf8");
|
|
116
|
+
const crlf = raw.includes("\r\n");
|
|
117
|
+
const finalNewline = raw.endsWith("\n");
|
|
118
|
+
let body = raw;
|
|
119
|
+
if (finalNewline) body = body.slice(0, -1); // strips "\n"; a trailing "\r" is removed per-line below
|
|
120
|
+
const lines = body.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
121
|
+
return { raw, crlf, finalNewline, lines };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function serializeLines(lines: string[], crlf: boolean, finalNewline: boolean): string {
|
|
125
|
+
const sep = crlf ? "\r\n" : "\n";
|
|
126
|
+
let out = lines.join(sep);
|
|
127
|
+
if (finalNewline && out.length > 0) out += sep;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default function editFileExtension(pi: { registerTool: (t: unknown) => void }) {
|
|
132
|
+
pi.registerTool({
|
|
133
|
+
name: TOOL_NAME,
|
|
134
|
+
label: "Edit File",
|
|
135
|
+
description: DESCRIPTION,
|
|
136
|
+
parameters: PARAMETERS,
|
|
137
|
+
|
|
138
|
+
async execute(_toolCallId: string, params: { path: string; patch: string }, _signal: unknown, _onUpdate: unknown, ctx: { cwd?: string }) {
|
|
139
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
140
|
+
const absPath = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
141
|
+
|
|
142
|
+
let st;
|
|
143
|
+
try {
|
|
144
|
+
st = statSync(absPath);
|
|
145
|
+
} catch {
|
|
146
|
+
throw new EditError(`file not found: ${params.path}`);
|
|
147
|
+
}
|
|
148
|
+
if (!st.isFile()) throw new EditError(`not a regular file: ${params.path}`);
|
|
149
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
150
|
+
throw new EditError(`file too large: ${(st.size / 1024 / 1024).toFixed(1)}MB (limit ~5MB) — use bash for bulk edits`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let parsed: Hunk[];
|
|
154
|
+
try {
|
|
155
|
+
parsed = parsePatch(params.patch);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof EditError) {
|
|
158
|
+
// Grammar failures get a ready-to-paste skeleton built from the
|
|
159
|
+
// model's own blocks: a patch that opens with content (the chain
|
|
160
|
+
// form) and the two "delimiter missing" cases. The unified-diff
|
|
161
|
+
// and delimiter-mismatch messages already carry their own
|
|
162
|
+
// corrected snippet, so they are left alone.
|
|
163
|
+
//
|
|
164
|
+
// A skeleton needs at least one full-line delimiter to split the
|
|
165
|
+
// blocks on. Without one (only "NNN @@@" headers, no standalone
|
|
166
|
+
// delimiter line) its fallback text is the generic "no delimiter
|
|
167
|
+
// found" — then the precise diagnosis alone is more useful.
|
|
168
|
+
const grammarError = err.code === "content-start" || err.code === "missing-separator" || err.code === "unterminated";
|
|
169
|
+
if (grammarError && (err.code === "content-start" || patchDelimiter(params.patch) !== null)) {
|
|
170
|
+
const skeleton = chainSkeleton(params.patch, readLines(absPath).lines);
|
|
171
|
+
const diagnosis = err.code === "content-start" ? "" : `${err.message}\n\n`;
|
|
172
|
+
throw new EditError(`patch rejected — nothing was written to the file.\n${diagnosis}${skeleton}`);
|
|
173
|
+
}
|
|
174
|
+
throw new EditError(`patch rejected — nothing was written to the file.\n${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// No-op hunks (before == after, exact) carry no change at all. Inside a
|
|
180
|
+
// batch each is skipped with a loud note; a patch made ONLY of them is
|
|
181
|
+
// rejected — such a patch means the author copied the wrong lines, and
|
|
182
|
+
// that signal must never come back as a success (Nik, 2026-10-02).
|
|
183
|
+
const active: Array<{ hunk: Hunk; origIndex: number }> = [];
|
|
184
|
+
const noOpNotes: string[] = [];
|
|
185
|
+
parsed.forEach((h, i) => {
|
|
186
|
+
if (isNoOpHunk(h)) noOpNotes.push(`hunk ${i + 1}: SKIPPED — no-op (the old block equals the new block); this hunk changes nothing`);
|
|
187
|
+
else active.push({ hunk: h, origIndex: i });
|
|
188
|
+
});
|
|
189
|
+
if (active.length === 0) {
|
|
190
|
+
throw new EditError(
|
|
191
|
+
`nothing applied — every hunk of this patch is a no-op (the old block equals the new block), so the file was NOT written.\n` +
|
|
192
|
+
`${noOpNotes.join("\n")}\n` +
|
|
193
|
+
`Check that the old block is the text you mean to replace, and that the new block differs from it.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const totalHunks = parsed.length;
|
|
197
|
+
parsed = active.map((x) => x.hunk);
|
|
198
|
+
const hunkNumbers = active.map((x) => x.origIndex);
|
|
199
|
+
|
|
200
|
+
const result = await withFileMutationQueue(absPath, async () => {
|
|
201
|
+
const file = readLines(absPath);
|
|
202
|
+
const originalCount = file.lines.length;
|
|
203
|
+
|
|
204
|
+
const resolved = resolveHunks(parsed, file.lines);
|
|
205
|
+
const updated = applyHunks(file.lines, resolved);
|
|
206
|
+
|
|
207
|
+
writeFileSync(absPath, serializeLines(updated, file.crlf, file.finalNewline), "utf8");
|
|
208
|
+
|
|
209
|
+
const reports = resolved.map((r, i) => hunkSummary(r, i));
|
|
210
|
+
// Caveats and the shorter-form tip go ABOVE the per-hunk lines: stale
|
|
211
|
+
// anchors, batch numbering and an insert written as a replace are the
|
|
212
|
+
// parts a model must not skim past.
|
|
213
|
+
const tip = insertTip(resolved, hunkNumbers, patchDelimiter(params.patch) ?? "@@@");
|
|
214
|
+
const caveats = [...reportCaveats(resolved, hunkNumbers), ...(tip ? [tip] : [])];
|
|
215
|
+
// src = line numbers in the original file, out = in the result file;
|
|
216
|
+
// hunk numbers stay the patch's original numbering across no-op skips
|
|
217
|
+
const reportLines = [...caveats, ...formatHunkReports(resolved, hunkNumbers)];
|
|
218
|
+
const summary =
|
|
219
|
+
`${[appliedLine(resolved.length, totalHunks), ...reportLines, ...noOpNotes].join("\n")}\n` +
|
|
220
|
+
`src = original file, out = resulting file\n` +
|
|
221
|
+
`follow-up edits: use the out-numbers from this report as hints — they match the file as it is now`;
|
|
222
|
+
|
|
223
|
+
const diffText = sequentialDiffs(file.lines, resolved)
|
|
224
|
+
.filter(Boolean)
|
|
225
|
+
.join("\n");
|
|
226
|
+
const { text: diff, truncated } = truncateDiff(diffText);
|
|
227
|
+
|
|
228
|
+
// No raw diff in model-facing content: models started mimicking the
|
|
229
|
+
// unified-diff format when writing patches (see pi-hermes-memory
|
|
230
|
+
// session 2026-09-25). The UI renders details.diff; the model gets
|
|
231
|
+
// the per-hunk summary only.
|
|
232
|
+
const text = `${summary}\nfile: ${params.path} — now ${updated.length} lines (was ${originalCount})`;
|
|
233
|
+
return { text, reports, reportLines, totalLines: updated.length, truncated, diff };
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: result.text }],
|
|
238
|
+
details: {
|
|
239
|
+
path: absPath,
|
|
240
|
+
hunks: result.reports,
|
|
241
|
+
reportLines: result.reportLines,
|
|
242
|
+
totalLines: result.totalLines,
|
|
243
|
+
diff: result.diff,
|
|
244
|
+
diffTruncated: result.truncated,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
renderCall(args: { path?: string }, theme: any) {
|
|
250
|
+
let text = theme.fg("toolTitle", theme.bold("edit_file "));
|
|
251
|
+
text += theme.fg("accent", args.path ?? "");
|
|
252
|
+
return new Text(text, 0, 0);
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
renderResult(
|
|
256
|
+
result: any,
|
|
257
|
+
{ expanded, isPartial }: { expanded: boolean; isPartial: boolean },
|
|
258
|
+
theme: any,
|
|
259
|
+
context?: { isError?: boolean },
|
|
260
|
+
) {
|
|
261
|
+
if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0);
|
|
262
|
+
|
|
263
|
+
// A rejected patch carries its whole diagnosis in the error text and has
|
|
264
|
+
// no details, so the summary below would render as "+0 / -0 · 0 hunks"
|
|
265
|
+
// and swallow the reason (jup-degen session, 2026-10-07 — the model's
|
|
266
|
+
// workaround was visible, the cause was not).
|
|
267
|
+
if (context?.isError || result?.isError) {
|
|
268
|
+
const message = (result?.content ?? [])
|
|
269
|
+
.filter((c: any) => c?.type === "text")
|
|
270
|
+
.map((c: any) => c.text ?? "")
|
|
271
|
+
.join("\n");
|
|
272
|
+
return new Text(theme.fg("error", message || "edit_file failed"), 0, 0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const details = result?.details as
|
|
276
|
+
| { path?: string; hunks?: unknown[]; reportLines?: string[]; totalLines?: number; diff?: string; diffTruncated?: boolean }
|
|
277
|
+
| undefined;
|
|
278
|
+
const diff = details?.diff ?? "";
|
|
279
|
+
const diffLines = diff ? diff.split("\n") : [];
|
|
280
|
+
let additions = 0;
|
|
281
|
+
let removals = 0;
|
|
282
|
+
for (const line of diffLines) {
|
|
283
|
+
if (line.startsWith("+")) additions++;
|
|
284
|
+
if (line.startsWith("-")) removals++;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const hunkCount = details?.hunks?.length ?? 0;
|
|
288
|
+
let text = theme.fg("success", `+${additions}`);
|
|
289
|
+
text += theme.fg("dim", " / ");
|
|
290
|
+
text += theme.fg("error", `-${removals}`);
|
|
291
|
+
text += theme.fg("dim", ` · ${hunkCount} hunk${hunkCount === 1 ? "" : "s"}`);
|
|
292
|
+
if (details?.diffTruncated) text += theme.fg("warning", " [truncated]");
|
|
293
|
+
|
|
294
|
+
// Diff развёрнут по умолчанию (решение Ника); expanded от UI игнорируем.
|
|
295
|
+
// The per-hunk report lines (caveats, src/out mapping, shorter-form tip)
|
|
296
|
+
// are deliberately NOT drawn: they are written for the model, and in the
|
|
297
|
+
// transcript they are noise next to the diff (Nik, 2026-10-01 — "только
|
|
298
|
+
// сам дифф"). details.reportLines keeps them for exports and debugging.
|
|
299
|
+
if (diffLines.length > 0) {
|
|
300
|
+
text += `\n${renderDiffBody(diff, details?.path, theme)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return new Text(text, 0, 0);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// pi's built-in `edit` tool must not compete with edit_file, so it is
|
|
308
|
+
// withdrawn: re-registering a name with exposure "hidden" is pi's way to
|
|
309
|
+
// unregister it (the tool registry has no unregister). Without this the model
|
|
310
|
+
// mixes `edit` and `edit_file` within a single session.
|
|
311
|
+
//
|
|
312
|
+
// registerTool() only validates that parameters is an object schema, so the
|
|
313
|
+
// hidden stub needs no name/description/execute of its own.
|
|
314
|
+
pi.registerTool({
|
|
315
|
+
name: BUILTIN_EDIT_TOOL,
|
|
316
|
+
exposure: "hidden",
|
|
317
|
+
parameters: Type.Object({}),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Plugin-provided quick_edit / target_edit are deliberately left alone
|
|
321
|
+
// (Nik's decision, 2026-09-26): they stay as a fallback edit path.
|
|
322
|
+
registerWriteDiff(pi);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const WRITE_TOOL_NAME = "write";
|
|
326
|
+
|
|
327
|
+
/** Old text of a file worth diffing: regular, text, within the size cap. */
|
|
328
|
+
function readDiffableText(absPath: string): string | undefined {
|
|
329
|
+
try {
|
|
330
|
+
const st = statSync(absPath);
|
|
331
|
+
if (!st.isFile() || st.size > MAX_FILE_BYTES) return undefined;
|
|
332
|
+
const raw = readFileSync(absPath, "utf8");
|
|
333
|
+
return raw.includes("\0") ? undefined : raw;
|
|
334
|
+
} catch {
|
|
335
|
+
return undefined; // new file — nothing to diff against
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Wrap the built-in `write` tool so overwriting an existing file shows a diff.
|
|
341
|
+
*
|
|
342
|
+
* Pi's write returns only "Successfully wrote to <path>"; the old content is
|
|
343
|
+
* captured before execution and a unified diff is attached as `details.diff`,
|
|
344
|
+
* which renderResult draws in the same style as edit_file. The model-facing
|
|
345
|
+
* content stays the original one-liner on purpose: models that see raw diffs in
|
|
346
|
+
* tool output start imitating the format in their edits.
|
|
347
|
+
*/
|
|
348
|
+
function registerWriteDiff(pi: { registerTool: (t: unknown) => void }) {
|
|
349
|
+
// createWriteTool() wraps the definition and DROPS renderCall/renderResult, so
|
|
350
|
+
// the built-in content preview would be lost. createWriteToolDefinition() is the
|
|
351
|
+
// unwrapped one and keeps them (…writeRenderers), which is what we delegate to.
|
|
352
|
+
const schemaSource = createWriteToolDefinition(process.cwd());
|
|
353
|
+
|
|
354
|
+
pi.registerTool({
|
|
355
|
+
...schemaSource,
|
|
356
|
+
name: WRITE_TOOL_NAME,
|
|
357
|
+
|
|
358
|
+
async execute(
|
|
359
|
+
toolCallId: string,
|
|
360
|
+
params: { path: string; content: string },
|
|
361
|
+
signal: unknown,
|
|
362
|
+
onUpdate: unknown,
|
|
363
|
+
ctx: { cwd?: string },
|
|
364
|
+
) {
|
|
365
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
366
|
+
const absPath = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
367
|
+
const before = readDiffableText(absPath);
|
|
368
|
+
|
|
369
|
+
// Fresh instance so relative paths resolve against this session's cwd.
|
|
370
|
+
const result = await createWriteToolDefinition(cwd).execute(toolCallId, params, signal, onUpdate, ctx);
|
|
371
|
+
if (before === undefined || before === params.content) return result;
|
|
372
|
+
|
|
373
|
+
const diffText = unifiedDiff(before, params.content, params.path);
|
|
374
|
+
if (!diffText) return result;
|
|
375
|
+
const { text: diff, truncated } = truncateDiff(diffText);
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
...(result as Record<string, unknown>),
|
|
379
|
+
details: { path: absPath, diff, diffTruncated: truncated },
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
// Delegate the call rendering to pi's built-in renderer: it already shows
|
|
384
|
+
// the content preview (first 10 lines, syntax-highlighted) plus
|
|
385
|
+
// "... (N more lines, M total, …)". Only the diff case is ours.
|
|
386
|
+
renderCall(args: unknown, theme: any, context: any) {
|
|
387
|
+
const original = (schemaSource as { renderCall?: (a: unknown, t: unknown, c: unknown) => unknown }).renderCall;
|
|
388
|
+
const component = typeof original === "function"
|
|
389
|
+
? original(args, theme, context)
|
|
390
|
+
: new Text(
|
|
391
|
+
`${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", (args as { path?: string } | undefined)?.path ?? "")}`,
|
|
392
|
+
0,
|
|
393
|
+
0,
|
|
394
|
+
);
|
|
395
|
+
// Stash it: an overwrite replaces this call region (the content preview)
|
|
396
|
+
// with the diff, so the unchanged first lines are not repeated below.
|
|
397
|
+
if (context?.state) (context.state as { callComponent?: unknown }).callComponent = component;
|
|
398
|
+
return component;
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: any) {
|
|
402
|
+
const details = result?.details as { diff?: string; diffTruncated?: boolean } | undefined;
|
|
403
|
+
const diff = details?.diff ?? "";
|
|
404
|
+
if (!diff) {
|
|
405
|
+
// New file or no-op: keep pi's own result rendering (it clears the
|
|
406
|
+
// call component on success, so the preview stays as-is).
|
|
407
|
+
const original = (schemaSource as { renderResult?: (...a: any[]) => unknown }).renderResult;
|
|
408
|
+
if (typeof original === "function") return original(result, options, theme, context);
|
|
409
|
+
const text = (result?.content ?? [])
|
|
410
|
+
.filter((c: any) => c?.type === "text")
|
|
411
|
+
.map((c: any) => c.text ?? "")
|
|
412
|
+
.join("\n");
|
|
413
|
+
return new Text(theme.fg("dim", text || "written"), 0, 0);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const lines = diff.split("\n");
|
|
417
|
+
let additions = 0;
|
|
418
|
+
let removals = 0;
|
|
419
|
+
for (const line of lines) {
|
|
420
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
421
|
+
if (line.startsWith("+")) additions++;
|
|
422
|
+
if (line.startsWith("-")) removals++;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let text = theme.fg("success", `+${additions}`);
|
|
426
|
+
text += theme.fg("dim", " / ");
|
|
427
|
+
text += theme.fg("error", `-${removals}`);
|
|
428
|
+
text += theme.fg("dim", " · overwrite");
|
|
429
|
+
if (details?.diffTruncated) text += theme.fg("warning", " [truncated]");
|
|
430
|
+
|
|
431
|
+
text += `\n${renderDiffBody(diff, details?.path, theme)}`;
|
|
432
|
+
|
|
433
|
+
// Overwrite: show header + summary + diff INSTEAD of the content preview
|
|
434
|
+
// (pi renders the call region separately, so returning a new component
|
|
435
|
+
// would leave the preview above the diff).
|
|
436
|
+
const state = context?.state as { callComponent?: { setText?: (t: string) => void } } | undefined;
|
|
437
|
+
const callComponent = state?.callComponent;
|
|
438
|
+
if (callComponent && typeof callComponent.setText === "function" && context?.args) {
|
|
439
|
+
const path = (context.args as { path?: string }).path ?? "";
|
|
440
|
+
const header = `${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", path)}`;
|
|
441
|
+
callComponent.setText(`${header}\n${text}`);
|
|
442
|
+
const empty = context.lastComponent ?? new Text("", 0, 0);
|
|
443
|
+
if (typeof (empty as { clear?: () => void }).clear === "function") (empty as { clear: () => void }).clear();
|
|
444
|
+
return empty;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return new Text(text, 0, 0);
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Diff line backgrounds: flat pastel tints, no theme blending.
|
|
454
|
+
*
|
|
455
|
+
* Removed lines get #ffd7d7, added lines #d7ffd7 — picked for the light
|
|
456
|
+
* "light-fix" theme. The earlier implementation derived the tint from the
|
|
457
|
+
* theme's own diff colors (blended toward white on light themes, black on dark
|
|
458
|
+
* ones, with a PI_DIFF_BG_DIM knob); that is gone on purpose, so the diff looks
|
|
459
|
+
* the same in every session and nothing depends on the theme's palette.
|
|
460
|
+
*/
|
|
461
|
+
const DIFF_BG_REMOVED = "\x1B[48;2;255;215;215m"; // #ffd7d7
|
|
462
|
+
const DIFF_BG_ADDED = "\x1B[48;2;215;255;215m"; // #d7ffd7
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Render a unified diff body with syntax highlighting.
|
|
466
|
+
*
|
|
467
|
+
* Hybrid approach: the NEW file is read from disk and highlighted as a whole, so
|
|
468
|
+
* added and context lines keep multi-line context (template literals, block
|
|
469
|
+
* comments); removed lines no longer exist on disk and are highlighted
|
|
470
|
+
* individually.
|
|
471
|
+
*
|
|
472
|
+
* Coloring is line-level only: removed lines get the red tint, added lines the
|
|
473
|
+
* green one (see DIFF_BG_*). Word-level highlighting was removed on purpose —
|
|
474
|
+
* the tint plus syntax colors already say what changed, and inverting tokens
|
|
475
|
+
* fought with the syntax highlighting underneath. Marker characters and hunk
|
|
476
|
+
* headers keep the theme's diff colors; the code itself is never re-wrapped in
|
|
477
|
+
* fg(), which would wipe the colors highlightCode already applied.
|
|
478
|
+
*/
|
|
479
|
+
function renderDiffBody(diffText: string, absPath: string | undefined, theme: any): string {
|
|
480
|
+
const hunks = parseUnifiedDiff(diffText);
|
|
481
|
+
if (hunks.length === 0) return diffText;
|
|
482
|
+
|
|
483
|
+
const lang = absPath ? getLanguageFromPath(absPath) : undefined;
|
|
484
|
+
let newFileLines: string[] | undefined;
|
|
485
|
+
if (lang && absPath) {
|
|
486
|
+
try {
|
|
487
|
+
newFileLines = highlightCode(readFileSync(absPath, "utf8"), lang);
|
|
488
|
+
} catch {
|
|
489
|
+
newFileLines = undefined; // file removed/renamed since the edit: fall back
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const hl = (text: string): string => {
|
|
494
|
+
if (!lang) return text;
|
|
495
|
+
try {
|
|
496
|
+
return highlightCode(text, lang)[0] ?? text;
|
|
497
|
+
} catch {
|
|
498
|
+
return text;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const newLine = (text: string, b: number): string => newFileLines?.[b - 1] ?? hl(text);
|
|
502
|
+
|
|
503
|
+
const shade = (bg: string, text: string): string => `${bg}${text}\x1B[49m`;
|
|
504
|
+
|
|
505
|
+
const out: string[] = [];
|
|
506
|
+
for (const hunk of hunks) {
|
|
507
|
+
out.push(theme.fg("accent", hunk.header));
|
|
508
|
+
const lines = hunk.lines;
|
|
509
|
+
let b = hunk.bStart;
|
|
510
|
+
let i = 0;
|
|
511
|
+
|
|
512
|
+
while (i < lines.length) {
|
|
513
|
+
if (lines[i].kind === " ") {
|
|
514
|
+
out.push(` ${newLine(lines[i].text, b)}`);
|
|
515
|
+
b++;
|
|
516
|
+
i++;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (lines[i].kind === "-") {
|
|
521
|
+
const removed: string[] = [];
|
|
522
|
+
while (i < lines.length && lines[i].kind === "-") removed.push(lines[i++].text);
|
|
523
|
+
const added: string[] = [];
|
|
524
|
+
while (i < lines.length && lines[i].kind === "+") added.push(lines[i++].text);
|
|
525
|
+
|
|
526
|
+
for (const text of removed) out.push(shade(DIFF_BG_REMOVED, `${theme.fg("toolDiffRemoved", "-")}${hl(text)}`));
|
|
527
|
+
for (const text of added) {
|
|
528
|
+
out.push(shade(DIFF_BG_ADDED, `${theme.fg("toolDiffAdded", "+")}${newLine(text, b)}`));
|
|
529
|
+
b++;
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Pure insertion (no preceding removals in this run).
|
|
535
|
+
while (i < lines.length && lines[i].kind === "+") {
|
|
536
|
+
out.push(shade(DIFF_BG_ADDED, `${theme.fg("toolDiffAdded", "+")}${newLine(lines[i].text, b)}`));
|
|
537
|
+
b++;
|
|
538
|
+
i++;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return out.join("\n");
|
|
544
|
+
}
|