pi-hashline-edit-pro 2.7.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -34
- package/index.ts +11 -9
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +3 -6
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -6
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +2 -2
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -8
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -3
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/constants.ts +4 -2
- package/src/file-kind.ts +8 -6
- package/src/file-reader.ts +25 -11
- package/src/fs-write.ts +28 -2
- package/src/grep.ts +137 -43
- package/src/hash-store.ts +57 -17
- package/src/hashline/hash.ts +16 -9
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +17 -3
- package/src/hashline/resolve.ts +9 -8
- package/src/insert.ts +11 -16
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +32 -11
- package/src/replace-diff.ts +108 -19
- package/src/replace-render.ts +47 -43
- package/src/replace-response.ts +4 -1
- package/src/replace-undo.ts +17 -12
- package/src/replace.ts +44 -106
- package/src/served.ts +26 -5
- package/src/utils.ts +71 -0
- package/src/write-hook.ts +59 -0
- package/src/replace-normalize.ts +0 -13
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 {
|
|
@@ -14,11 +17,22 @@ function diagRef(ref: string): string {
|
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
if (/^\d+/.test(trimmed)) {
|
|
17
|
-
return `[E_BAD_REF] Invalid anchor. Use the
|
|
20
|
+
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
|
|
21
|
+
}
|
|
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.`;
|
|
18
33
|
}
|
|
19
|
-
|
|
20
34
|
if (trimmed.includes("│")) {
|
|
21
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char
|
|
35
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
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";
|
|
@@ -152,12 +152,12 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
152
152
|
|
|
153
153
|
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
154
154
|
throw new Error(
|
|
155
|
-
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char
|
|
155
|
+
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char anchor).`,
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
if ("remove_to" in edit && typeof edit.remove_to !== "string") {
|
|
159
159
|
throw new Error(
|
|
160
|
-
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char
|
|
160
|
+
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char anchor).`,
|
|
161
161
|
);
|
|
162
162
|
}
|
|
163
163
|
if (!("replacement_lines" in edit)) {
|
|
@@ -168,7 +168,7 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
168
168
|
}
|
|
169
169
|
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
170
170
|
throw new Error(
|
|
171
|
-
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char
|
|
171
|
+
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char anchors from read output).`,
|
|
172
172
|
);
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -187,7 +187,7 @@ export function stripAnchorRow(
|
|
|
187
187
|
? "diff-preview marker"
|
|
188
188
|
: match[1] === "-"
|
|
189
189
|
? 'leading "-" marker'
|
|
190
|
-
: '"
|
|
190
|
+
: '"anchor│" prefix';
|
|
191
191
|
warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
|
|
192
192
|
return match[2]!;
|
|
193
193
|
}
|
|
@@ -239,7 +239,7 @@ export function stripBarePrefixes(
|
|
|
239
239
|
? " Verify it was pasted from read output."
|
|
240
240
|
: "";
|
|
241
241
|
warnings.push(
|
|
242
|
-
`[E_BARE_HASH_PREFIX] Stripped "
|
|
242
|
+
`[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.${guidance}`
|
|
243
243
|
);
|
|
244
244
|
return { ...edit, content_lines: contentLines };
|
|
245
245
|
}
|
|
@@ -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),
|
|
@@ -546,7 +547,7 @@ export function assertRangeServed(
|
|
|
546
547
|
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
547
548
|
const hash = fileHashes[line - 1]!;
|
|
548
549
|
shownHashes.push(hash);
|
|
549
|
-
rows.push(fmtRow(hash, fileLines[line - 1]));
|
|
550
|
+
rows.push(fmtRow(hash, clipLine(fileLines[line - 1])));
|
|
550
551
|
}
|
|
551
552
|
const location = filePath ? ` in ${filePath}` : "";
|
|
552
553
|
const first = mismatchLines[0]!;
|
package/src/insert.ts
CHANGED
|
@@ -2,16 +2,14 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
|
|
|
2
2
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { constants } from "fs";
|
|
5
|
-
import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
|
|
5
|
+
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
6
6
|
import { commitEdit } from "./commit";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import {
|
|
8
|
+
import { resolveInCwd } from "./fs-write";
|
|
9
9
|
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
10
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
|
-
import { toCwd } from "./paths";
|
|
12
11
|
import { loadP, loadGuide } from "./prompts";
|
|
13
|
-
import { normReq } from "./
|
|
14
|
-
import { genDiff } from "./replace-diff";
|
|
12
|
+
import { normReq } from "./payload-contract";
|
|
15
13
|
import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
|
|
16
14
|
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
17
15
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
@@ -34,7 +32,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
34
32
|
throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
|
|
35
33
|
}
|
|
36
34
|
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
37
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char
|
|
35
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char anchor from read output).');
|
|
38
36
|
}
|
|
39
37
|
if (request.direction !== "before" && request.direction !== "after") {
|
|
40
38
|
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
@@ -52,7 +50,7 @@ const insertToolSchema = Type.Object(
|
|
|
52
50
|
}),
|
|
53
51
|
anchor: Type.String({
|
|
54
52
|
description:
|
|
55
|
-
'Bare 3-char
|
|
53
|
+
'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. A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
|
|
56
54
|
}),
|
|
57
55
|
direction: Type.Union(
|
|
58
56
|
[
|
|
@@ -121,12 +119,9 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
|
|
|
121
119
|
preloadedNorm: preload,
|
|
122
120
|
skipBoundaryDedup: true,
|
|
123
121
|
});
|
|
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 };
|
|
122
|
+
return previewFromPipe(pipe);
|
|
128
123
|
} catch (error: unknown) {
|
|
129
|
-
return
|
|
124
|
+
return previewError(error);
|
|
130
125
|
}
|
|
131
126
|
}
|
|
132
127
|
|
|
@@ -164,16 +159,17 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
164
159
|
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
165
160
|
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
161
|
prepareArguments: makePrepareArguments(),
|
|
162
|
+
executionMode: "sequential",
|
|
167
163
|
parameters: insertToolSchema,
|
|
168
164
|
renderShell: "default",
|
|
169
165
|
renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
|
|
170
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
166
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
171
167
|
return renderEditResult(
|
|
172
168
|
result as {
|
|
173
169
|
content?: Array<{ type: string; text?: string }>;
|
|
174
170
|
details?: ReplaceDetails;
|
|
175
171
|
},
|
|
176
|
-
isPartial,
|
|
172
|
+
{ isPartial, expanded },
|
|
177
173
|
theme,
|
|
178
174
|
context,
|
|
179
175
|
);
|
|
@@ -185,8 +181,7 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
185
181
|
const req = canonical;
|
|
186
182
|
const path = req.path;
|
|
187
183
|
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
188
|
-
const absolutePath =
|
|
189
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
184
|
+
const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
190
185
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
191
186
|
abortIf(signal);
|
|
192
187
|
const preload = await readNormFile(path, ctx.cwd, {
|
|
@@ -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
|
@@ -3,19 +3,21 @@ import {
|
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
5
|
truncateHead,
|
|
6
|
+
DEFAULT_MAX_BYTES,
|
|
6
7
|
DEFAULT_MAX_LINES,
|
|
7
8
|
type TruncationResult,
|
|
8
9
|
} from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { Type } from "typebox";
|
|
10
|
-
import { MAX_READ_LINE_BYTES } from "./constants";
|
|
11
11
|
import { loadFileKindAndText } from "./file-kind";
|
|
12
|
+
import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
|
|
12
13
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
13
|
-
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
|
+
import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
15
|
import { toCwd } from "./paths";
|
|
15
|
-
import { abortIf, makePrepareArguments, visLines } from "./utils";
|
|
16
|
+
import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
|
|
16
17
|
import { recordServedSafe } from "./served";
|
|
17
18
|
import { loadP, loadGuide } from "./prompts";
|
|
18
19
|
import { valAccess } from "./validation";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
19
21
|
|
|
20
22
|
const R_DESC = loadP("../prompts/read.md");
|
|
21
23
|
|
|
@@ -56,7 +58,7 @@ export async function fmtReadPreview(
|
|
|
56
58
|
options: { offset?: number; limit?: number },
|
|
57
59
|
precomputedHashes?: string[],
|
|
58
60
|
path?: string,
|
|
59
|
-
maxLineBytes =
|
|
61
|
+
maxLineBytes = DEFAULT_MAX_BYTES,
|
|
60
62
|
maxTruncLines = DEFAULT_MAX_LINES,
|
|
61
63
|
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number; servedHashes: string[] }> {
|
|
62
64
|
const allLines = visLines(text);
|
|
@@ -100,21 +102,27 @@ export async function fmtReadPreview(
|
|
|
100
102
|
const oversized = rowSizes.filter((row) => row.bytes > maxBytes);
|
|
101
103
|
const rows = rowSizes.map((row, index) =>
|
|
102
104
|
row.bytes > maxBytes
|
|
103
|
-
? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
|
|
105
|
+
? fmtRow(selectedHashes[index]!, `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`)
|
|
104
106
|
: fmtRegion([selectedHashes[index]!], [selected[index]!]),
|
|
105
107
|
);
|
|
106
108
|
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
|
|
107
109
|
const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
|
|
108
110
|
const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
|
|
109
|
-
const oversizedIndexes = new Set(rowSizes.map((row, index) => row.bytes > maxBytes ? index : -1).filter((index) => index >= 0));
|
|
110
111
|
const servedHashes: string[] = [];
|
|
111
112
|
for (let index = 0; index < Math.min(shownRowCount, rows.length); index++) {
|
|
112
|
-
|
|
113
|
+
servedHashes.push(selectedHashes[index]!);
|
|
113
114
|
}
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
115
|
+
const listed = oversized.slice(0, MAX_OVERSIZED_WARNING_LINES);
|
|
116
|
+
const hiddenCount = oversized.length - listed.length;
|
|
117
|
+
const lineLabel = oversized.length === 1
|
|
118
|
+
? `Line ${oversized[0]!.lineNumber}`
|
|
119
|
+
: `Lines ${listed.map((row) => row.lineNumber).join(', ')}${hiddenCount > 0 ? `, ... (+${hiddenCount} more)` : ''}`;
|
|
120
|
+
const verb = oversized.length === 1 ? 'exceeds' : 'exceed';
|
|
121
|
+
const addresses = listed.map((row) => `${row.lineNumber}p`).join(';');
|
|
122
|
+
const moreHint = hiddenCount > 0
|
|
123
|
+
? ` ${hiddenCount} more oversized line(s). Use read with offset to inspect them.`
|
|
124
|
+
: '';
|
|
125
|
+
const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}${moreHint}]`;
|
|
118
126
|
let preview = skippedTruncation.content;
|
|
119
127
|
let nextOffset: number | undefined;
|
|
120
128
|
if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
|
|
@@ -183,6 +191,18 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
183
191
|
}),
|
|
184
192
|
),
|
|
185
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
|
+
},
|
|
186
206
|
|
|
187
207
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
188
208
|
const rawPath = params.path;
|
|
@@ -228,6 +248,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
228
248
|
details: {
|
|
229
249
|
truncation: preview.truncation,
|
|
230
250
|
snapshotId,
|
|
251
|
+
offset: params.offset ?? 1,
|
|
231
252
|
...(preview.nextOffset !== undefined
|
|
232
253
|
? { nextOffset: preview.nextOffset }
|
|
233
254
|
: {}),
|
package/src/replace-diff.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Diff from "diff";
|
|
2
|
+
import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import {
|
|
3
4
|
_lineHashesPure,
|
|
4
5
|
ANCHOR_LEN,
|
|
@@ -8,13 +9,13 @@ import {
|
|
|
8
9
|
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
9
10
|
|
|
10
11
|
export function detectEnding(content: string): LineEnding {
|
|
12
|
+
const crIdx = content.indexOf("\r");
|
|
11
13
|
const lfIdx = content.indexOf("\n");
|
|
12
|
-
if (lfIdx === -1)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
14
|
+
if (crIdx === -1 && lfIdx === -1) return "\n";
|
|
15
|
+
if (crIdx === -1) return "\n";
|
|
16
|
+
if (lfIdx === -1) return "\r";
|
|
17
|
+
if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
|
|
18
|
+
return "\n";
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function toLF(text: string): string {
|
|
@@ -55,14 +56,23 @@ const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
|
|
|
55
56
|
const isEllipsisMarker = (line: string | symbol): line is symbol =>
|
|
56
57
|
line === ELLIPSIS_MARKER;
|
|
57
58
|
|
|
59
|
+
export interface DiffLimits {
|
|
60
|
+
maxLineBytes?: number;
|
|
61
|
+
maxBytes?: number;
|
|
62
|
+
unlimited?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
export function genDiff(
|
|
59
66
|
oldContent: string,
|
|
60
67
|
newContent: string,
|
|
61
68
|
contextLines = 2,
|
|
62
69
|
newContentHashes?: string[],
|
|
63
70
|
oldContentHashes?: string[],
|
|
64
|
-
|
|
71
|
+
limits?: DiffLimits,
|
|
72
|
+
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
|
|
65
73
|
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
74
|
+
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
75
|
+
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
66
76
|
|
|
67
77
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
68
78
|
const output: string[] = [];
|
|
@@ -70,8 +80,45 @@ export function genDiff(
|
|
|
70
80
|
let oldLineNum = 1;
|
|
71
81
|
let lastWasChange = false;
|
|
72
82
|
let firstChangedLine: number | undefined;
|
|
83
|
+
let outBytes = 0;
|
|
84
|
+
let stopped = false;
|
|
85
|
+
let diffTruncated = false;
|
|
86
|
+
const lineNumbers: (number|undefined)[] = [];
|
|
87
|
+
|
|
88
|
+
const emitPlain = (line: string, num?: number): void => {
|
|
89
|
+
if (stopped) return;
|
|
90
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
|
|
91
|
+
if (outBytes + lineBytes > maxBytes) {
|
|
92
|
+
stopped = true;
|
|
93
|
+
diffTruncated = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
outBytes += lineBytes;
|
|
97
|
+
output.push(line);
|
|
98
|
+
lineNumbers.push(num);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
|
|
102
|
+
if (stopped) return;
|
|
103
|
+
const full = fmtDiffLine(prefix, line, hash);
|
|
104
|
+
const rowBytes = Buffer.byteLength(full, "utf-8");
|
|
105
|
+
if (rowBytes > maxLineBytes) {
|
|
106
|
+
const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
107
|
+
emitPlain(fmtDiffLine(prefix, marker, hash), num);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (outBytes + rowBytes + 1 > maxBytes) {
|
|
111
|
+
stopped = true;
|
|
112
|
+
diffTruncated = true;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
outBytes += rowBytes + 1;
|
|
116
|
+
output.push(full);
|
|
117
|
+
lineNumbers.push(num);
|
|
118
|
+
};
|
|
73
119
|
|
|
74
120
|
for (let i = 0; i < parts.length; i++) {
|
|
121
|
+
if (stopped) break;
|
|
75
122
|
const part = parts[i]!;
|
|
76
123
|
const raw = part.value.split("\n");
|
|
77
124
|
if (raw[raw.length - 1] === "") raw.pop();
|
|
@@ -80,16 +127,18 @@ export function genDiff(
|
|
|
80
127
|
if (part.added || part.removed) {
|
|
81
128
|
if (firstChangedLine === undefined) firstChangedLine = newLineNum;
|
|
82
129
|
for (let k = 0; k < displayLines.length; k++) {
|
|
130
|
+
if (stopped) break;
|
|
83
131
|
if (part.added) {
|
|
84
132
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
85
|
-
|
|
133
|
+
emitRow("+", displayLines[k]!, hash, newLineNum);
|
|
86
134
|
newLineNum++;
|
|
87
135
|
} else {
|
|
88
136
|
const hash = oldContentHashes?.[oldLineNum - 1];
|
|
89
|
-
|
|
137
|
+
emitRow("-", displayLines[k]!, hash, oldLineNum);
|
|
90
138
|
oldLineNum++;
|
|
91
139
|
}
|
|
92
140
|
}
|
|
141
|
+
if (stopped) break;
|
|
93
142
|
lastWasChange = true;
|
|
94
143
|
continue;
|
|
95
144
|
}
|
|
@@ -152,24 +201,25 @@ export function genDiff(
|
|
|
152
201
|
}
|
|
153
202
|
|
|
154
203
|
if (skipStart > 0) {
|
|
155
|
-
|
|
204
|
+
emitPlain(" ...", undefined);
|
|
156
205
|
newLineNum += skipStart;
|
|
157
206
|
oldLineNum += skipStart;
|
|
158
207
|
}
|
|
159
208
|
for (const line of linesToShow) {
|
|
209
|
+
if (stopped) break;
|
|
160
210
|
if (isEllipsisMarker(line)) {
|
|
161
|
-
|
|
211
|
+
emitPlain(" ...", undefined);
|
|
162
212
|
newLineNum += skipMiddle;
|
|
163
213
|
oldLineNum += skipMiddle;
|
|
164
214
|
continue;
|
|
165
215
|
}
|
|
166
216
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
167
|
-
|
|
217
|
+
emitRow(" ", line, hash, newLineNum);
|
|
168
218
|
newLineNum++;
|
|
169
219
|
oldLineNum++;
|
|
170
220
|
}
|
|
171
221
|
if (skipTail > 0) {
|
|
172
|
-
|
|
222
|
+
emitPlain(" ...", undefined);
|
|
173
223
|
}
|
|
174
224
|
} else {
|
|
175
225
|
newLineNum += displayLines.length;
|
|
@@ -178,16 +228,55 @@ export function genDiff(
|
|
|
178
228
|
lastWasChange = false;
|
|
179
229
|
}
|
|
180
230
|
|
|
181
|
-
|
|
231
|
+
if (diffTruncated) {
|
|
232
|
+
output.push(" ...");
|
|
233
|
+
lineNumbers.push(undefined);
|
|
234
|
+
output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
|
|
235
|
+
lineNumbers.push(undefined);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { diff: output.join("\n"), firstChangedLine, lineNumbers };
|
|
182
239
|
}
|
|
183
240
|
|
|
184
241
|
export function genPatch(
|
|
185
242
|
path: string,
|
|
186
243
|
oldContent: string,
|
|
187
244
|
newContent: string,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
245
|
+
limits?: DiffLimits,
|
|
246
|
+
): { patch: string; truncated: boolean } {
|
|
247
|
+
const patchOpts: Record<string, unknown> = { context: 4 };
|
|
248
|
+
const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
|
|
249
|
+
if (ho !== undefined) patchOpts.headerOptions = ho;
|
|
250
|
+
const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
|
|
251
|
+
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
252
|
+
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
253
|
+
const out: string[] = [];
|
|
254
|
+
let outBytes = 0;
|
|
255
|
+
let truncated = false;
|
|
256
|
+
for (const line of full.split("\n")) {
|
|
257
|
+
const lineBytes = Buffer.byteLength(line, "utf-8");
|
|
258
|
+
if (lineBytes > maxLineBytes) {
|
|
259
|
+
truncated = true;
|
|
260
|
+
const prefix = /^[ +-]/.test(line) ? line[0]! : "";
|
|
261
|
+
const marker = `${prefix}[Patch line is ${formatSize(lineBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
262
|
+
const markerBytes = Buffer.byteLength(marker, "utf-8") + 1;
|
|
263
|
+
if (outBytes + markerBytes > maxBytes) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
outBytes += markerBytes;
|
|
267
|
+
out.push(marker);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (outBytes + lineBytes + 1 > maxBytes) {
|
|
271
|
+
truncated = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
outBytes += lineBytes + 1;
|
|
275
|
+
out.push(line);
|
|
276
|
+
}
|
|
277
|
+
if (truncated) {
|
|
278
|
+
out.push("...");
|
|
279
|
+
out.push(`[patch truncated at ${formatSize(maxBytes)}; the patch cannot be applied as-is. Use read to see the full file.]`);
|
|
280
|
+
}
|
|
281
|
+
return { patch: out.join("\n"), truncated };
|
|
193
282
|
}
|