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.
@@ -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 hash alone (e.g. "aB3"): no line numbers or trailing content.`;
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 hash, drop everything from "│" onward.`;
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").`;
@@ -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 hash).`,
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 hash).`,
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 hashes from read output).`,
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
- : '"HASH│" prefix';
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 "HASH│" prefix from ${locations}.${guidance}`
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 canonLines = fileLines.map((line) => canon(line));
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 { resolveTarget } from "./fs-write";
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 "./replace-normalize";
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 hash from read output).');
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 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.',
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
- 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 };
122
+ return previewFromPipe(pipe);
128
123
  } catch (error: unknown) {
129
- return { error: error instanceof Error ? error.message : String(error) };
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 = toCwd(path, ctx.cwd);
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 = MAX_READ_LINE_BYTES,
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
- if (!oversizedIndexes.has(index)) servedHashes.push(selectedHashes[index]!);
113
+ servedHashes.push(selectedHashes[index]!);
113
114
  }
114
- const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
115
- const verb = oversized.length === 1 ? "exceeds" : "exceed";
116
- const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
117
- const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
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
  : {}),
@@ -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
- return content.indexOf("\r") >= 0 ? "\r" : "\n";
14
- }
15
- const crlfIdx = content.indexOf("\r\n");
16
- if (crlfIdx === -1) return "\n";
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
- ): { diff: string; firstChangedLine: number | undefined } {
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
- output.push(fmtDiffLine("+", displayLines[k]!, hash));
133
+ emitRow("+", displayLines[k]!, hash, newLineNum);
86
134
  newLineNum++;
87
135
  } else {
88
136
  const hash = oldContentHashes?.[oldLineNum - 1];
89
- output.push(fmtDiffLine("-", displayLines[k]!, hash));
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
- output.push(" ...");
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
- output.push(" ...");
211
+ emitPlain(" ...", undefined);
162
212
  newLineNum += skipMiddle;
163
213
  oldLineNum += skipMiddle;
164
214
  continue;
165
215
  }
166
216
  const hash = effectiveNewHashes[newLineNum - 1];
167
- output.push(fmtDiffLine(" ", line, hash));
217
+ emitRow(" ", line, hash, newLineNum);
168
218
  newLineNum++;
169
219
  oldLineNum++;
170
220
  }
171
221
  if (skipTail > 0) {
172
- output.push(" ...");
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
- return { diff: output.join("\n"), firstChangedLine };
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
- ): string {
189
- return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
190
- context: 4,
191
- headerOptions: Diff.FILE_HEADERS_ONLY,
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
  }