pi-hashline-edit-pro 4.2.10 → 4.3.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,10 +1,8 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached, decodeStringArray, assertNoNul } from "../utils";
2
2
  import { parseHashRef, parseText, type Anchor } from "./parse";
3
- import { HASH_SEP, stripRowPrefix, canon } from "./hash";
3
+ import { HASH_SEP, stripRowPrefix, canon, lineChecksum, type RowPrefixKind } from "./hash";
4
4
  import { HASH_RUN } from "./alphabet";
5
5
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
6
- import { contentChecksum } from "./hasher";
7
- import { hashSource } from "./hash";
8
6
 
9
7
  export type RAnchor = {
10
8
  line: number;
@@ -110,7 +108,7 @@ export function fmtMismatchWithHashes(
110
108
  const h = fileHashes[ln - 1]!;
111
109
  const c = fileLines[ln - 1] ?? "";
112
110
  hashes.push(h);
113
- servedMap.set(h, contentChecksum(hashSource(c)));
111
+ servedMap.set(h, lineChecksum(c));
114
112
  rows.push(` ${ln}: ${h}│${clipLine(c)}`);
115
113
  }
116
114
  out.push("");
@@ -171,7 +169,7 @@ export function stripAnchorRow(
171
169
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
172
170
  assertItem(edit as Record<string, unknown>);
173
171
 
174
- const replaceLines = parseText(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines, warnings);
172
+ const replaceLines = parseText(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
175
173
  assertNoNul(replaceLines);
176
174
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
177
175
  return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
@@ -193,52 +191,37 @@ function warnUnicodeEsc(
193
191
  }
194
192
  }
195
193
 
196
- export function stripBarePrefixes(
197
- edit: HEdit,
198
- fileHashes: string[],
199
- warnings: string[],
200
- ): HEdit {
201
- const fileHashSet = new Set(fileHashes);
202
- const stripped: { lineIndex: number; matched: boolean }[] = [];
203
- const contentLines = edit.content_lines.map((line, lineIndex) => {
204
- const result = stripRowPrefix(line);
205
- if (result.kind !== "bare") return line;
206
- stripped.push({ lineIndex, matched: fileHashSet.has(result.hash ?? "") });
207
- return result.text;
208
- });
209
- if (stripped.length === 0) return edit;
210
- const locations = stripped
211
- .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
212
- .join(", ");
213
- warnings.push(
214
- `[W_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.`
215
- );
216
- return { ...edit, content_lines: contentLines };
217
- }
218
-
219
- export function stripDiffPrefixes(
194
+ function stripRowPrefixes(
220
195
  edit: HEdit,
221
196
  warnings: string[],
197
+ kinds: RowPrefixKind[],
198
+ code: string,
199
+ marker: string,
222
200
  ): HEdit {
223
201
  const stripped: number[] = [];
224
202
  const contentLines = edit.content_lines.map((line, lineIndex) => {
225
203
  const result = stripRowPrefix(line);
226
- if (result.kind !== "plus" && result.kind !== "minus") return line;
204
+ if (result.kind === null || !kinds.includes(result.kind)) return line;
227
205
  stripped.push(lineIndex);
228
206
  return result.text;
229
207
  });
230
208
  if (stripped.length === 0) return edit;
231
209
  const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
232
- warnings.push(
233
- `[W_INVALID_PATCH] Stripped diff-preview marker from ${locations}.`
234
- );
210
+ warnings.push(`${code} Stripped ${marker} from ${locations}.`);
235
211
  return { ...edit, content_lines: contentLines };
236
212
  }
237
213
 
214
+ export function stripBarePrefixes(edit: HEdit, warnings: string[]): HEdit {
215
+ return stripRowPrefixes(edit, warnings, ["bare"], "[W_BARE_HASH_PREFIX]", '"anchor│" prefix');
216
+ }
217
+
218
+ export function stripDiffPrefixes(edit: HEdit, warnings: string[]): HEdit {
219
+ return stripRowPrefixes(edit, warnings, ["plus", "minus"], "[W_INVALID_PATCH]", "diff-preview marker");
220
+ }
221
+
238
222
  export function swapReversedRanges(
239
223
  edit: HEdit,
240
224
  fileHashes: string[],
241
- warnings: string[],
242
225
  ): HEdit {
243
226
  const lineByHash = new Map<string, number>();
244
227
  for (let i = 0; i < fileHashes.length; i++) {
@@ -254,9 +237,6 @@ export function swapReversedRanges(
254
237
  ) {
255
238
  return edit;
256
239
  }
257
- warnings.push(
258
- `[W_BAD_OP] Swapped reversed remove_from/remove_to.`
259
- );
260
240
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
261
241
  }
262
242
 
@@ -515,7 +495,7 @@ export function assertRangeServed(
515
495
  const hash = fileHashes[line - 1]!;
516
496
  const content = fileLines[line - 1]!;
517
497
  const servedContent = served?.get(hash);
518
- if (servedContent === undefined || servedContent !== contentChecksum(hashSource(content))) mismatchLines.push(line);
498
+ if (servedContent === undefined || servedContent !== lineChecksum(content)) mismatchLines.push(line);
519
499
  }
520
500
  if (mismatchLines.length === 0) return;
521
501
  const rangeLength = endLine - startLine + 1;
@@ -527,7 +507,7 @@ export function assertRangeServed(
527
507
  const hash = fileHashes[line - 1]!;
528
508
  const content = fileLines[line - 1]!;
529
509
  shownHashes.push(hash);
530
- shownMap.set(hash, contentChecksum(hashSource(content)));
510
+ shownMap.set(hash, lineChecksum(content));
531
511
  rows.push(fmtRow(hash, clipLine(content)));
532
512
  }
533
513
  const location = filePath ? ` in ${filePath}` : "";
package/src/insert.ts CHANGED
@@ -3,7 +3,7 @@ import { Type } from "typebox";
3
3
  import { constants } from "node:fs";
4
4
  import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
- import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure, suffixPoisonCause } from "./batch";
6
+ import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
8
  import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
9
9
  import { stripAnchorRow } from "./hashline/resolve";
@@ -11,7 +11,6 @@ import { withAnchorSession } from "./anchor-registry";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
13
13
  import { decodeStringArray, isRec, splitLines } from "./utils";
14
- import { clearBoundaryBypass } from "./boundary-bypass";
15
14
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
16
15
  import type { RPreview, RRState } from "./replace-render";
17
16
  export { assertInsertReq, type InsertReq };
@@ -189,7 +188,6 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
189
188
  }).catch((error: unknown) => {
190
189
  const member = batchMemberFor(_toolCallId);
191
190
  if (member) noteBatchFailure(member, error);
192
- else suffixPoisonCause(_toolCallId, error);
193
191
  throw error;
194
192
  });
195
193
  let ref: Anchor;
@@ -218,6 +216,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
218
216
  }
219
217
  return executeBatchMember({
220
218
  kind: "insert",
219
+ direction: req.direction,
221
220
  member,
222
221
  targetPath,
223
222
  mutationTargetPath,
@@ -251,7 +250,6 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
251
250
  noopNoun: "Insertion",
252
251
  foldedAnchorLines: anchorLine === undefined ? 0 : 1,
253
252
  prefixWarnings: [...anchorWarnings, ...insertWarnings],
254
- onApplied: () => clearBoundaryBypass(mutationTargetPath),
255
253
  });
256
254
  });
257
255
  });
@@ -1,5 +1,5 @@
1
1
  import { Type } from "typebox";
2
- import { isRec, normalizeAnchors, normalizeFilePath, rejectUnknownFields, assertNoNul } from "./utils";
2
+ import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
3
3
 
4
4
  const replacementLinesSchema = Type.Array(
5
5
  Type.String({
@@ -77,20 +77,12 @@ export function assertReq(request: unknown): asserts request is ReqParams {
77
77
  assertNoNul(request.replacement_lines);
78
78
  }
79
79
 
80
- export function normReq(input: unknown): unknown {
81
- if (!isRec(input)) {
82
- return input;
83
- }
84
- const record: Record<string, unknown> = { ...input };
85
- normalizeFilePath(record);
86
- normalizeAnchors(record);
87
- return record;
88
- }
80
+ export { normalizeRequest as normReq } from "./utils";
89
81
 
90
82
  export function getPreviewInput(args: unknown): { path?: string; remove_from: string; remove_to: string; replacement_lines: string[] } | null {
91
83
  let normalized: unknown;
92
84
  try {
93
- normalized = normReq(args);
85
+ normalized = normalizeRequest(args);
94
86
  } catch {
95
87
  return null;
96
88
  }
package/src/read.ts CHANGED
@@ -17,8 +17,8 @@ import { abortIf, makePrepareArguments, numberedRead, visLines, splitLines } fro
17
17
  import { loadP, loadGuide } from "./prompts";
18
18
  import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
19
19
  import { valAccess } from "./validation";
20
- import { markServed as markServedScoped, withAnchorSession } from "./anchor-registry";
21
- import { buildServedMap } from "./served";
20
+ import { withAnchorSession } from "./anchor-registry";
21
+ import { serveRows } from "./served";
22
22
  import { Text } from "@earendil-works/pi-tui";
23
23
  const R_DESC = loadP("../prompts/read.md");
24
24
  const R_SNIPPET = loadP("../prompts/read-snippet.md");
@@ -237,7 +237,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
237
237
  fileHashes,
238
238
  resolvedPath,
239
239
  );
240
- markServedScoped(resolvedPath, buildServedMap(fileHashes, fileLines, preview.servedHashes), new Set(fileHashes));
240
+ serveRows(resolvedPath, fileHashes, fileLines, preview.servedHashes);
241
241
  const snapshotId = await safeSnapId(absolutePath, "read");
242
242
  const previewText =
243
243
  hadUtf8DecodeErrors
@@ -6,7 +6,7 @@ import {
6
6
  HASH_SEP,
7
7
  changedRange,
8
8
  } from "./hashline";
9
- import { MAX_DIFF_INPUT_BYTES } from "./constants";
9
+ import { MAX_DIFF_INPUT_BYTES, DEDUP_ANCHOR } from "./constants";
10
10
  import { splitLines } from "./utils";
11
11
  import {
12
12
  detectEnding,
@@ -22,6 +22,8 @@ export interface DiffSpan {
22
22
  start: number;
23
23
  end: number;
24
24
  replacementCount: number;
25
+ dedupAbove?: string[];
26
+ dedupBelow?: string[];
25
27
  }
26
28
 
27
29
  interface PlacedSpan {
@@ -29,6 +31,16 @@ interface PlacedSpan {
29
31
  oldEnd: number;
30
32
  newStart: number;
31
33
  newEnd: number;
34
+ dedupAbove?: string[];
35
+ dedupBelow?: string[];
36
+ }
37
+
38
+ export function fmtDedupRow(line: string): string {
39
+ const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
40
+ if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
41
+ const size = formatSize(Buffer.byteLength(row, "utf-8"));
42
+ const limit = formatSize(DEFAULT_MAX_BYTES);
43
+ return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
32
44
  }
33
45
 
34
46
  function fmtDiffLine(
@@ -155,7 +167,7 @@ function placeSpans(
155
167
  if (span.replacementCount === 0) {
156
168
  if (newEnd !== newStart - 1) return undefined;
157
169
  } else if (newEnd >= newLen) return undefined;
158
- placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd });
170
+ placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd, dedupAbove: span.dedupAbove, dedupBelow: span.dedupBelow });
159
171
  offset += span.replacementCount - spanLen;
160
172
  removedSum += spanLen;
161
173
  addedSum += span.replacementCount;
@@ -184,53 +196,32 @@ function trimPlacedSpans(
184
196
  newEnd -= 1;
185
197
  }
186
198
  if (oldStart > oldEnd && newStart > newEnd) continue;
187
- out.push({ oldStart, oldEnd, newStart, newEnd });
199
+ out.push({ oldStart, oldEnd, newStart, newEnd, dedupAbove: span.dedupAbove, dedupBelow: span.dedupBelow });
188
200
  }
189
201
  return out;
190
202
  }
191
203
 
192
- function genSpanDiff(
193
- oldContent: string,
194
- newContent: string,
195
- contextLines: number,
196
- newHashes: string[],
197
- oldHashes: string[],
204
+ function createRowEmitter(
205
+ output: string[],
206
+ lineNumbers: (number | undefined)[],
198
207
  maxLineBytes: number,
199
208
  maxBytes: number,
200
- spans: DiffSpan[],
201
- ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[] } | undefined {
202
- const oldLines = splitLines(oldContent);
203
- const newLines = splitLines(newContent);
204
- if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
205
- const placed = placeSpans(oldLines.length, newLines.length, spans);
206
- if (!placed) return undefined;
207
- const trimmed = trimPlacedSpans(placed, oldLines, newLines);
208
- const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
209
- let oldPos = 0;
210
- let newPos = 0;
211
- for (const span of ordered) {
212
- if (span.oldStart < oldPos || span.newStart < newPos) return undefined;
213
- const gapOld = span.oldStart - oldPos;
214
- const gapNew = span.newStart - newPos;
215
- if (gapOld !== gapNew || gapOld < 0) return undefined;
216
- oldPos = span.oldEnd + 1;
217
- newPos = span.newEnd + 1;
218
- }
219
- if (oldLines.length - ordered.reduce((sum, span) => sum + Math.max(0, span.oldEnd - span.oldStart + 1), 0) + ordered.reduce((sum, span) => sum + Math.max(0, span.newEnd - span.newStart + 1), 0) !== newLines.length) {
220
- return undefined;
221
- }
222
- const output: string[] = [];
223
- const lineNumbers: (number | undefined)[] = [];
224
- let firstChangedLine: number | undefined;
209
+ ): {
210
+ readonly stopped: boolean;
211
+ readonly truncated: boolean;
212
+ emitPlain(line: string, num?: number): void;
213
+ emitRow(prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void;
214
+ emitDedup(line: string): boolean;
215
+ } {
225
216
  let outBytes = 0;
226
217
  let stopped = false;
227
- let diffTruncated = false;
218
+ let truncated = false;
228
219
  const emitPlain = (line: string, num?: number): void => {
229
220
  if (stopped) return;
230
221
  const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
231
222
  if (outBytes + lineBytes > maxBytes) {
232
223
  stopped = true;
233
- diffTruncated = true;
224
+ truncated = true;
234
225
  return;
235
226
  }
236
227
  outBytes += lineBytes;
@@ -248,24 +239,82 @@ function genSpanDiff(
248
239
  }
249
240
  if (outBytes + rowBytes + 1 > maxBytes) {
250
241
  stopped = true;
251
- diffTruncated = true;
242
+ truncated = true;
252
243
  return;
253
244
  }
254
245
  outBytes += rowBytes + 1;
255
246
  output.push(full);
256
247
  lineNumbers.push(num);
257
248
  };
249
+ const emitDedup = (line: string): boolean => {
250
+ if (stopped) return false;
251
+ const row = fmtDedupRow(line);
252
+ const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
253
+ if (outBytes + rowBytes > maxBytes) {
254
+ stopped = true;
255
+ truncated = true;
256
+ return false;
257
+ }
258
+ outBytes += rowBytes;
259
+ output.push(row);
260
+ lineNumbers.push(undefined);
261
+ return true;
262
+ };
263
+ return {
264
+ get stopped() { return stopped; },
265
+ get truncated() { return truncated; },
266
+ emitPlain,
267
+ emitRow,
268
+ emitDedup,
269
+ };
270
+ }
271
+
272
+ function genSpanDiff(
273
+ oldContent: string,
274
+ newContent: string,
275
+ contextLines: number,
276
+ newHashes: string[],
277
+ oldHashes: string[],
278
+ maxLineBytes: number,
279
+ maxBytes: number,
280
+ spans: DiffSpan[],
281
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[]; dedupEmitted: boolean } | undefined {
282
+ const oldLines = splitLines(oldContent);
283
+ const newLines = splitLines(newContent);
284
+ if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
285
+ const placed = placeSpans(oldLines.length, newLines.length, spans);
286
+ if (!placed) return undefined;
287
+ const trimmed = trimPlacedSpans(placed, oldLines, newLines);
288
+ const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
289
+ let oldPos = 0;
290
+ let newPos = 0;
291
+ for (const span of ordered) {
292
+ if (span.oldStart < oldPos || span.newStart < newPos) return undefined;
293
+ const gapOld = span.oldStart - oldPos;
294
+ const gapNew = span.newStart - newPos;
295
+ if (gapOld !== gapNew || gapOld < 0) return undefined;
296
+ oldPos = span.oldEnd + 1;
297
+ newPos = span.newEnd + 1;
298
+ }
299
+ if (oldLines.length - ordered.reduce((sum, span) => sum + Math.max(0, span.oldEnd - span.oldStart + 1), 0) + ordered.reduce((sum, span) => sum + Math.max(0, span.newEnd - span.newStart + 1), 0) !== newLines.length) {
300
+ return undefined;
301
+ }
302
+ const output: string[] = [];
303
+ let dedupEmitted = false;
304
+ const lineNumbers: (number | undefined)[] = [];
305
+ let firstChangedLine: number | undefined;
306
+ const em = createRowEmitter(output, lineNumbers, maxLineBytes, maxBytes);
258
307
  const emitGap = (gapStartNew: number, gapLen: number, position: "leading" | "middle" | "trailing"): void => {
259
- if (stopped || gapLen <= 0) return;
308
+ if (em.stopped || gapLen <= 0) return;
260
309
  if (position === "leading") {
261
310
  let count = contextLines;
262
311
  if (contextLines > 0 && gapLen > count && isBlankLine(newLines[gapStartNew + gapLen - 1]!)) count += 1;
263
312
  count = Math.min(count, gapLen);
264
313
  const skipStart = gapLen - count;
265
- if (skipStart > 0) emitPlain(" ...", undefined);
266
- for (let k = gapLen - count; k < gapLen && !stopped; k++) {
314
+ if (skipStart > 0) em.emitPlain(" ...", undefined);
315
+ for (let k = gapLen - count; k < gapLen && !em.stopped; k++) {
267
316
  const newIdx = gapStartNew + k;
268
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
317
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
269
318
  }
270
319
  return;
271
320
  }
@@ -273,11 +322,11 @@ function genSpanDiff(
273
322
  let count = contextLines;
274
323
  if (contextLines > 0 && gapLen > count && isBlankLine(newLines[gapStartNew]!)) count += 1;
275
324
  count = Math.min(count, gapLen);
276
- for (let k = 0; k < count && !stopped; k++) {
325
+ for (let k = 0; k < count && !em.stopped; k++) {
277
326
  const newIdx = gapStartNew + k;
278
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
327
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
279
328
  }
280
- if (gapLen > count) emitPlain(" ...", undefined);
329
+ if (gapLen > count) em.emitPlain(" ...", undefined);
281
330
  return;
282
331
  }
283
332
  let headCount = contextLines;
@@ -286,50 +335,58 @@ function genSpanDiff(
286
335
  if (contextLines > 0 && gapLen - tailCount > headCount && isBlankLine(newLines[gapStartNew + gapLen - tailCount]!)) tailCount += 1;
287
336
  const middleLen = gapLen - headCount - tailCount;
288
337
  if (middleLen <= 0) {
289
- for (let k = 0; k < gapLen && !stopped; k++) {
338
+ for (let k = 0; k < gapLen && !em.stopped; k++) {
290
339
  const newIdx = gapStartNew + k;
291
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
340
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
292
341
  }
293
342
  return;
294
343
  }
295
- for (let k = 0; k < headCount && !stopped; k++) {
344
+ for (let k = 0; k < headCount && !em.stopped; k++) {
296
345
  const newIdx = gapStartNew + k;
297
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
346
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
298
347
  }
299
- if (!stopped) emitPlain(" ...", undefined);
300
- for (let k = gapLen - tailCount; k < gapLen && !stopped; k++) {
348
+ if (!em.stopped) em.emitPlain(" ...", undefined);
349
+ for (let k = gapLen - tailCount; k < gapLen && !em.stopped; k++) {
301
350
  const newIdx = gapStartNew + k;
302
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
351
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
303
352
  }
304
353
  };
305
354
  oldPos = 0;
306
355
  newPos = 0;
307
- for (let spanIdx = 0; spanIdx < ordered.length && !stopped; spanIdx++) {
356
+ for (let spanIdx = 0; spanIdx < ordered.length && !em.stopped; spanIdx++) {
308
357
  const span = ordered[spanIdx]!;
309
358
  const gapLen = span.oldStart - oldPos;
310
359
  emitGap(newPos, gapLen, spanIdx === 0 ? "leading" : "middle");
311
- if (stopped) break;
360
+ if (em.stopped) break;
312
361
  if (firstChangedLine === undefined) firstChangedLine = span.newStart + 1;
313
- for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !stopped; oldIdx++) {
314
- emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
362
+ for (const dedup of span.dedupAbove ?? []) {
363
+ if (em.stopped) break;
364
+ if (em.emitDedup(dedup)) dedupEmitted = true;
315
365
  }
316
- for (let newIdx = span.newStart; newIdx <= span.newEnd && !stopped; newIdx++) {
317
- emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
366
+ for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !em.stopped; oldIdx++) {
367
+ em.emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
368
+ }
369
+ for (let newIdx = span.newStart; newIdx <= span.newEnd && !em.stopped; newIdx++) {
370
+ em.emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
371
+ }
372
+ for (const dedup of span.dedupBelow ?? []) {
373
+ if (em.stopped) break;
374
+ if (em.emitDedup(dedup)) dedupEmitted = true;
318
375
  }
319
376
  oldPos = span.oldEnd + 1;
320
377
  newPos = span.newEnd + 1;
321
378
  }
322
- if (!stopped) {
379
+ if (!em.stopped) {
323
380
  const trailingLen = oldLines.length - oldPos;
324
381
  emitGap(newPos, trailingLen, "trailing");
325
382
  }
326
- if (diffTruncated) {
383
+ if (em.truncated) {
327
384
  output.push(" ...");
328
385
  lineNumbers.push(undefined);
329
386
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
330
387
  lineNumbers.push(undefined);
331
388
  }
332
- return { diff: output.join("\n"), firstChangedLine, lineNumbers };
389
+ return { diff: output.join("\n"), firstChangedLine, lineNumbers, dedupEmitted };
333
390
  }
334
391
 
335
392
  function overDiffInputLimit(oldContent: string, newContent: string): boolean {
@@ -344,14 +401,14 @@ export function genDiff(
344
401
  oldContentHashes?: string[],
345
402
  limits?: DiffLimits,
346
403
  spans?: DiffSpan[],
347
- ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
404
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[]; spanDedupEmitted?: boolean } {
348
405
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
349
406
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
350
407
  if (spans && newContentHashes && oldContentHashes) {
351
408
  const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
352
409
  if (anchored) {
353
410
  const diff = disambiguateDuplicateAnchors(anchored.diff);
354
- return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers };
411
+ return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers, ...(anchored.dedupEmitted ? { spanDedupEmitted: true as const } : {}) };
355
412
  }
356
413
  }
357
414
  if (!limits?.unlimited && overDiffInputLimit(oldContent, newContent)) {
@@ -416,45 +473,12 @@ export function genDiff(
416
473
  let oldLineNum = 1;
417
474
  let lastWasChange = false;
418
475
  let firstChangedLine: number | undefined;
419
- let outBytes = 0;
420
- let stopped = false;
421
- let diffTruncated = false;
422
476
  const lineNumbers: (number|undefined)[] = [];
423
477
 
424
- const emitPlain = (line: string, num?: number): void => {
425
- if (stopped) return;
426
- const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
427
- if (outBytes + lineBytes > maxBytes) {
428
- stopped = true;
429
- diffTruncated = true;
430
- return;
431
- }
432
- outBytes += lineBytes;
433
- output.push(line);
434
- lineNumbers.push(num);
435
- };
436
-
437
- const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
438
- if (stopped) return;
439
- const full = fmtDiffLine(prefix, line, hash);
440
- const rowBytes = Buffer.byteLength(full, "utf-8");
441
- if (rowBytes > maxLineBytes) {
442
- const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
443
- emitPlain(fmtDiffLine(prefix, marker, hash), num);
444
- return;
445
- }
446
- if (outBytes + rowBytes + 1 > maxBytes) {
447
- stopped = true;
448
- diffTruncated = true;
449
- return;
450
- }
451
- outBytes += rowBytes + 1;
452
- output.push(full);
453
- lineNumbers.push(num);
454
- };
478
+ const em = createRowEmitter(output, lineNumbers, maxLineBytes, maxBytes);
455
479
 
456
480
  for (let i = 0; i < parts.length; i++) {
457
- if (stopped) break;
481
+ if (em.stopped) break;
458
482
  const part = parts[i]!;
459
483
  const raw = part.value.split("\n");
460
484
  if (raw[raw.length - 1] === "") raw.pop();
@@ -463,18 +487,18 @@ export function genDiff(
463
487
  if (part.added || part.removed) {
464
488
  if (firstChangedLine === undefined) firstChangedLine = newLineNum;
465
489
  for (let k = 0; k < displayLines.length; k++) {
466
- if (stopped) break;
490
+ if (em.stopped) break;
467
491
  if (part.added) {
468
492
  const hash = effectiveNewHashes[newLineNum - 1];
469
- emitRow("+", displayLines[k]!, hash, newLineNum);
493
+ em.emitRow("+", displayLines[k]!, hash, newLineNum);
470
494
  newLineNum++;
471
495
  } else {
472
496
  const hash = oldContentHashes?.[oldLineNum - 1];
473
- emitRow("-", displayLines[k]!, hash, oldLineNum);
497
+ em.emitRow("-", displayLines[k]!, hash, oldLineNum);
474
498
  oldLineNum++;
475
499
  }
476
500
  }
477
- if (stopped) break;
501
+ if (em.stopped) break;
478
502
  lastWasChange = true;
479
503
  continue;
480
504
  }
@@ -537,25 +561,25 @@ export function genDiff(
537
561
  }
538
562
 
539
563
  if (skipStart > 0) {
540
- emitPlain(" ...", undefined);
564
+ em.emitPlain(" ...", undefined);
541
565
  newLineNum += skipStart;
542
566
  oldLineNum += skipStart;
543
567
  }
544
568
  for (const line of linesToShow) {
545
- if (stopped) break;
569
+ if (em.stopped) break;
546
570
  if (isEllipsisMarker(line)) {
547
- emitPlain(" ...", undefined);
571
+ em.emitPlain(" ...", undefined);
548
572
  newLineNum += skipMiddle;
549
573
  oldLineNum += skipMiddle;
550
574
  continue;
551
575
  }
552
576
  const hash = effectiveNewHashes[newLineNum - 1];
553
- emitRow(" ", line, hash, newLineNum);
577
+ em.emitRow(" ", line, hash, newLineNum);
554
578
  newLineNum++;
555
579
  oldLineNum++;
556
580
  }
557
581
  if (skipTail > 0) {
558
- emitPlain(" ...", undefined);
582
+ em.emitPlain(" ...", undefined);
559
583
  }
560
584
  } else {
561
585
  newLineNum += displayLines.length;
@@ -564,7 +588,7 @@ export function genDiff(
564
588
  lastWasChange = false;
565
589
  }
566
590
 
567
- if (diffTruncated) {
591
+ if (em.truncated) {
568
592
  output.push(" ...");
569
593
  lineNumbers.push(undefined);
570
594
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);