pi-hashline-edit-pro 2.7.2 → 2.8.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 +7 -1
- package/index.ts +9 -6
- package/package.json +10 -10
- package/prompts/grep-guidelines.md +3 -7
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -7
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -9
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -4
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/commit.ts +2 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +14 -8
- package/src/file-reader.ts +34 -13
- package/src/fs-write.ts +60 -3
- package/src/grep.ts +119 -32
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +62 -0
- package/src/hash-store.ts +68 -133
- package/src/hashline/hash.ts +11 -9
- package/src/hashline/parse.ts +15 -1
- package/src/hashline/resolve.ts +3 -2
- package/src/insert.ts +11 -32
- package/src/normalize.ts +27 -0
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +15 -1
- package/src/replace-diff.ts +28 -45
- package/src/replace-render.ts +18 -40
- package/src/replace-response.ts +1 -0
- package/src/replace-undo.ts +28 -13
- package/src/replace.ts +49 -128
- package/src/served.ts +26 -5
- package/src/utils.ts +58 -0
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +59 -0
- package/src/replace-normalize.ts +0 -13
package/src/hashline/hash.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { splitLines, truncateToBytes } from "../utils";
|
|
1
|
+
import { splitLines, truncateToBytes, getCached } from "../utils";
|
|
2
2
|
import { MAX_HASH_SOURCE_BYTES } from "../constants";
|
|
3
3
|
import {
|
|
4
4
|
loadHashStore,
|
|
5
5
|
type HashStore,
|
|
6
6
|
getSnapshot,
|
|
7
|
-
|
|
7
|
+
persistSnapshot,
|
|
8
8
|
} from "../hash-store";
|
|
9
|
-
import { xxh32,
|
|
9
|
+
import { xxh32, initHasher } from "./hasher";
|
|
10
10
|
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
|
|
11
11
|
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
|
|
12
12
|
|
|
@@ -120,9 +120,10 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
120
120
|
const hashes = new Array<string>(lines.length);
|
|
121
121
|
const used = new Uint32Array(BITSET_WORDS);
|
|
122
122
|
const hint = { value: 0 };
|
|
123
|
+
const hashSourceCache = new Map<string, string>();
|
|
123
124
|
|
|
124
125
|
for (let i = 0; i < lines.length; i++) {
|
|
125
|
-
const c =
|
|
126
|
+
const c = getCached(hashSourceCache, lines[i]!, hashSource);
|
|
126
127
|
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
127
128
|
hashes[i] = assignHash(used, baseIdx, hint);
|
|
128
129
|
}
|
|
@@ -151,7 +152,7 @@ export async function lineHashes(
|
|
|
151
152
|
);
|
|
152
153
|
if (persist !== false) {
|
|
153
154
|
try {
|
|
154
|
-
|
|
155
|
+
persistSnapshot(hashStore, path, content, newHashes);
|
|
155
156
|
} catch (error) {
|
|
156
157
|
console.error("Failed to persist hash snapshot:", error);
|
|
157
158
|
}
|
|
@@ -172,7 +173,7 @@ export async function lineHashes(
|
|
|
172
173
|
const newHashes = _lineHashesPure(content);
|
|
173
174
|
if (persist !== false) {
|
|
174
175
|
try {
|
|
175
|
-
|
|
176
|
+
persistSnapshot(hashStore, path, content, newHashes);
|
|
176
177
|
} catch (error) {
|
|
177
178
|
console.error("Failed to persist hash snapshot:", error);
|
|
178
179
|
}
|
|
@@ -224,6 +225,7 @@ function mapStableHashes(
|
|
|
224
225
|
const newHashes = new Array<string>(newLines.length);
|
|
225
226
|
const used = new Uint32Array(BITSET_WORDS);
|
|
226
227
|
const hint = { value: 0 };
|
|
228
|
+
const hashSourceCache = new Map<string, string>();
|
|
227
229
|
const removed = removedHashes ?? new Set<string>();
|
|
228
230
|
|
|
229
231
|
const oldHashIndex = new Map<string, number>();
|
|
@@ -260,7 +262,7 @@ function mapStableHashes(
|
|
|
260
262
|
|
|
261
263
|
const newByContent = new Map<string, number[]>();
|
|
262
264
|
for (let i = 0; i < newLines.length; i++) {
|
|
263
|
-
const key =
|
|
265
|
+
const key = getCached(hashSourceCache, newLines[i]!, hashSource);
|
|
264
266
|
const list = newByContent.get(key);
|
|
265
267
|
if (list) list.push(i);
|
|
266
268
|
else newByContent.set(key, [i]);
|
|
@@ -275,7 +277,7 @@ function mapStableHashes(
|
|
|
275
277
|
};
|
|
276
278
|
|
|
277
279
|
for (const entry of survivors) {
|
|
278
|
-
const candidates = newByContent.get(
|
|
280
|
+
const candidates = newByContent.get(getCached(hashSourceCache, oldLines[entry.index]!, hashSource));
|
|
279
281
|
if (!candidates || candidates.length === 0) continue;
|
|
280
282
|
const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
|
|
281
283
|
const pos = nearestNew(candidates, target);
|
|
@@ -306,7 +308,7 @@ function mapStableHashes(
|
|
|
306
308
|
|
|
307
309
|
for (let i = 0; i < newLines.length; i++) {
|
|
308
310
|
if (newHashes[i]) continue;
|
|
309
|
-
const c =
|
|
311
|
+
const c = getCached(hashSourceCache, newLines[i]!, hashSource);
|
|
310
312
|
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
311
313
|
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
312
314
|
}
|
package/src/hashline/parse.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
|
+
HASH_CLASS,
|
|
4
5
|
} from "./hash";
|
|
5
6
|
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
6
7
|
|
|
8
|
+
const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
|
|
9
|
+
|
|
7
10
|
export type Anchor = { hash: string };
|
|
8
11
|
|
|
9
12
|
function diagRef(ref: string): string {
|
|
@@ -16,7 +19,18 @@ function diagRef(ref: string): string {
|
|
|
16
19
|
if (/^\d+/.test(trimmed)) {
|
|
17
20
|
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
if (trimmed.includes("│") && trimmed.includes("\n")) {
|
|
23
|
+
const lines = trimmed.split(/\r?\n/);
|
|
24
|
+
const first = lines[0] ?? "";
|
|
25
|
+
const last = lines[lines.length - 1] ?? "";
|
|
26
|
+
const hashRe = HASH_EXTRACT_RE;
|
|
27
|
+
const firstMatch = first.match(hashRe);
|
|
28
|
+
const lastMatch = last.match(hashRe);
|
|
29
|
+
const firstHash = firstMatch?.[0] ?? "aB3";
|
|
30
|
+
const lastHash = lastMatch?.[0] ?? "aB3";
|
|
31
|
+
const preview = first.slice(0, 60);
|
|
32
|
+
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 3-char hash (e.g. "aB3"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
|
|
33
|
+
}
|
|
20
34
|
if (trimmed.includes("│")) {
|
|
21
35
|
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
|
|
22
36
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
1
|
+
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
|
|
2
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";
|
|
@@ -462,7 +462,8 @@ export function valEdit(
|
|
|
462
462
|
}
|
|
463
463
|
const endLine = endResolved.line;
|
|
464
464
|
const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
|
|
465
|
-
const
|
|
465
|
+
const canonCache = new Map<string, string>();
|
|
466
|
+
const canonLines = fileLines.map((line) => getCached(canonCache, line, canon));
|
|
466
467
|
boundaryDups.push(
|
|
467
468
|
...trailingDups(edit.content_lines, fileLines, endLine),
|
|
468
469
|
...leadingDups(edit.content_lines, fileLines, startResolved.line),
|
package/src/insert.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { Type } from "typebox";
|
|
4
3
|
import { constants } from "fs";
|
|
5
|
-
import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
|
|
4
|
+
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
6
5
|
import { commitEdit } from "./commit";
|
|
7
6
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { resolveTarget } from "./fs-write";
|
|
9
7
|
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
8
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
|
-
import { toCwd } from "./paths";
|
|
12
9
|
import { loadP, loadGuide } from "./prompts";
|
|
13
|
-
import { normReq } from "./
|
|
14
|
-
import {
|
|
15
|
-
import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
|
|
16
|
-
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
10
|
+
import { normReq } from "./payload-contract";
|
|
11
|
+
import { isRec, rejectUnknownFields, splitLines } from "./utils";
|
|
17
12
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
|
+
import type { RPreview, RRState } from "./replace-render";
|
|
14
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
18
15
|
|
|
19
16
|
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
20
17
|
|
|
@@ -121,12 +118,9 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
|
|
|
121
118
|
preloadedNorm: preload,
|
|
122
119
|
skipBoundaryDedup: true,
|
|
123
120
|
});
|
|
124
|
-
|
|
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 };
|
|
121
|
+
return previewFromPipe(pipe);
|
|
128
122
|
} catch (error: unknown) {
|
|
129
|
-
return
|
|
123
|
+
return previewError(error);
|
|
130
124
|
}
|
|
131
125
|
}
|
|
132
126
|
|
|
@@ -163,32 +157,17 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
163
157
|
description: loadP("../prompts/insert.md"),
|
|
164
158
|
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
165
159
|
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
|
-
|
|
160
|
+
...editToolBase,
|
|
167
161
|
parameters: insertToolSchema,
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
162
|
+
renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
|
|
163
|
+
renderResult: editRenderResultWrapper,
|
|
182
164
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
183
165
|
const canonical = normReq(params);
|
|
184
166
|
assertInsertReq(canonical);
|
|
185
167
|
const req = canonical;
|
|
186
168
|
const path = req.path;
|
|
187
169
|
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
188
|
-
|
|
189
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
190
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
191
|
-
abortIf(signal);
|
|
170
|
+
return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
192
171
|
const preload = await readNormFile(path, ctx.cwd, {
|
|
193
172
|
signal,
|
|
194
173
|
accessMode: constants.R_OK | constants.W_OK,
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
2
|
+
|
|
3
|
+
export function detectEnding(content: string): LineEnding {
|
|
4
|
+
const crIdx = content.indexOf("\r");
|
|
5
|
+
const lfIdx = content.indexOf("\n");
|
|
6
|
+
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
7
|
+
if (crIdx === -1) return "\n";
|
|
8
|
+
if (lfIdx === -1) return "\r";
|
|
9
|
+
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
10
|
+
return "\n";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toLF(text: string): string {
|
|
14
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function restoreEndings(text: string, ending: LineEnding): string {
|
|
18
|
+
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
19
|
+
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stripBOM(content: string): { bom: string; text: string } {
|
|
24
|
+
return content.startsWith("\uFEFF")
|
|
25
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
26
|
+
: { bom: "", text: content };
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
|
|
3
|
+
|
|
4
|
+
const replacementLinesSchema = Type.Array(
|
|
5
|
+
Type.String({
|
|
6
|
+
description:
|
|
7
|
+
"One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
|
|
8
|
+
}),
|
|
9
|
+
{
|
|
10
|
+
description:
|
|
11
|
+
"Replacement lines as an array of strings, one element per line. Use [] to delete the range.",
|
|
12
|
+
},
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const removeFromSchema = Type.String({
|
|
16
|
+
description:
|
|
17
|
+
"Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const removeToSchema = Type.String({
|
|
21
|
+
description:
|
|
22
|
+
"Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const editToolSchema = Type.Object(
|
|
26
|
+
{
|
|
27
|
+
path: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description:
|
|
30
|
+
"Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake.",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
remove_from: removeFromSchema,
|
|
34
|
+
remove_to: removeToSchema,
|
|
35
|
+
replacement_lines: replacementLinesSchema,
|
|
36
|
+
},
|
|
37
|
+
{ additionalProperties: false },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export type ReqParams = {
|
|
41
|
+
path: string;
|
|
42
|
+
remove_from: string;
|
|
43
|
+
remove_to: string;
|
|
44
|
+
replacement_lines: string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
48
|
+
|
|
49
|
+
export function assertReq(request: unknown): asserts request is ReqParams {
|
|
50
|
+
if (!isRec(request)) {
|
|
51
|
+
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
52
|
+
}
|
|
53
|
+
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
54
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
55
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
typeof request.remove_from !== "string" ||
|
|
59
|
+
typeof request.remove_to !== "string" ||
|
|
60
|
+
!Array.isArray(request.replacement_lines) ||
|
|
61
|
+
request.replacement_lines.some((line) => typeof line !== "string")
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normReq(input: unknown): unknown {
|
|
70
|
+
if (!isRec(input)) {
|
|
71
|
+
return input;
|
|
72
|
+
}
|
|
73
|
+
const record: Record<string, unknown> = { ...input };
|
|
74
|
+
normalizeFilePath(record);
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getPreviewInput(args: unknown): ReqParams | null {
|
|
79
|
+
let normalized: unknown;
|
|
80
|
+
try {
|
|
81
|
+
normalized = normReq(args);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!isRec(normalized) || typeof normalized.path !== "string") {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
typeof normalized.remove_from !== "string" ||
|
|
90
|
+
typeof normalized.remove_to !== "string" ||
|
|
91
|
+
!Array.isArray(normalized.replacement_lines) ||
|
|
92
|
+
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
93
|
+
) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
path: normalized.path,
|
|
98
|
+
remove_from: normalized.remove_from,
|
|
99
|
+
remove_to: normalized.remove_to,
|
|
100
|
+
replacement_lines: normalized.replacement_lines,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -13,10 +13,11 @@ import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
|
|
|
13
13
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
14
14
|
import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
|
-
import { abortIf, makePrepareArguments, visLines } from "./utils";
|
|
16
|
+
import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
|
|
17
17
|
import { recordServedSafe } from "./served";
|
|
18
18
|
import { loadP, loadGuide } from "./prompts";
|
|
19
19
|
import { valAccess } from "./validation";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
20
21
|
|
|
21
22
|
const R_DESC = loadP("../prompts/read.md");
|
|
22
23
|
|
|
@@ -190,6 +191,18 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
190
191
|
}),
|
|
191
192
|
),
|
|
192
193
|
}),
|
|
194
|
+
executionMode: "sequential",
|
|
195
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
196
|
+
if (isPartial) return new Text((theme as unknown as { fg: (a:string,b:string)=>string }).fg("warning", "Reading..."), 0, 0);
|
|
197
|
+
const raw = (result.content?.[0] as { text?: string } | undefined)?.text;
|
|
198
|
+
if (typeof raw !== "string") return new Text("", 0, 0);
|
|
199
|
+
if ((context as unknown as { isError?: boolean }).isError) return new Text((theme as unknown as { fg: (a:string,b:string)=>string }).fg("error", raw), 0, 0);
|
|
200
|
+
const isExpanded = expanded === true || (context as unknown as { expanded?: boolean }).expanded === true;
|
|
201
|
+
if (!isExpanded) return new Text("", 0, 0);
|
|
202
|
+
const details = (result as unknown as { details?: { offset?: number } }).details;
|
|
203
|
+
const off = details?.offset ?? (context as unknown as { args?: { offset?: number } }).args?.offset ?? 1;
|
|
204
|
+
return new Text(numberedRead(raw, off), 0, 0);
|
|
205
|
+
},
|
|
193
206
|
|
|
194
207
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
195
208
|
const rawPath = params.path;
|
|
@@ -235,6 +248,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
235
248
|
details: {
|
|
236
249
|
truncation: preview.truncation,
|
|
237
250
|
snapshotId,
|
|
251
|
+
offset: params.offset ?? 1,
|
|
238
252
|
...(preview.nextOffset !== undefined
|
|
239
253
|
? { nextOffset: preview.nextOffset }
|
|
240
254
|
: {}),
|
package/src/replace-diff.ts
CHANGED
|
@@ -5,37 +5,15 @@ import {
|
|
|
5
5
|
ANCHOR_LEN,
|
|
6
6
|
HASH_SEP,
|
|
7
7
|
} from "./hashline";
|
|
8
|
+
import {
|
|
9
|
+
detectEnding,
|
|
10
|
+
toLF,
|
|
11
|
+
restoreEndings,
|
|
12
|
+
stripBOM,
|
|
13
|
+
type LineEnding,
|
|
14
|
+
} from "./normalize";
|
|
8
15
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
export function detectEnding(content: string): LineEnding {
|
|
12
|
-
const lfIdx = content.indexOf("\n");
|
|
13
|
-
if (lfIdx === -1) {
|
|
14
|
-
return content.indexOf("\r") >= 0 ? "\r" : "\n";
|
|
15
|
-
}
|
|
16
|
-
const crlfIdx = content.indexOf("\r\n");
|
|
17
|
-
if (crlfIdx === -1) return "\n";
|
|
18
|
-
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function toLF(text: string): string {
|
|
22
|
-
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function restoreEndings(
|
|
26
|
-
text: string,
|
|
27
|
-
ending: LineEnding,
|
|
28
|
-
): string {
|
|
29
|
-
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
30
|
-
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
31
|
-
return text;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function stripBOM(content: string): { bom: string; text: string } {
|
|
35
|
-
return content.startsWith("\uFEFF")
|
|
36
|
-
? { bom: "\uFEFF", text: content.slice(1) }
|
|
37
|
-
: { bom: "", text: content };
|
|
38
|
-
}
|
|
16
|
+
export { detectEnding, toLF, restoreEndings, stripBOM, type LineEnding };
|
|
39
17
|
|
|
40
18
|
function fmtDiffLine(
|
|
41
19
|
prefix: " " | "+" | "-",
|
|
@@ -69,7 +47,7 @@ export function genDiff(
|
|
|
69
47
|
newContentHashes?: string[],
|
|
70
48
|
oldContentHashes?: string[],
|
|
71
49
|
limits?: DiffLimits,
|
|
72
|
-
): { diff: string; firstChangedLine: number | undefined } {
|
|
50
|
+
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
|
|
73
51
|
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
74
52
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
75
53
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
@@ -83,8 +61,9 @@ export function genDiff(
|
|
|
83
61
|
let outBytes = 0;
|
|
84
62
|
let stopped = false;
|
|
85
63
|
let diffTruncated = false;
|
|
64
|
+
const lineNumbers: (number|undefined)[] = [];
|
|
86
65
|
|
|
87
|
-
|
|
66
|
+
const emitPlain = (line: string, num?: number): void => {
|
|
88
67
|
if (stopped) return;
|
|
89
68
|
const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
|
|
90
69
|
if (outBytes + lineBytes > maxBytes) {
|
|
@@ -94,15 +73,16 @@ export function genDiff(
|
|
|
94
73
|
}
|
|
95
74
|
outBytes += lineBytes;
|
|
96
75
|
output.push(line);
|
|
76
|
+
lineNumbers.push(num);
|
|
97
77
|
};
|
|
98
78
|
|
|
99
|
-
|
|
79
|
+
const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
|
|
100
80
|
if (stopped) return;
|
|
101
81
|
const full = fmtDiffLine(prefix, line, hash);
|
|
102
82
|
const rowBytes = Buffer.byteLength(full, "utf-8");
|
|
103
83
|
if (rowBytes > maxLineBytes) {
|
|
104
84
|
const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
105
|
-
emitPlain(fmtDiffLine(prefix, marker, hash));
|
|
85
|
+
emitPlain(fmtDiffLine(prefix, marker, hash), num);
|
|
106
86
|
return;
|
|
107
87
|
}
|
|
108
88
|
if (outBytes + rowBytes + 1 > maxBytes) {
|
|
@@ -112,6 +92,7 @@ export function genDiff(
|
|
|
112
92
|
}
|
|
113
93
|
outBytes += rowBytes + 1;
|
|
114
94
|
output.push(full);
|
|
95
|
+
lineNumbers.push(num);
|
|
115
96
|
};
|
|
116
97
|
|
|
117
98
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -127,11 +108,11 @@ export function genDiff(
|
|
|
127
108
|
if (stopped) break;
|
|
128
109
|
if (part.added) {
|
|
129
110
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
130
|
-
emitRow("+", displayLines[k]!, hash);
|
|
111
|
+
emitRow("+", displayLines[k]!, hash, newLineNum);
|
|
131
112
|
newLineNum++;
|
|
132
113
|
} else {
|
|
133
114
|
const hash = oldContentHashes?.[oldLineNum - 1];
|
|
134
|
-
emitRow("-", displayLines[k]!, hash);
|
|
115
|
+
emitRow("-", displayLines[k]!, hash, oldLineNum);
|
|
135
116
|
oldLineNum++;
|
|
136
117
|
}
|
|
137
118
|
}
|
|
@@ -198,25 +179,25 @@ export function genDiff(
|
|
|
198
179
|
}
|
|
199
180
|
|
|
200
181
|
if (skipStart > 0) {
|
|
201
|
-
emitPlain(" ...");
|
|
182
|
+
emitPlain(" ...", undefined);
|
|
202
183
|
newLineNum += skipStart;
|
|
203
184
|
oldLineNum += skipStart;
|
|
204
185
|
}
|
|
205
186
|
for (const line of linesToShow) {
|
|
206
187
|
if (stopped) break;
|
|
207
188
|
if (isEllipsisMarker(line)) {
|
|
208
|
-
emitPlain(" ...");
|
|
189
|
+
emitPlain(" ...", undefined);
|
|
209
190
|
newLineNum += skipMiddle;
|
|
210
191
|
oldLineNum += skipMiddle;
|
|
211
192
|
continue;
|
|
212
193
|
}
|
|
213
194
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
214
|
-
emitRow(" ", line, hash);
|
|
195
|
+
emitRow(" ", line, hash, newLineNum);
|
|
215
196
|
newLineNum++;
|
|
216
197
|
oldLineNum++;
|
|
217
198
|
}
|
|
218
199
|
if (skipTail > 0) {
|
|
219
|
-
emitPlain(" ...");
|
|
200
|
+
emitPlain(" ...", undefined);
|
|
220
201
|
}
|
|
221
202
|
} else {
|
|
222
203
|
newLineNum += displayLines.length;
|
|
@@ -227,10 +208,12 @@ export function genDiff(
|
|
|
227
208
|
|
|
228
209
|
if (diffTruncated) {
|
|
229
210
|
output.push(" ...");
|
|
211
|
+
lineNumbers.push(undefined);
|
|
230
212
|
output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
|
|
213
|
+
lineNumbers.push(undefined);
|
|
231
214
|
}
|
|
232
215
|
|
|
233
|
-
return { diff: output.join("\n"), firstChangedLine };
|
|
216
|
+
return { diff: output.join("\n"), firstChangedLine, lineNumbers };
|
|
234
217
|
}
|
|
235
218
|
|
|
236
219
|
export function genPatch(
|
|
@@ -239,10 +222,10 @@ export function genPatch(
|
|
|
239
222
|
newContent: string,
|
|
240
223
|
limits?: DiffLimits,
|
|
241
224
|
): { patch: string; truncated: boolean } {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
225
|
+
const patchOpts: Record<string, unknown> = { context: 4 };
|
|
226
|
+
const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
|
|
227
|
+
if (ho !== undefined) patchOpts.headerOptions = ho;
|
|
228
|
+
const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
|
|
246
229
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
247
230
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
248
231
|
const out: string[] = [];
|
package/src/replace-render.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
2
|
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
3
|
+
import type { ReplaceDetails } from "./replace";
|
|
4
|
+
import { withLineNumbers } from "./utils";
|
|
5
|
+
import { getPreviewInput } from "./payload-contract";
|
|
6
|
+
export { getPreviewInput };
|
|
6
7
|
|
|
7
8
|
export type FgT = Pick<Theme, "fg">;
|
|
8
9
|
export type CallT = Pick<Theme, "fg" | "bold">;
|
|
@@ -20,42 +21,12 @@ export type RRState = {
|
|
|
20
21
|
previewTimer?: ReturnType<typeof setTimeout>;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
export function getPreviewInput(
|
|
24
|
-
args: unknown,
|
|
25
|
-
): ReqParams | null {
|
|
26
|
-
let normalized: unknown;
|
|
27
|
-
try {
|
|
28
|
-
normalized = normReq(args);
|
|
29
|
-
} catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
if (!isRec(normalized) || typeof normalized.path !== "string") {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
typeof normalized.remove_from !== "string" ||
|
|
38
|
-
typeof normalized.remove_to !== "string" ||
|
|
39
|
-
!Array.isArray(normalized.replacement_lines) ||
|
|
40
|
-
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
41
|
-
) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const request: ReqParams = {
|
|
46
|
-
path: normalized.path,
|
|
47
|
-
remove_from: normalized.remove_from,
|
|
48
|
-
remove_to: normalized.remove_to,
|
|
49
|
-
replacement_lines: normalized.replacement_lines,
|
|
50
|
-
};
|
|
51
|
-
return request;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
24
|
type DiffRowKind = "added" | "removed" | "context";
|
|
55
25
|
|
|
56
26
|
function diffRowKind(line: string): DiffRowKind {
|
|
57
|
-
|
|
58
|
-
if (
|
|
27
|
+
const stripped = line.replace(/^\s*\d+\s+│\s*/, "");
|
|
28
|
+
if (stripped.startsWith("+") && !stripped.startsWith("+++")) return "added";
|
|
29
|
+
if (stripped.startsWith("-") && !stripped.startsWith("---")) return "removed";
|
|
59
30
|
return "context";
|
|
60
31
|
}
|
|
61
32
|
|
|
@@ -67,6 +38,9 @@ export function colorLines(lines: string[], theme: FgT): string[] {
|
|
|
67
38
|
return theme.fg("dim", line);
|
|
68
39
|
});
|
|
69
40
|
}
|
|
41
|
+
export function toNumberedDiff(diff: string, lineNumbers: (number|undefined)[]): string {
|
|
42
|
+
return withLineNumbers(diff, lineNumbers);
|
|
43
|
+
}
|
|
70
44
|
|
|
71
45
|
export function fmtPreview(
|
|
72
46
|
diff: string,
|
|
@@ -173,10 +147,11 @@ export function buildAppliedText(
|
|
|
173
147
|
const summary = extractSummary(text);
|
|
174
148
|
if (summary) sections.push(summary);
|
|
175
149
|
if (details?.diff) {
|
|
150
|
+
const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
|
|
176
151
|
const diffLines = details.diff.split("\n");
|
|
177
152
|
const diffSection = expanded
|
|
178
|
-
? fmtResult(
|
|
179
|
-
: fmtPreview(
|
|
153
|
+
? fmtResult(rawDiff, theme)
|
|
154
|
+
: fmtPreview(rawDiff, false, theme);
|
|
180
155
|
const hint =
|
|
181
156
|
!expanded && diffLines.length > RESULT_PREVIEW_LINES
|
|
182
157
|
? ` (${expandHint()})`
|
|
@@ -322,10 +297,12 @@ export function makeRenderCall(
|
|
|
322
297
|
|
|
323
298
|
export function renderEditResult(
|
|
324
299
|
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
325
|
-
isPartial: boolean,
|
|
300
|
+
options: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
326
301
|
theme: FgT,
|
|
327
302
|
context: any,
|
|
328
303
|
): Text | Markdown {
|
|
304
|
+
const isPartial = typeof options === "boolean" ? options : options.isPartial;
|
|
305
|
+
const optionsExpanded = typeof options === "boolean" ? undefined : options.expanded;
|
|
329
306
|
if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
|
|
330
307
|
const renderedText = getResultText(result);
|
|
331
308
|
const renderState = context.state as RRState | undefined;
|
|
@@ -343,7 +320,8 @@ export function renderEditResult(
|
|
|
343
320
|
: new Text("", 0, 0);
|
|
344
321
|
}
|
|
345
322
|
if (isApplied(result.details)) {
|
|
346
|
-
const
|
|
323
|
+
const isExpanded = optionsExpanded === true || context.expanded === true;
|
|
324
|
+
const appliedText = buildAppliedText(renderedText, result.details, theme, isExpanded);
|
|
347
325
|
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
348
326
|
}
|
|
349
327
|
if (!renderedText) return new Text("", 0, 0);
|
package/src/replace-response.ts
CHANGED