pi-hashline-edit-pro 2.6.5 → 2.7.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/README.md +70 -18
- package/index.ts +15 -9
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +6 -0
- package/prompts/grep-snippet.md +1 -0
- package/prompts/grep.md +1 -0
- package/prompts/insert-guidelines.md +6 -0
- package/prompts/insert-snippet.md +1 -0
- package/prompts/insert.md +1 -0
- package/prompts/read-guidelines.md +1 -1
- package/prompts/undo-last-change-guidelines.md +3 -0
- package/prompts/undo-last-change-snippet.md +1 -0
- package/prompts/undo-last-change.md +1 -0
- package/src/boundary-bypass.ts +2 -10
- package/src/commit.ts +107 -0
- package/src/file-reader.ts +4 -0
- package/src/grep.ts +323 -0
- package/src/hash-store.ts +40 -12
- package/src/hashline/apply.ts +2 -15
- package/src/hashline/hash.ts +24 -0
- package/src/hashline/index.ts +3 -0
- package/src/hashline/resolve.ts +68 -38
- package/src/insert.ts +222 -0
- package/src/replace-diff.ts +47 -6
- package/src/replace-render.ts +117 -2
- package/src/replace-response.ts +7 -11
- package/src/replace-undo.ts +22 -31
- package/src/replace.ts +35 -242
- package/src/served.ts +2 -3
- package/prompts/undo-last-replace-guidelines.md +0 -2
- package/prompts/undo-last-replace-snippet.md +0 -1
- package/prompts/undo-last-replace.md +0 -1
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
-
import { HASH_SEP, HASH_RUN,
|
|
2
|
+
import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
8
8
|
hash: string;
|
|
9
|
-
hashMatched: boolean;
|
|
10
9
|
};
|
|
11
10
|
|
|
12
11
|
export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
|
|
@@ -56,7 +55,6 @@ function resAnchorFromMap(
|
|
|
56
55
|
return {
|
|
57
56
|
line: hashMatches[0]!,
|
|
58
57
|
hash: ref.hash,
|
|
59
|
-
hashMatched: true,
|
|
60
58
|
};
|
|
61
59
|
}
|
|
62
60
|
return { ref, kind: "ambiguous", candidates: hashMatches };
|
|
@@ -74,6 +72,19 @@ function assertAligned(
|
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
74
|
|
|
75
|
+
export function fmtRow(hash: string, line: string): string {
|
|
76
|
+
return `${hash}${HASH_SEP}${line}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function fmtRegion(hashes: string[], lines: string[]): string {
|
|
80
|
+
if (hashes.length !== lines.length) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return lines.map((line, index) => fmtRow(hashes[index]!, line)).join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
export function fmtMismatchWithHashes(
|
|
78
89
|
mismatches: HMismatch[],
|
|
79
90
|
fileLines: string[],
|
|
@@ -164,26 +175,29 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
164
175
|
|
|
165
176
|
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
166
177
|
|
|
178
|
+
export function stripAnchorRow(
|
|
179
|
+
trimmed: string,
|
|
180
|
+
entryLabel: string,
|
|
181
|
+
warnings?: string[],
|
|
182
|
+
): string {
|
|
183
|
+
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
184
|
+
if (!match) return trimmed;
|
|
185
|
+
const marker =
|
|
186
|
+
match[1] === "+"
|
|
187
|
+
? "diff-preview marker"
|
|
188
|
+
: match[1] === "-"
|
|
189
|
+
? 'leading "-" marker'
|
|
190
|
+
: '"HASH│" prefix';
|
|
191
|
+
warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
|
|
192
|
+
return match[2]!;
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
168
196
|
assertItem(edit as Record<string, unknown>);
|
|
169
197
|
|
|
170
198
|
const replaceLines = parseText(edit.replacement_lines, warnings);
|
|
171
199
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
172
|
-
|
|
173
|
-
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
174
|
-
if (match) {
|
|
175
|
-
let message: string;
|
|
176
|
-
if (match[1] === "+") {
|
|
177
|
-
message = `[E_BAD_REF] Stripped diff-preview marker from remove_from/remove_to entry "${trimmed}".`;
|
|
178
|
-
} else if (match[1] === "-") {
|
|
179
|
-
message = `[E_BAD_REF] Stripped leading "-" marker from remove_from/remove_to entry "${trimmed}".`;
|
|
180
|
-
} else {
|
|
181
|
-
message = `[E_BAD_REF] Stripped "HASH│" prefix from remove_from/remove_to entry "${trimmed}".`;
|
|
182
|
-
}
|
|
183
|
-
warnings?.push(message);
|
|
184
|
-
return match[2]!;
|
|
185
|
-
}
|
|
186
|
-
return ref;
|
|
200
|
+
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
187
201
|
}) as [string, string];
|
|
188
202
|
return {
|
|
189
203
|
content_lines: replaceLines,
|
|
@@ -210,10 +224,10 @@ export function stripBarePrefixes(
|
|
|
210
224
|
const fileHashSet = new Set(fileHashes);
|
|
211
225
|
const stripped: { lineIndex: number; matched: boolean }[] = [];
|
|
212
226
|
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
stripped.push({ lineIndex, matched: fileHashSet.has(
|
|
216
|
-
return
|
|
227
|
+
const result = stripRowPrefix(line);
|
|
228
|
+
if (result.kind !== "bare") return line;
|
|
229
|
+
stripped.push({ lineIndex, matched: fileHashSet.has(result.hash ?? "") });
|
|
230
|
+
return result.text;
|
|
217
231
|
});
|
|
218
232
|
if (stripped.length === 0) return edit;
|
|
219
233
|
const locations = stripped
|
|
@@ -236,17 +250,10 @@ export function stripDiffPrefixes(
|
|
|
236
250
|
): HEdit {
|
|
237
251
|
const stripped: number[] = [];
|
|
238
252
|
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
239
|
-
const
|
|
240
|
-
if (plus)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
const minus = line.match(HL_PREFIX_MINUS_RE);
|
|
245
|
-
if (minus) {
|
|
246
|
-
stripped.push(lineIndex);
|
|
247
|
-
return line.slice(minus[0].length);
|
|
248
|
-
}
|
|
249
|
-
return line;
|
|
253
|
+
const result = stripRowPrefix(line);
|
|
254
|
+
if (result.kind !== "plus" && result.kind !== "minus") return line;
|
|
255
|
+
stripped.push(lineIndex);
|
|
256
|
+
return result.text;
|
|
250
257
|
});
|
|
251
258
|
if (stripped.length === 0) return edit;
|
|
252
259
|
const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
|
|
@@ -473,13 +480,36 @@ export function valEdit(
|
|
|
473
480
|
};
|
|
474
481
|
}
|
|
475
482
|
|
|
483
|
+
export function resolveAnchorLine(
|
|
484
|
+
ref: Anchor,
|
|
485
|
+
fileLines: string[],
|
|
486
|
+
fileHashes: string[],
|
|
487
|
+
filePath?: string,
|
|
488
|
+
): number {
|
|
489
|
+
const { resolved, mismatches } = valEdit(
|
|
490
|
+
{ hash_bounds: [ref, ref], content_lines: [] },
|
|
491
|
+
fileLines,
|
|
492
|
+
fileHashes,
|
|
493
|
+
[],
|
|
494
|
+
undefined,
|
|
495
|
+
);
|
|
496
|
+
if (mismatches.length > 0 || !resolved) {
|
|
497
|
+
const feedback = fmtMismatchWithHashes(
|
|
498
|
+
mismatches,
|
|
499
|
+
fileLines,
|
|
500
|
+
fileHashes,
|
|
501
|
+
filePath,
|
|
502
|
+
);
|
|
503
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
504
|
+
}
|
|
505
|
+
return resolved.hash_bounds[0].line;
|
|
506
|
+
}
|
|
507
|
+
|
|
476
508
|
export class RangeStaleError extends Error {
|
|
477
|
-
readonly firstMismatchLine: number;
|
|
478
509
|
readonly rangeHashes: string[];
|
|
479
|
-
constructor(message: string,
|
|
510
|
+
constructor(message: string, rangeHashes: string[]) {
|
|
480
511
|
super(message);
|
|
481
512
|
this.name = "RangeStaleError";
|
|
482
|
-
this.firstMismatchLine = firstMismatchLine;
|
|
483
513
|
this.rangeHashes = rangeHashes;
|
|
484
514
|
}
|
|
485
515
|
}
|
|
@@ -516,7 +546,7 @@ export function assertRangeServed(
|
|
|
516
546
|
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
517
547
|
const hash = fileHashes[line - 1]!;
|
|
518
548
|
shownHashes.push(hash);
|
|
519
|
-
rows.push(
|
|
549
|
+
rows.push(fmtRow(hash, fileLines[line - 1]));
|
|
520
550
|
}
|
|
521
551
|
const location = filePath ? ` in ${filePath}` : "";
|
|
522
552
|
const first = mismatchLines[0]!;
|
|
@@ -530,7 +560,7 @@ export function assertRangeServed(
|
|
|
530
560
|
: "";
|
|
531
561
|
const message =
|
|
532
562
|
`[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
533
|
-
throw new RangeStaleError(message,
|
|
563
|
+
throw new RangeStaleError(message, shownHashes);
|
|
534
564
|
}
|
|
535
565
|
|
|
536
566
|
export { warnUnicodeEsc };
|
package/src/insert.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { constants } from "fs";
|
|
5
|
+
import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
|
|
6
|
+
import { commitEdit } from "./commit";
|
|
7
|
+
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
+
import { resolveTarget } from "./fs-write";
|
|
9
|
+
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
|
+
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
|
+
import { toCwd } from "./paths";
|
|
12
|
+
import { loadP, loadGuide } from "./prompts";
|
|
13
|
+
import { normReq } from "./replace-normalize";
|
|
14
|
+
import { genDiff } from "./replace-diff";
|
|
15
|
+
import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
|
|
16
|
+
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
17
|
+
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
18
|
+
|
|
19
|
+
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
20
|
+
|
|
21
|
+
export interface InsertReq {
|
|
22
|
+
path: string;
|
|
23
|
+
anchor: string;
|
|
24
|
+
direction: "before" | "after";
|
|
25
|
+
lines: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
29
|
+
if (!isRec(request)) {
|
|
30
|
+
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
31
|
+
}
|
|
32
|
+
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
33
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
34
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
|
|
35
|
+
}
|
|
36
|
+
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
37
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char hash from read output).');
|
|
38
|
+
}
|
|
39
|
+
if (request.direction !== "before" && request.direction !== "after") {
|
|
40
|
+
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
43
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const insertToolSchema = Type.Object(
|
|
48
|
+
{
|
|
49
|
+
path: Type.String({
|
|
50
|
+
description:
|
|
51
|
+
"Path to the file to edit",
|
|
52
|
+
}),
|
|
53
|
+
anchor: Type.String({
|
|
54
|
+
description:
|
|
55
|
+
'Bare 3-char HASH only (e.g. "aB3"): copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or a `HASH│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
|
|
56
|
+
}),
|
|
57
|
+
direction: Type.Union(
|
|
58
|
+
[
|
|
59
|
+
Type.Literal("after", { description: "Insert the lines after the anchor line" }),
|
|
60
|
+
Type.Literal("before", { description: "Insert the lines before the anchor line" }),
|
|
61
|
+
],
|
|
62
|
+
{ description: '"after" or "before"' },
|
|
63
|
+
),
|
|
64
|
+
lines: Type.Array(
|
|
65
|
+
Type.String({
|
|
66
|
+
description:
|
|
67
|
+
"One line to insert. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
|
|
68
|
+
}),
|
|
69
|
+
{
|
|
70
|
+
description:
|
|
71
|
+
'Lines to insert as an array of strings, one element per line. Use [""] for a blank line. The anchor line is preserved; never include it in lines.',
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: false },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
79
|
+
const trimmedAnchor = raw.trim();
|
|
80
|
+
const warnings: string[] = [];
|
|
81
|
+
const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
|
|
82
|
+
return { ref: parseHashRef(anchorText), warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildInsertEdit(
|
|
86
|
+
req: InsertReq,
|
|
87
|
+
preload: NormFile,
|
|
88
|
+
ref: Anchor,
|
|
89
|
+
): { editParams: ReqParams; anchorLine: string | undefined } {
|
|
90
|
+
const fileLines = splitLines(preload.normalized);
|
|
91
|
+
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, req.path);
|
|
92
|
+
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
93
|
+
const editParams: ReqParams = {
|
|
94
|
+
path: req.path,
|
|
95
|
+
remove_from: ref.hash,
|
|
96
|
+
remove_to: ref.hash,
|
|
97
|
+
replacement_lines:
|
|
98
|
+
anchorLine === undefined
|
|
99
|
+
? [...req.lines]
|
|
100
|
+
: req.direction === "after"
|
|
101
|
+
? [anchorLine, ...req.lines]
|
|
102
|
+
: [...req.lines, anchorLine],
|
|
103
|
+
};
|
|
104
|
+
return { editParams, anchorLine };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
|
|
108
|
+
try {
|
|
109
|
+
const normalized = normReq(request);
|
|
110
|
+
assertInsertReq(normalized);
|
|
111
|
+
const { ref } = parseInsertAnchor(normalized.anchor);
|
|
112
|
+
const preload = await readNormFile(normalized.path, cwd, {
|
|
113
|
+
accessMode: constants.R_OK,
|
|
114
|
+
maxLines: MAX_HASH_LINES,
|
|
115
|
+
noPersist: true,
|
|
116
|
+
});
|
|
117
|
+
const { editParams } = buildInsertEdit(normalized, preload, ref);
|
|
118
|
+
const pipe = await execPipeline(editParams, cwd, {
|
|
119
|
+
accessMode: constants.R_OK,
|
|
120
|
+
noPersist: true,
|
|
121
|
+
preloadedNorm: preload,
|
|
122
|
+
skipBoundaryDedup: true,
|
|
123
|
+
});
|
|
124
|
+
if (pipe.originalNormalized === pipe.result) {
|
|
125
|
+
return { error: `No changes made to ${normalized.path}. The edit produced identical content.` };
|
|
126
|
+
}
|
|
127
|
+
return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string[] } | null {
|
|
134
|
+
let normalized: unknown;
|
|
135
|
+
try {
|
|
136
|
+
normalized = normReq(args);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!isRec(normalized) || typeof normalized.path !== "string") return null;
|
|
141
|
+
if (
|
|
142
|
+
typeof normalized.anchor !== "string" ||
|
|
143
|
+
(normalized.direction !== "before" && normalized.direction !== "after") ||
|
|
144
|
+
!Array.isArray(normalized.lines) ||
|
|
145
|
+
normalized.lines.some((line) => typeof line !== "string")
|
|
146
|
+
) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
path: normalized.path,
|
|
151
|
+
anchor: normalized.anchor,
|
|
152
|
+
direction: normalized.direction,
|
|
153
|
+
lines: normalized.lines,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
158
|
+
|
|
159
|
+
export function buildInsertToolDef(): InsertToolDef {
|
|
160
|
+
return {
|
|
161
|
+
name: "insert",
|
|
162
|
+
label: "Insert",
|
|
163
|
+
description: loadP("../prompts/insert.md"),
|
|
164
|
+
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
165
|
+
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
|
+
prepareArguments: makePrepareArguments(),
|
|
167
|
+
parameters: insertToolSchema,
|
|
168
|
+
renderShell: "default",
|
|
169
|
+
renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
|
|
170
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
171
|
+
return renderEditResult(
|
|
172
|
+
result as {
|
|
173
|
+
content?: Array<{ type: string; text?: string }>;
|
|
174
|
+
details?: ReplaceDetails;
|
|
175
|
+
},
|
|
176
|
+
isPartial,
|
|
177
|
+
theme,
|
|
178
|
+
context,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
183
|
+
const canonical = normReq(params);
|
|
184
|
+
assertInsertReq(canonical);
|
|
185
|
+
const req = canonical;
|
|
186
|
+
const path = req.path;
|
|
187
|
+
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
188
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
189
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
190
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
191
|
+
abortIf(signal);
|
|
192
|
+
const preload = await readNormFile(path, ctx.cwd, {
|
|
193
|
+
signal,
|
|
194
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
195
|
+
maxLines: MAX_HASH_LINES,
|
|
196
|
+
});
|
|
197
|
+
const { editParams, anchorLine } = buildInsertEdit(req, preload, ref);
|
|
198
|
+
const pipe = await execPipeline(editParams, ctx.cwd, {
|
|
199
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
200
|
+
signal,
|
|
201
|
+
preloadedNorm: preload,
|
|
202
|
+
skipBoundaryDedup: true,
|
|
203
|
+
});
|
|
204
|
+
return commitEdit(pipe, {
|
|
205
|
+
path,
|
|
206
|
+
absolutePath,
|
|
207
|
+
mutationTargetPath,
|
|
208
|
+
signal,
|
|
209
|
+
verb: "inserted",
|
|
210
|
+
noopNoun: "Insertion",
|
|
211
|
+
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
212
|
+
prefixWarnings: anchorWarnings,
|
|
213
|
+
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function regInsert(pi: ExtensionAPI): void {
|
|
221
|
+
pi.registerTool(buildInsertToolDef());
|
|
222
|
+
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -47,6 +47,10 @@ function fmtDiffLine(
|
|
|
47
47
|
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function isBlankLine(line: string): boolean {
|
|
51
|
+
return line.trim().length === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
|
|
51
55
|
const isEllipsisMarker = (line: string | symbol): line is symbol =>
|
|
52
56
|
line === ELLIPSIS_MARKER;
|
|
@@ -99,15 +103,52 @@ export function genDiff(
|
|
|
99
103
|
let skipTail = 0;
|
|
100
104
|
|
|
101
105
|
if (!lastWasChange) {
|
|
102
|
-
|
|
106
|
+
let count = contextLines;
|
|
107
|
+
if (
|
|
108
|
+
contextLines > 0 &&
|
|
109
|
+
displayLines.length > count &&
|
|
110
|
+
isBlankLine(displayLines[displayLines.length - 1]!)
|
|
111
|
+
) {
|
|
112
|
+
count += 1;
|
|
113
|
+
}
|
|
114
|
+
count = Math.min(count, displayLines.length);
|
|
115
|
+
skipStart = displayLines.length - count;
|
|
103
116
|
linesToShow = displayLines.slice(skipStart);
|
|
104
117
|
} else if (nextPartIsChange && displayLines.length > contextLines * 2) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
let headCount = contextLines;
|
|
119
|
+
let tailCount = contextLines;
|
|
120
|
+
if (
|
|
121
|
+
contextLines > 0 &&
|
|
122
|
+
displayLines.length - headCount > tailCount &&
|
|
123
|
+
isBlankLine(displayLines[headCount - 1]!)
|
|
124
|
+
) {
|
|
125
|
+
headCount += 1;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
contextLines > 0 &&
|
|
129
|
+
displayLines.length - tailCount > headCount &&
|
|
130
|
+
isBlankLine(displayLines[displayLines.length - tailCount]!)
|
|
131
|
+
) {
|
|
132
|
+
tailCount += 1;
|
|
133
|
+
}
|
|
134
|
+
const middleLen = displayLines.length - headCount - tailCount;
|
|
135
|
+
if (middleLen > 0) {
|
|
136
|
+
linesToShow = [
|
|
137
|
+
...displayLines.slice(0, headCount),
|
|
138
|
+
ELLIPSIS_MARKER,
|
|
139
|
+
...displayLines.slice(displayLines.length - tailCount),
|
|
140
|
+
];
|
|
141
|
+
skipMiddle = middleLen;
|
|
142
|
+
} else {
|
|
143
|
+
linesToShow = displayLines;
|
|
144
|
+
}
|
|
108
145
|
} else if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
let count = contextLines;
|
|
147
|
+
const firstLine = linesToShow[0];
|
|
148
|
+
if (contextLines > 0 && typeof firstLine === "string" && isBlankLine(firstLine)) count += 1;
|
|
149
|
+
count = Math.min(count, linesToShow.length);
|
|
150
|
+
linesToShow = linesToShow.slice(0, count);
|
|
151
|
+
skipTail = displayLines.length - count;
|
|
111
152
|
}
|
|
112
153
|
|
|
113
154
|
if (skipStart > 0) {
|
package/src/replace-render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
1
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { normReq } from "./replace-normalize";
|
|
3
4
|
import type { ReqParams, ReplaceDetails } from "./replace";
|
|
@@ -89,17 +90,18 @@ export function fmtResult(diff: string, theme: FgT): string {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export function fmtCall(
|
|
92
|
-
args:
|
|
93
|
+
args: { path?: string } | undefined,
|
|
93
94
|
state: RRState,
|
|
94
95
|
expanded: boolean,
|
|
95
96
|
theme: CallT,
|
|
97
|
+
toolName = "replace",
|
|
96
98
|
): string {
|
|
97
99
|
const path = args?.path;
|
|
98
100
|
const pathDisplay =
|
|
99
101
|
typeof path === "string" && path.length > 0
|
|
100
102
|
? theme.fg("accent", path)
|
|
101
103
|
: theme.fg("toolOutput", "...");
|
|
102
|
-
let text = `${theme.fg("toolTitle", theme.bold(
|
|
104
|
+
let text = `${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`;
|
|
103
105
|
|
|
104
106
|
if (!state.preview) {
|
|
105
107
|
return text;
|
|
@@ -209,3 +211,116 @@ export function mkMdTheme(theme: MdTheme) {
|
|
|
209
211
|
}),
|
|
210
212
|
};
|
|
211
213
|
}
|
|
214
|
+
|
|
215
|
+
export const PREVIEW_DEBOUNCE_MS = 150;
|
|
216
|
+
|
|
217
|
+
export function reuseText(context: any, content: string): Text {
|
|
218
|
+
const t = context.lastComponent instanceof Text
|
|
219
|
+
? context.lastComponent
|
|
220
|
+
: new Text("", 0, 0);
|
|
221
|
+
t.setText(content);
|
|
222
|
+
return t;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
226
|
+
const m = context.lastComponent instanceof Markdown
|
|
227
|
+
? context.lastComponent
|
|
228
|
+
: new Markdown("", 0, 0, mkMdTheme(theme));
|
|
229
|
+
m.setText(content);
|
|
230
|
+
return m;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function makeRenderCall(
|
|
234
|
+
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
235
|
+
options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
|
|
236
|
+
) {
|
|
237
|
+
const getInput = options.getInput ?? getPreviewInput;
|
|
238
|
+
const toolName = options.toolName ?? "replace";
|
|
239
|
+
return (args: any, theme: CallT, context: any): Text => {
|
|
240
|
+
const previewInput = getInput(args);
|
|
241
|
+
const cancelPendingPreview = () => {
|
|
242
|
+
if (context.state.previewTimer) {
|
|
243
|
+
clearTimeout(context.state.previewTimer);
|
|
244
|
+
context.state.previewTimer = undefined;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
if (context.executionStarted) {
|
|
248
|
+
cancelPendingPreview();
|
|
249
|
+
context.state.argsKey = undefined;
|
|
250
|
+
context.state.preview = undefined;
|
|
251
|
+
context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
252
|
+
} else if (!context.argsComplete || !previewInput) {
|
|
253
|
+
cancelPendingPreview();
|
|
254
|
+
context.state.argsKey = undefined;
|
|
255
|
+
context.state.preview = undefined;
|
|
256
|
+
context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
257
|
+
} else {
|
|
258
|
+
const argsKey = JSON.stringify(previewInput);
|
|
259
|
+
if (context.state.argsKey !== argsKey) {
|
|
260
|
+
cancelPendingPreview();
|
|
261
|
+
context.state.argsKey = argsKey;
|
|
262
|
+
context.state.preview = undefined;
|
|
263
|
+
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
264
|
+
context.state.previewGeneration = previewGeneration;
|
|
265
|
+
context.state.previewTimer = setTimeout(() => {
|
|
266
|
+
context.state.previewTimer = undefined;
|
|
267
|
+
preview(args, context.cwd)
|
|
268
|
+
.then((result) => {
|
|
269
|
+
if (
|
|
270
|
+
context.state.argsKey === argsKey &&
|
|
271
|
+
context.state.previewGeneration === previewGeneration
|
|
272
|
+
) {
|
|
273
|
+
context.state.preview = result;
|
|
274
|
+
context.invalidate();
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
.catch((err: unknown) => {
|
|
278
|
+
if (
|
|
279
|
+
context.state.argsKey === argsKey &&
|
|
280
|
+
context.state.previewGeneration === previewGeneration
|
|
281
|
+
) {
|
|
282
|
+
context.state.preview = {
|
|
283
|
+
error: err instanceof Error ? err.message : String(err),
|
|
284
|
+
};
|
|
285
|
+
context.invalidate();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}, PREVIEW_DEBOUNCE_MS);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
292
|
+
text.setText(fmtCall(getInput(args) ?? undefined, context.state as RRState, context.expanded, theme, toolName));
|
|
293
|
+
return text;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function renderEditResult(
|
|
298
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
299
|
+
isPartial: boolean,
|
|
300
|
+
theme: FgT,
|
|
301
|
+
context: any,
|
|
302
|
+
): Text | Markdown {
|
|
303
|
+
if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
|
|
304
|
+
const renderedText = getResultText(result);
|
|
305
|
+
const renderState = context.state as RRState | undefined;
|
|
306
|
+
if (renderState) {
|
|
307
|
+
if (renderState.previewTimer) {
|
|
308
|
+
clearTimeout(renderState.previewTimer);
|
|
309
|
+
renderState.previewTimer = undefined;
|
|
310
|
+
}
|
|
311
|
+
renderState.preview = undefined;
|
|
312
|
+
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
313
|
+
}
|
|
314
|
+
if (context.isError) {
|
|
315
|
+
return renderedText
|
|
316
|
+
? reuseText(context, `\n${theme.fg("error", renderedText)}`)
|
|
317
|
+
: new Text("", 0, 0);
|
|
318
|
+
}
|
|
319
|
+
if (isApplied(result.details)) {
|
|
320
|
+
const appliedText = buildAppliedText(renderedText, result.details, theme);
|
|
321
|
+
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
322
|
+
}
|
|
323
|
+
if (!renderedText) return new Text("", 0, 0);
|
|
324
|
+
return reuseMarkdown(context, fmtResultMd(renderedText), theme);
|
|
325
|
+
}
|
|
326
|
+
|
package/src/replace-response.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import type { NEdit } from "./hashline";
|
|
1
2
|
import type { ReplaceDetails } from "./replace";
|
|
2
3
|
import { genDiff, genPatch } from "./replace-diff";
|
|
3
4
|
import { visLines, clipLine } from "./utils";
|
|
4
5
|
|
|
5
|
-
type TResult = {
|
|
6
|
+
export type TResult = {
|
|
6
7
|
content: Array<{ type: "text"; text: string }>;
|
|
7
8
|
isError?: boolean;
|
|
8
9
|
details: ReplaceDetails;
|
|
@@ -27,14 +28,9 @@ export type RMeta = {
|
|
|
27
28
|
removedLines: number;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
type NEditEntry = {
|
|
31
|
-
loc: string;
|
|
32
|
-
currentContent: string;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
31
|
export interface NoopInput {
|
|
36
32
|
path: string;
|
|
37
|
-
noopEdit:
|
|
33
|
+
noopEdit: NEdit | undefined;
|
|
38
34
|
snapshotId?: string;
|
|
39
35
|
editMeta: RMeta;
|
|
40
36
|
warnings: string[] | undefined;
|
|
@@ -89,7 +85,7 @@ function warnBlock(warnings: string[] | undefined): string {
|
|
|
89
85
|
return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
90
86
|
}
|
|
91
87
|
|
|
92
|
-
export function buildNoop(input: NoopInput): TResult {
|
|
88
|
+
export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
93
89
|
const {
|
|
94
90
|
path,
|
|
95
91
|
noopEdit,
|
|
@@ -100,7 +96,7 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
100
96
|
} = input;
|
|
101
97
|
|
|
102
98
|
const noopDetailsText = noopEdit
|
|
103
|
-
?
|
|
99
|
+
? `${noopNoun} for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
|
|
104
100
|
: "The edit produced identical content.";
|
|
105
101
|
const dedupNote =
|
|
106
102
|
boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
|
|
@@ -129,14 +125,14 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
129
125
|
};
|
|
130
126
|
}
|
|
131
127
|
|
|
132
|
-
export function buildChanged(input: SuccessInput): TResult {
|
|
128
|
+
export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
133
129
|
const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
|
|
134
130
|
const resultLines = visLines(result);
|
|
135
131
|
const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
|
|
136
132
|
const addedLines = editMeta.addedLines;
|
|
137
133
|
const removedLines = editMeta.removedLines;
|
|
138
134
|
const warningsBlock = warnBlock(warnings);
|
|
139
|
-
const successPrefix = `Successfully
|
|
135
|
+
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
140
136
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
141
137
|
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
142
138
|
: "";
|