pi-hashline-edit-pro 4.2.11 → 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.
@@ -55,43 +55,30 @@ export const parseHashRef = parseRef;
55
55
 
56
56
  const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
57
57
 
58
- function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
58
+ function unwrapJsonEnvelope(line: string): string {
59
59
  const match = line.match(JSON_ENVELOPE_RE);
60
60
  if (!match) return line;
61
61
  const withoutDot = line.trim().slice(0, -1);
62
62
  try {
63
63
  const parsed: unknown = JSON.parse(withoutDot);
64
64
  if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string") {
65
- warnings?.push(
66
- '[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
67
- );
68
65
  return parsed[0];
69
66
  }
70
67
  return line;
71
68
  } catch {
72
- warnings?.push(
73
- '[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
74
- );
75
69
  return match[1]!;
76
70
  }
77
71
  }
78
72
 
79
- export function parseText(edit: string[], warnings?: string[]): string[] {
73
+ export function parseText(edit: string[]): string[] {
80
74
  if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
81
75
  throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
82
76
  }
83
77
  const out: string[] = [];
84
- let split = false;
85
78
  for (const line of edit) {
86
- const unwrapped = unwrapJsonEnvelope(line, warnings);
79
+ const unwrapped = unwrapJsonEnvelope(line);
87
80
  const normalized = unwrapped.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
88
- if (normalized.includes("\n")) split = true;
89
81
  out.push(...normalized.split("\n"));
90
82
  }
91
- if (split) {
92
- warnings?.push(
93
- "[W_BAD_SHAPE] replacement_lines contained embedded newlines; split into one line each.",
94
- );
95
- }
96
83
  return out;
97
84
  }
@@ -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}` : "";
@@ -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
@@ -201,6 +201,74 @@ function trimPlacedSpans(
201
201
  return out;
202
202
  }
203
203
 
204
+ function createRowEmitter(
205
+ output: string[],
206
+ lineNumbers: (number | undefined)[],
207
+ maxLineBytes: number,
208
+ maxBytes: number,
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
+ } {
216
+ let outBytes = 0;
217
+ let stopped = false;
218
+ let truncated = false;
219
+ const emitPlain = (line: string, num?: number): void => {
220
+ if (stopped) return;
221
+ const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
222
+ if (outBytes + lineBytes > maxBytes) {
223
+ stopped = true;
224
+ truncated = true;
225
+ return;
226
+ }
227
+ outBytes += lineBytes;
228
+ output.push(line);
229
+ lineNumbers.push(num);
230
+ };
231
+ const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
232
+ if (stopped) return;
233
+ const full = fmtDiffLine(prefix, line, hash);
234
+ const rowBytes = Buffer.byteLength(full, "utf-8");
235
+ if (rowBytes > maxLineBytes) {
236
+ const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
237
+ emitPlain(fmtDiffLine(prefix, marker, hash), num);
238
+ return;
239
+ }
240
+ if (outBytes + rowBytes + 1 > maxBytes) {
241
+ stopped = true;
242
+ truncated = true;
243
+ return;
244
+ }
245
+ outBytes += rowBytes + 1;
246
+ output.push(full);
247
+ lineNumbers.push(num);
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
+
204
272
  function genSpanDiff(
205
273
  oldContent: string,
206
274
  newContent: string,
@@ -235,50 +303,18 @@ function genSpanDiff(
235
303
  let dedupEmitted = false;
236
304
  const lineNumbers: (number | undefined)[] = [];
237
305
  let firstChangedLine: number | undefined;
238
- let outBytes = 0;
239
- let stopped = false;
240
- let diffTruncated = false;
241
- const emitPlain = (line: string, num?: number): void => {
242
- if (stopped) return;
243
- const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
244
- if (outBytes + lineBytes > maxBytes) {
245
- stopped = true;
246
- diffTruncated = true;
247
- return;
248
- }
249
- outBytes += lineBytes;
250
- output.push(line);
251
- lineNumbers.push(num);
252
- };
253
- const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
254
- if (stopped) return;
255
- const full = fmtDiffLine(prefix, line, hash);
256
- const rowBytes = Buffer.byteLength(full, "utf-8");
257
- if (rowBytes > maxLineBytes) {
258
- const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
259
- emitPlain(fmtDiffLine(prefix, marker, hash), num);
260
- return;
261
- }
262
- if (outBytes + rowBytes + 1 > maxBytes) {
263
- stopped = true;
264
- diffTruncated = true;
265
- return;
266
- }
267
- outBytes += rowBytes + 1;
268
- output.push(full);
269
- lineNumbers.push(num);
270
- };
306
+ const em = createRowEmitter(output, lineNumbers, maxLineBytes, maxBytes);
271
307
  const emitGap = (gapStartNew: number, gapLen: number, position: "leading" | "middle" | "trailing"): void => {
272
- if (stopped || gapLen <= 0) return;
308
+ if (em.stopped || gapLen <= 0) return;
273
309
  if (position === "leading") {
274
310
  let count = contextLines;
275
311
  if (contextLines > 0 && gapLen > count && isBlankLine(newLines[gapStartNew + gapLen - 1]!)) count += 1;
276
312
  count = Math.min(count, gapLen);
277
313
  const skipStart = gapLen - count;
278
- if (skipStart > 0) emitPlain(" ...", undefined);
279
- 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++) {
280
316
  const newIdx = gapStartNew + k;
281
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
317
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
282
318
  }
283
319
  return;
284
320
  }
@@ -286,11 +322,11 @@ function genSpanDiff(
286
322
  let count = contextLines;
287
323
  if (contextLines > 0 && gapLen > count && isBlankLine(newLines[gapStartNew]!)) count += 1;
288
324
  count = Math.min(count, gapLen);
289
- for (let k = 0; k < count && !stopped; k++) {
325
+ for (let k = 0; k < count && !em.stopped; k++) {
290
326
  const newIdx = gapStartNew + k;
291
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
327
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
292
328
  }
293
- if (gapLen > count) emitPlain(" ...", undefined);
329
+ if (gapLen > count) em.emitPlain(" ...", undefined);
294
330
  return;
295
331
  }
296
332
  let headCount = contextLines;
@@ -299,56 +335,52 @@ function genSpanDiff(
299
335
  if (contextLines > 0 && gapLen - tailCount > headCount && isBlankLine(newLines[gapStartNew + gapLen - tailCount]!)) tailCount += 1;
300
336
  const middleLen = gapLen - headCount - tailCount;
301
337
  if (middleLen <= 0) {
302
- for (let k = 0; k < gapLen && !stopped; k++) {
338
+ for (let k = 0; k < gapLen && !em.stopped; k++) {
303
339
  const newIdx = gapStartNew + k;
304
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
340
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
305
341
  }
306
342
  return;
307
343
  }
308
- for (let k = 0; k < headCount && !stopped; k++) {
344
+ for (let k = 0; k < headCount && !em.stopped; k++) {
309
345
  const newIdx = gapStartNew + k;
310
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
346
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
311
347
  }
312
- if (!stopped) emitPlain(" ...", undefined);
313
- 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++) {
314
350
  const newIdx = gapStartNew + k;
315
- emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
351
+ em.emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
316
352
  }
317
353
  };
318
354
  oldPos = 0;
319
355
  newPos = 0;
320
- for (let spanIdx = 0; spanIdx < ordered.length && !stopped; spanIdx++) {
356
+ for (let spanIdx = 0; spanIdx < ordered.length && !em.stopped; spanIdx++) {
321
357
  const span = ordered[spanIdx]!;
322
358
  const gapLen = span.oldStart - oldPos;
323
359
  emitGap(newPos, gapLen, spanIdx === 0 ? "leading" : "middle");
324
- if (stopped) break;
360
+ if (em.stopped) break;
325
361
  if (firstChangedLine === undefined) firstChangedLine = span.newStart + 1;
326
362
  for (const dedup of span.dedupAbove ?? []) {
327
- if (stopped) break;
328
- dedupEmitted = true;
329
- output.push(fmtDedupRow(dedup));
330
- lineNumbers.push(undefined);
363
+ if (em.stopped) break;
364
+ if (em.emitDedup(dedup)) dedupEmitted = true;
331
365
  }
332
- for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !stopped; oldIdx++) {
333
- emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
366
+ for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !em.stopped; oldIdx++) {
367
+ em.emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
334
368
  }
335
- for (let newIdx = span.newStart; newIdx <= span.newEnd && !stopped; newIdx++) {
336
- emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
369
+ for (let newIdx = span.newStart; newIdx <= span.newEnd && !em.stopped; newIdx++) {
370
+ em.emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
337
371
  }
338
372
  for (const dedup of span.dedupBelow ?? []) {
339
- if (stopped) break;
340
- dedupEmitted = true;
341
- output.push(fmtDedupRow(dedup));
342
- lineNumbers.push(undefined);
373
+ if (em.stopped) break;
374
+ if (em.emitDedup(dedup)) dedupEmitted = true;
343
375
  }
344
376
  oldPos = span.oldEnd + 1;
345
377
  newPos = span.newEnd + 1;
346
378
  }
347
- if (!stopped) {
379
+ if (!em.stopped) {
348
380
  const trailingLen = oldLines.length - oldPos;
349
381
  emitGap(newPos, trailingLen, "trailing");
350
382
  }
351
- if (diffTruncated) {
383
+ if (em.truncated) {
352
384
  output.push(" ...");
353
385
  lineNumbers.push(undefined);
354
386
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
@@ -441,45 +473,12 @@ export function genDiff(
441
473
  let oldLineNum = 1;
442
474
  let lastWasChange = false;
443
475
  let firstChangedLine: number | undefined;
444
- let outBytes = 0;
445
- let stopped = false;
446
- let diffTruncated = false;
447
476
  const lineNumbers: (number|undefined)[] = [];
448
477
 
449
- const emitPlain = (line: string, num?: number): void => {
450
- if (stopped) return;
451
- const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
452
- if (outBytes + lineBytes > maxBytes) {
453
- stopped = true;
454
- diffTruncated = true;
455
- return;
456
- }
457
- outBytes += lineBytes;
458
- output.push(line);
459
- lineNumbers.push(num);
460
- };
461
-
462
- const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
463
- if (stopped) return;
464
- const full = fmtDiffLine(prefix, line, hash);
465
- const rowBytes = Buffer.byteLength(full, "utf-8");
466
- if (rowBytes > maxLineBytes) {
467
- const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
468
- emitPlain(fmtDiffLine(prefix, marker, hash), num);
469
- return;
470
- }
471
- if (outBytes + rowBytes + 1 > maxBytes) {
472
- stopped = true;
473
- diffTruncated = true;
474
- return;
475
- }
476
- outBytes += rowBytes + 1;
477
- output.push(full);
478
- lineNumbers.push(num);
479
- };
478
+ const em = createRowEmitter(output, lineNumbers, maxLineBytes, maxBytes);
480
479
 
481
480
  for (let i = 0; i < parts.length; i++) {
482
- if (stopped) break;
481
+ if (em.stopped) break;
483
482
  const part = parts[i]!;
484
483
  const raw = part.value.split("\n");
485
484
  if (raw[raw.length - 1] === "") raw.pop();
@@ -488,18 +487,18 @@ export function genDiff(
488
487
  if (part.added || part.removed) {
489
488
  if (firstChangedLine === undefined) firstChangedLine = newLineNum;
490
489
  for (let k = 0; k < displayLines.length; k++) {
491
- if (stopped) break;
490
+ if (em.stopped) break;
492
491
  if (part.added) {
493
492
  const hash = effectiveNewHashes[newLineNum - 1];
494
- emitRow("+", displayLines[k]!, hash, newLineNum);
493
+ em.emitRow("+", displayLines[k]!, hash, newLineNum);
495
494
  newLineNum++;
496
495
  } else {
497
496
  const hash = oldContentHashes?.[oldLineNum - 1];
498
- emitRow("-", displayLines[k]!, hash, oldLineNum);
497
+ em.emitRow("-", displayLines[k]!, hash, oldLineNum);
499
498
  oldLineNum++;
500
499
  }
501
500
  }
502
- if (stopped) break;
501
+ if (em.stopped) break;
503
502
  lastWasChange = true;
504
503
  continue;
505
504
  }
@@ -562,25 +561,25 @@ export function genDiff(
562
561
  }
563
562
 
564
563
  if (skipStart > 0) {
565
- emitPlain(" ...", undefined);
564
+ em.emitPlain(" ...", undefined);
566
565
  newLineNum += skipStart;
567
566
  oldLineNum += skipStart;
568
567
  }
569
568
  for (const line of linesToShow) {
570
- if (stopped) break;
569
+ if (em.stopped) break;
571
570
  if (isEllipsisMarker(line)) {
572
- emitPlain(" ...", undefined);
571
+ em.emitPlain(" ...", undefined);
573
572
  newLineNum += skipMiddle;
574
573
  oldLineNum += skipMiddle;
575
574
  continue;
576
575
  }
577
576
  const hash = effectiveNewHashes[newLineNum - 1];
578
- emitRow(" ", line, hash, newLineNum);
577
+ em.emitRow(" ", line, hash, newLineNum);
579
578
  newLineNum++;
580
579
  oldLineNum++;
581
580
  }
582
581
  if (skipTail > 0) {
583
- emitPlain(" ...", undefined);
582
+ em.emitPlain(" ...", undefined);
584
583
  }
585
584
  } else {
586
585
  newLineNum += displayLines.length;
@@ -589,7 +588,7 @@ export function genDiff(
589
588
  lastWasChange = false;
590
589
  }
591
590
 
592
- if (diffTruncated) {
591
+ if (em.truncated) {
593
592
  output.push(" ...");
594
593
  lineNumbers.push(undefined);
595
594
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
@@ -1,3 +1,4 @@
1
+ import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
1
2
  import type { NEdit } from "./hashline";
2
3
  import { HASH_SEP } from "./hashline";
3
4
  import type { ReplaceDetails } from "./replace";
@@ -140,9 +141,42 @@ export function isChangeRow(line: string): boolean {
140
141
  return line.startsWith("+") || line.startsWith("-");
141
142
  }
142
143
 
143
- export function withDedupRows(diff: string, lineNumbers: (number | undefined)[], above: string[] | undefined, below: string[] | undefined): { diff: string; lineNumbers: (number | undefined)[] } {
144
- const top = (above ?? []).map(fmtDedupRow);
145
- const bottom = (below ?? []).map(fmtDedupRow);
144
+ function capDedupRows(rows: string[], budget: number): { rows: string[]; bytes: number; omitted: number } {
145
+ const kept: string[] = [];
146
+ let bytes = 0;
147
+ for (const row of rows) {
148
+ const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
149
+ if (bytes + rowBytes > budget) break;
150
+ kept.push(row);
151
+ bytes += rowBytes;
152
+ }
153
+ return { rows: kept, bytes, omitted: rows.length - kept.length };
154
+ }
155
+
156
+ function dedupOmissionNote(omitted: number, maxBytes: number): string {
157
+ return fmtDedupRow(`[${omitted} line(s) not shown again; output capped at ${formatSize(maxBytes)}.]`);
158
+ }
159
+
160
+ export function withDedupRows(diff: string, lineNumbers: (number | undefined)[], above: string[] | undefined, below: string[] | undefined, maxBytes = DEFAULT_MAX_BYTES): { diff: string; lineNumbers: (number | undefined)[] } {
161
+ const topAll = (above ?? []).map(fmtDedupRow);
162
+ const bottomAll = (below ?? []).map(fmtDedupRow);
163
+ if (topAll.length === 0 && bottomAll.length === 0) return { diff, lineNumbers };
164
+ const usedBytes = Buffer.byteLength(diff, "utf-8");
165
+ const fullBudget = Math.max(0, maxBytes - usedBytes);
166
+ const initialTop = capDedupRows(topAll, fullBudget);
167
+ const initialBottom = capDedupRows(bottomAll, Math.max(0, fullBudget - initialTop.bytes));
168
+ const anyOmitted = initialTop.omitted + initialBottom.omitted > 0;
169
+ const reserved = anyOmitted ? Buffer.byteLength(dedupOmissionNote(topAll.length + bottomAll.length, maxBytes), "utf-8") + 1 : 0;
170
+ const budget = Math.max(0, fullBudget - reserved);
171
+ const topCapped = anyOmitted ? capDedupRows(topAll, budget) : initialTop;
172
+ const bottomCapped = anyOmitted ? capDedupRows(bottomAll, Math.max(0, budget - topCapped.bytes)) : initialBottom;
173
+ const top = topCapped.rows;
174
+ const bottom = [...bottomCapped.rows];
175
+ const omitted = topCapped.omitted + bottomCapped.omitted;
176
+ if (omitted > 0) {
177
+ const note = dedupOmissionNote(omitted, maxBytes);
178
+ if (usedBytes + topCapped.bytes + bottomCapped.bytes + Buffer.byteLength(note, "utf-8") + 1 <= maxBytes) bottom.push(note);
179
+ }
146
180
  if (top.length === 0 && bottom.length === 0) return { diff, lineNumbers };
147
181
  if (diff.length === 0) return { diff: [...top, ...bottom].join("\n"), lineNumbers: [...lineNumbers, ...[...top, ...bottom].map(() => undefined)] };
148
182
  const lines = diff.split("\n");
@@ -4,10 +4,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
  import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
7
- import { servedHashesFromDiff, buildServedMap } from "./served";
8
- import { contentChecksum } from "./hashline/hasher";
9
- import { hashSource } from "./hashline";
10
- import { markServed as markServedScoped, freeAnchors, adoptAnchors, withAnchorSession } from "./anchor-registry";
7
+ import { servedHashesFromDiff, serveRows } from "./served";
8
+ import { lineChecksum } from "./hashline";
9
+ import { freeAnchors, adoptAnchors, withAnchorSession } from "./anchor-registry";
11
10
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
12
11
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
13
12
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
@@ -200,16 +199,17 @@ export function regUndo(pi: ExtensionAPI): void {
200
199
  try {
201
200
  const store = await loadHashStore();
202
201
  const undoLines = splitLines(undo.content);
203
- persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes, undoLines.map((line) => contentChecksum(hashSource(line))));
202
+ persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes, undoLines.map(lineChecksum));
204
203
  freeAnchors(mutationTargetPath);
205
204
  adoptAnchors(
206
205
  mutationTargetPath,
207
- new Map(undoLines.map((line, i) => [undo.hashes[i]!, contentChecksum(hashSource(line))])),
206
+ new Map(undoLines.map((line, i) => [undo.hashes[i]!, lineChecksum(line)])),
208
207
  );
209
- markServedScoped(
208
+ serveRows(
210
209
  mutationTargetPath,
211
- buildServedMap(undo.hashes, undoLines, servedHashesFromDiff(undoDiff)),
212
- new Set(undo.hashes),
210
+ undo.hashes,
211
+ undoLines,
212
+ servedHashesFromDiff(undoDiff),
213
213
  );
214
214
  } catch (error) {
215
215
  console.error("Failed to restore hash store snapshot after undo:", error);