pi-hashline-edit-pro 5.1.0 → 6.0.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.
Files changed (47) hide show
  1. package/README.md +47 -48
  2. package/index.ts +10 -10
  3. package/package.json +1 -1
  4. package/prompts/copy-guidelines.md +0 -1
  5. package/prompts/copy.md +2 -0
  6. package/prompts/insert-snippet.md +1 -1
  7. package/prompts/insert.md +1 -1
  8. package/prompts/move-guidelines.md +0 -2
  9. package/prompts/move.md +2 -0
  10. package/prompts/read-guidelines.md +0 -2
  11. package/prompts/replace-guidelines.md +4 -4
  12. package/prompts/replace-match-guidelines.md +1 -0
  13. package/prompts/replace-match-snippet.md +1 -0
  14. package/prompts/replace-match.md +7 -0
  15. package/prompts/replace-snippet.md +1 -1
  16. package/prompts/replace.md +3 -3
  17. package/prompts/transfer-guidelines.md +2 -0
  18. package/prompts/undo-last-change-guidelines.md +1 -1
  19. package/prompts/undo-last-change-snippet.md +1 -1
  20. package/prompts/undo-last-change.md +1 -1
  21. package/src/batch.ts +307 -58
  22. package/src/commit.ts +4 -2
  23. package/src/config-ui.ts +2 -2
  24. package/src/config.ts +6 -6
  25. package/src/constants.ts +4 -4
  26. package/src/copy-move.ts +303 -99
  27. package/src/edit-common.ts +78 -64
  28. package/src/edit-fidelity.ts +1 -5
  29. package/src/glob.ts +2 -20
  30. package/src/grep.ts +2 -9
  31. package/src/hash-store.ts +0 -3
  32. package/src/hashline/apply.ts +0 -9
  33. package/src/hashline/index.ts +0 -1
  34. package/src/hashline/parse.ts +1 -1
  35. package/src/hashline/resolve.ts +18 -19
  36. package/src/insert.ts +11 -11
  37. package/src/payload-contract.ts +60 -87
  38. package/src/replace-diff.ts +1 -5
  39. package/src/replace-match.ts +259 -0
  40. package/src/replace-response.ts +10 -4
  41. package/src/replace.ts +4 -6
  42. package/src/utils.ts +6 -29
  43. package/src/write-hook.ts +3 -5
  44. package/prompts/replace-within-guidelines.md +0 -3
  45. package/prompts/replace-within-snippet.md +0 -1
  46. package/prompts/replace-within.md +0 -5
  47. package/src/replace-within.ts +0 -224
package/src/batch.ts CHANGED
@@ -8,7 +8,6 @@ import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
8
8
  import {
9
9
  AnchorMismatchError,
10
10
  RangeStaleError,
11
- assertNotEmpty,
12
11
  changedRange,
13
12
  lineHashes,
14
13
  planEdit,
@@ -19,9 +18,11 @@ import {
19
18
  type StripWarningLocation,
20
19
  } from "./hashline";
21
20
  import { adoptAnchors, servedForPath, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
22
- import { stripBOM, toLF, type LineEnding } from "./normalize";
21
+ import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
23
22
  import { applySpanEndings, joinSeparators, separatorsForSpans } from "./line-endings";
24
- import { assertInsertReq, assertReq, normReq } from "./payload-contract";
23
+ import { assertInsertReq, assertReplaceMatchReq, assertReq, assertTransferReq, normReq } from "./payload-contract";
24
+ import type { PipelineResult } from "./replace";
25
+ import { genPatch } from "./replace-diff";
25
26
  import { saveUndo } from "./replace-undo";
26
27
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
27
28
  import { serveRows, servedHashesFromDiff } from "./served";
@@ -33,6 +34,7 @@ export interface PlannedMember {
33
34
  display: number;
34
35
  total: number;
35
36
  target: string;
37
+ sourceTarget?: string;
36
38
  kind: BatchKind;
37
39
  args: unknown;
38
40
  order: number;
@@ -40,9 +42,9 @@ export interface PlannedMember {
40
42
  last: boolean;
41
43
  }
42
44
 
43
- export type BatchKind = "replace" | "insert";
45
+ export type BatchKind = "replace" | "insert" | "replace_match" | "copy" | "move";
44
46
 
45
- interface BatchBase {
47
+ export interface BatchBase {
46
48
  content: string;
47
49
  hashes: string[];
48
50
  bom: string;
@@ -55,6 +57,19 @@ interface BatchBase {
55
57
  baseLines: string[];
56
58
  }
57
59
 
60
+ export interface BatchSourceMove {
61
+ displayPath: string;
62
+ mutationTargetPath: string;
63
+ pipe: PipelineResult;
64
+ }
65
+
66
+ interface SourcePreparation {
67
+ move: BatchSourceMove;
68
+ separators?: LineEnding[];
69
+ bytes: string;
70
+ originalBytes: string;
71
+ }
72
+
58
73
  export interface BatchPiece {
59
74
  order: number;
60
75
  kind: BatchKind;
@@ -84,6 +99,9 @@ export interface BatchMemberInput {
84
99
  foldedLines?: number;
85
100
  stripWarning?: StripWarningLocation;
86
101
  contentSeparators?: (LineEnding | undefined)[];
102
+ carryIndex?: number;
103
+ servedOverride?: ReadonlyMap<string, string>;
104
+ sourceMove?: BatchSourceMove;
87
105
  }
88
106
 
89
107
  interface BatchFailure {
@@ -96,12 +114,12 @@ interface BatchState {
96
114
  display: number;
97
115
  target: string;
98
116
  memberIds: string[];
99
- replaceCount: number;
100
- insertCount: number;
117
+ stale: boolean;
101
118
  base?: BatchBase;
102
119
  paths?: { absolutePath: string; mutationTargetPath: string; displayPath: string };
103
120
  served?: ReadonlyMap<string, string>;
104
121
  pieces: BatchPiece[];
122
+ sources: BatchSourceMove[];
105
123
  applied: number;
106
124
  noops: number;
107
125
  failures: number;
@@ -117,12 +135,39 @@ interface EditCall {
117
135
  args: unknown;
118
136
  }
119
137
 
138
+ interface ResolvedCall {
139
+ id: string;
140
+ target: string;
141
+ altTarget?: string;
142
+ sourceTarget?: string;
143
+ kind: BatchKind;
144
+ args: unknown;
145
+ path?: string;
146
+ }
147
+
148
+ function demoteBlockedMoveCalls(resolved: ResolvedCall[]): ResolvedCall[] {
149
+ const targets = new Set(resolved.map((item) => item.target));
150
+ const sourceCounts = new Map<string, number>();
151
+ for (const item of resolved) {
152
+ if (item.kind !== "move" || item.sourceTarget === undefined || item.sourceTarget === item.target) continue;
153
+ sourceCounts.set(item.sourceTarget, (sourceCounts.get(item.sourceTarget) ?? 0) + 1);
154
+ }
155
+ return resolved.filter((item) => {
156
+ if (item.kind !== "move" || item.sourceTarget === undefined || item.sourceTarget === item.target) return true;
157
+ if (targets.has(item.sourceTarget)) return false;
158
+ return (sourceCounts.get(item.sourceTarget) ?? 0) === 1;
159
+ });
160
+ }
161
+
120
162
  type NormalizedEditArgs =
121
163
  | { kind: "replace"; removeFrom: string; removeTo?: string; path?: string }
122
- | { kind: "insert"; anchor: string; path?: string };
164
+ | { kind: "insert"; anchor: string; path?: string }
165
+ | { kind: "replace_match"; replaceFrom: string; replaceTo?: string; path?: string }
166
+ | { kind: "copy" | "move"; sourceFrom: string; sourceTo?: string; insertAfter?: string; path?: string };
123
167
 
124
168
  const MAX_TRACKED_BATCHES = 256;
125
169
  const BATCH_DISCARDED_NOTE = "Nothing was written; the whole batch was discarded.";
170
+ const BATCHABLE_CALLS = new Set(["replace", "insert", "replace_match", "copy", "move"]);
126
171
 
127
172
  const plan = new Map<string, PlannedMember>();
128
173
  const batches = new Map<number, BatchState>();
@@ -162,6 +207,21 @@ export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
162
207
  return plan.get(toolCallId);
163
208
  }
164
209
 
210
+ export function pendingBatchMemberFor(path: string): PlannedMember | undefined {
211
+ for (const runtime of batches.values()) {
212
+ if (runtime.target !== path || runtime.stale || runtime.failed) continue;
213
+ for (const id of runtime.memberIds) {
214
+ const member = plan.get(id);
215
+ if (member) return member;
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ export function batchServedFor(member: PlannedMember): ReadonlyMap<string, string> | undefined {
222
+ return batches.get(member.batchKey)?.served;
223
+ }
224
+
165
225
  export function resetBatchStateForTests(): void {
166
226
  plan.clear();
167
227
  batches.clear();
@@ -170,11 +230,30 @@ export function resetBatchStateForTests(): void {
170
230
  nextBatchKey = 1;
171
231
  }
172
232
 
173
- function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
233
+ function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | undefined {
174
234
  if (!isRec(args)) return undefined;
175
235
  const normalized = normReq(args);
176
236
  if (!isRec(normalized)) return undefined;
177
237
  const path = typeof normalized.path === "string" ? normalized.path : undefined;
238
+ if (name === "copy" || name === "move") {
239
+ if (typeof normalized.source_from !== "string") return undefined;
240
+ return {
241
+ kind: name,
242
+ sourceFrom: normalized.source_from,
243
+ ...(typeof normalized.source_to === "string" ? { sourceTo: normalized.source_to } : {}),
244
+ ...(typeof normalized.insert_after === "string" ? { insertAfter: normalized.insert_after } : {}),
245
+ ...(path ? { path } : {}),
246
+ };
247
+ }
248
+ if (name === "replace_match") {
249
+ if (typeof normalized.replace_from !== "string") return undefined;
250
+ return {
251
+ kind: "replace_match",
252
+ replaceFrom: normalized.replace_from,
253
+ ...(typeof normalized.replace_to === "string" ? { replaceTo: normalized.replace_to } : {}),
254
+ ...(path ? { path } : {}),
255
+ };
256
+ }
178
257
  if (typeof normalized.remove_from === "string") {
179
258
  return {
180
259
  kind: "replace",
@@ -190,10 +269,10 @@ function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
190
269
  }
191
270
 
192
271
  async function verifyPaths(
193
- group: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>,
272
+ group: Array<{ id: string; target: string; altTarget?: string; kind: BatchKind; args: unknown; path?: string }>,
194
273
  cwd: string,
195
- ): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>> {
196
- const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }> = [];
274
+ ): Promise<Array<{ id: string; target: string; altTarget?: string; kind: BatchKind; args: unknown; path?: string }>> {
275
+ const verified: Array<{ id: string; target: string; altTarget?: string; kind: BatchKind; args: unknown; path?: string }> = [];
197
276
  for (const item of group) {
198
277
  if (!item.path) continue;
199
278
  let resolved: string | undefined;
@@ -202,11 +281,52 @@ async function verifyPaths(
202
281
  } catch {
203
282
  resolved = undefined;
204
283
  }
205
- if (resolved === item.target) verified.push(item);
284
+ if (resolved === item.target || (item.altTarget !== undefined && resolved === item.altTarget)) verified.push(item);
206
285
  }
207
286
  return verified;
208
287
  }
209
288
 
289
+ async function resolveBatchCallTarget(
290
+ normalized: NormalizedEditArgs,
291
+ requirePath: boolean,
292
+ cwd: string,
293
+ ): Promise<{ target: string; altTarget?: string; sourceTarget?: string } | undefined> {
294
+ const transfer = normalized.kind === "copy" || normalized.kind === "move";
295
+ if (normalized.kind === "replace" || normalized.kind === "replace_match") {
296
+ const from = normalized.kind === "replace" ? normalized.removeFrom : normalized.replaceFrom;
297
+ const to = normalized.kind === "replace" ? normalized.removeTo : normalized.replaceTo;
298
+ const target = tryResolveEditTarget(from, to)
299
+ ?? tryResolveEditTarget(from)
300
+ ?? (to !== undefined ? tryResolveEditTarget(to) : undefined);
301
+ if (target !== undefined) return { target };
302
+ } else if (normalized.kind === "insert") {
303
+ const target = tryResolveEditTarget(normalized.anchor);
304
+ if (target !== undefined) return { target };
305
+ } else {
306
+ const sourceTarget = normalized.sourceTo !== undefined
307
+ ? tryResolveEditTarget(normalized.sourceFrom, normalized.sourceTo)
308
+ : tryResolveEditTarget(normalized.sourceFrom);
309
+ const destinationTarget = normalized.insertAfter !== undefined ? tryResolveEditTarget(normalized.insertAfter) : undefined;
310
+ if (sourceTarget === undefined) return undefined;
311
+ if (normalized.kind === "move") {
312
+ if (sourceTarget === destinationTarget) return { target: sourceTarget, sourceTarget };
313
+ if (destinationTarget !== undefined) return { target: destinationTarget, altTarget: sourceTarget, sourceTarget };
314
+ }
315
+ if (sourceTarget === destinationTarget) return { target: sourceTarget, sourceTarget };
316
+ if (destinationTarget !== undefined) {
317
+ return { target: destinationTarget, altTarget: sourceTarget, sourceTarget };
318
+ }
319
+ }
320
+ if (requirePath && normalized.path !== undefined && !transfer) {
321
+ try {
322
+ return { target: (await resolveInCwd(normalized.path, cwd)).resolved };
323
+ } catch {
324
+ return undefined;
325
+ }
326
+ }
327
+ return undefined;
328
+ }
329
+
210
330
  function enforceCap(): void {
211
331
  while (batches.size > MAX_TRACKED_BATCHES) {
212
332
  const oldest = batches.keys().next().value;
@@ -227,34 +347,32 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
227
347
  const calls: EditCall[] = [];
228
348
  for (const block of message.content) {
229
349
  if (!isRec(block) || block.type !== "toolCall") continue;
230
- if (block.name !== "replace" && block.name !== "insert") continue;
350
+ if (typeof block.name !== "string" || !BATCHABLE_CALLS.has(block.name)) continue;
231
351
  if (typeof block.id !== "string") continue;
232
352
  calls.push({ id: block.id, name: block.name, args: block.arguments });
233
353
  }
234
354
  if (calls.length < 2) return;
235
355
  const earlyConfig = await readConfig();
236
356
  const requirePath = earlyConfig.requirePath === true;
237
- interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown; path?: string }
238
357
  const resolved: ResolvedCall[] = [];
239
358
  for (const call of calls) {
240
- const normalized = normalizeEditArgs(call.args);
359
+ const normalized = normalizeEditArgs(call.name, call.args);
241
360
  if (!normalized) continue;
242
- let target = normalized.kind === "replace" ? tryResolveEditTarget(normalized.removeFrom, normalized.removeTo) : tryResolveEditTarget(normalized.anchor);
243
- if (!target) {
244
- if (requirePath && normalized.path) {
245
- try {
246
- target = (await resolveInCwd(normalized.path, cwd)).resolved;
247
- } catch {
248
- target = undefined;
249
- }
250
- } else if (normalized.kind === "replace") {
251
- target = tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
252
- }
253
- }
254
- if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args, ...(normalized.path ? { path: normalized.path } : {}) });
361
+ const resolvedTarget = await resolveBatchCallTarget(normalized, requirePath, cwd);
362
+ if (!resolvedTarget) continue;
363
+ resolved.push({
364
+ id: call.id,
365
+ target: resolvedTarget.target,
366
+ kind: normalized.kind,
367
+ args: call.args,
368
+ ...(resolvedTarget.altTarget !== undefined ? { altTarget: resolvedTarget.altTarget } : {}),
369
+ ...(resolvedTarget.sourceTarget !== undefined ? { sourceTarget: resolvedTarget.sourceTarget } : {}),
370
+ ...(normalized.path ? { path: normalized.path } : {}),
371
+ });
255
372
  }
373
+ const allowed = demoteBlockedMoveCalls(resolved);
256
374
  const groups = new Map<string, ResolvedCall[]>();
257
- for (const item of resolved) {
375
+ for (const item of allowed) {
258
376
  const group = groups.get(item.target) ?? [];
259
377
  group.push(item);
260
378
  groups.set(item.target, group);
@@ -269,6 +387,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
269
387
  } else {
270
388
  finalGroups.push(...multi);
271
389
  }
390
+ for (const runtime of batches.values()) runtime.stale = true;
272
391
  let display = 0;
273
392
  for (const group of finalGroups) {
274
393
  display += 1;
@@ -277,9 +396,9 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
277
396
  display,
278
397
  target: group[0]!.target,
279
398
  memberIds: group.map((item) => item.id),
280
- replaceCount: 0,
281
- insertCount: 0,
399
+ stale: false,
282
400
  pieces: [],
401
+ sources: [],
283
402
  applied: 0,
284
403
  noops: 0,
285
404
  failures: 0,
@@ -293,6 +412,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
293
412
  display,
294
413
  total: finalGroups.length,
295
414
  target: item.target,
415
+ ...(item.sourceTarget !== undefined ? { sourceTarget: item.sourceTarget } : {}),
296
416
  kind: item.kind,
297
417
  args: item.args,
298
418
  order: index + 1,
@@ -316,9 +436,15 @@ function dedupeWarnings(warnings: string[]): string[] {
316
436
  }
317
437
 
318
438
  function batchVerb(runtime: BatchState): string {
319
- if (runtime.replaceCount > 0 && runtime.insertCount > 0) return "edited";
320
- if (runtime.insertCount > 0) return "inserted";
321
- return "replaced";
439
+ const kinds = new Set(runtime.pieces.map((piece) => piece.kind));
440
+ if (kinds.size === 1) {
441
+ const only = [...kinds][0];
442
+ if (only === "insert") return "inserted";
443
+ if (only === "copy") return "copied";
444
+ if (only === "move") return "moved";
445
+ return "replaced";
446
+ }
447
+ return "edited";
322
448
  }
323
449
  function batchHeader(member: PlannedMember): string {
324
450
  return `batch ${member.display}:`;
@@ -331,12 +457,14 @@ function formatBatchLines(start: number, end: number): string {
331
457
  function formatBatchPiece(piece: BatchPiece): string {
332
458
  const lines = formatBatchLines(piece.start, piece.end);
333
459
  if (piece.kind === "insert") return `edit #${piece.order} (insert at ${piece.fromHash}, ${lines})`;
460
+ if (piece.kind === "copy") return `edit #${piece.order} (copy at ${piece.fromHash}, ${lines})`;
461
+ if (piece.kind === "move") return `edit #${piece.order} (move ${piece.fromHash}→${piece.toHash}, ${lines})`;
334
462
  if (piece.fromHash === piece.toHash) return `edit #${piece.order} (replace ${piece.fromHash}, ${lines})`;
335
463
  return `edit #${piece.order} (replace ${piece.fromHash}→${piece.toHash}, ${lines})`;
336
464
  }
337
465
 
338
466
  function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId: string | undefined): TResult {
339
- const added = piece.kind === "insert" ? Math.max(0, piece.newLines.length - piece.foldedLines) : piece.newLines.length;
467
+ const added = Math.max(0, piece.newLines.length - piece.foldedLines);
340
468
  const metrics: RMetrics = {
341
469
  edits_attempted: 1,
342
470
  edits_noop: piece.noop ? 1 : 0,
@@ -455,7 +583,8 @@ export async function ensureBatchBase(input: {
455
583
  baseLines: splitLines(file.normalized),
456
584
  };
457
585
  runtime.base = base;
458
- runtime.served = servedForPath(file.absolutePath);
586
+ const served = servedForPath(file.absolutePath);
587
+ runtime.served = served ? new Map(served) : undefined;
459
588
  runtime.paths = {
460
589
  absolutePath: file.absolutePath,
461
590
  mutationTargetPath: input.mutationTargetPath,
@@ -494,7 +623,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
494
623
  try {
495
624
  planned = planEdit(base.content, effectiveHedit, base.hashes, {
496
625
  filePath: displayPath,
497
- servedHashes: runtime.served,
626
+ servedHashes: input.servedOverride ?? runtime.served,
498
627
  signal: input.signal,
499
628
  baseFileLines: base.baseLines,
500
629
  stripWarning: input.stripWarning,
@@ -515,11 +644,13 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
515
644
  const foldedLines = input.foldedLines ?? 0;
516
645
  const separators = input.contentSeparators ?? input.hedit.content_separators;
517
646
  const carryIndex =
518
- input.kind === "insert" && foldedLines > 0
519
- ? input.direction === "after"
520
- ? 0
521
- : newLines.length - 1
522
- : undefined;
647
+ input.carryIndex !== undefined
648
+ ? input.carryIndex
649
+ : input.kind === "insert" && foldedLines > 0
650
+ ? input.direction === "after"
651
+ ? 0
652
+ : newLines.length - 1
653
+ : undefined;
523
654
  const piece: BatchPiece = {
524
655
  order: input.member.order,
525
656
  kind: input.kind,
@@ -536,11 +667,10 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
536
667
  foldedLines,
537
668
  };
538
669
  runtime.pieces.push(piece);
539
- if (input.kind === "replace") runtime.replaceCount += 1;
540
- else runtime.insertCount += 1;
541
670
  if (noop) runtime.noops += 1;
542
671
  else runtime.applied += 1;
543
672
  runtime.warnings.push(...piece.warnings);
673
+ if (input.sourceMove !== undefined) runtime.sources.push(input.sourceMove);
544
674
  if (!input.member.last) {
545
675
  const placeholder = batchPlaceholder(input.member, piece, base.snapshotId);
546
676
  placeholderResults.set(input.member.id, placeholder);
@@ -642,8 +772,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
642
772
  if (executedOrders.has(planned.order)) continue;
643
773
  try {
644
774
  const normalized = normReq(planned.args);
645
- if (planned.kind === "replace") assertReq(normalized);
646
- else assertInsertReq(normalized);
775
+ if (planned.kind === "insert") assertInsertReq(normalized);
776
+ else if (planned.kind === "replace") assertReq(normalized);
777
+ else if (planned.kind === "replace_match") assertReplaceMatchReq(normalized);
778
+ else assertTransferReq(normalized);
647
779
  } catch (error) {
648
780
  noteBatchFailure(planned, error);
649
781
  throw batchAbortedError(runtime);
@@ -651,7 +783,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
651
783
  }
652
784
  const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
653
785
  const candidatePieces = runtime.pieces.filter((piece) => !piece.noop || changesEnding(piece, base.separators));
654
- if (candidatePieces.length === 0) {
786
+ if (candidatePieces.length === 0 && runtime.sources.length === 0) {
655
787
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
656
788
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
657
789
  }
@@ -666,7 +798,9 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
666
798
  }
667
799
  }
668
800
  const composed = composeBatchLines(base.content, effectivePieces);
669
- const spans = pieceMappingSpans(effectivePieces);
801
+ const spans = composed.length === 0
802
+ ? [{ start: 0, end: base.hashes.length - 1, replacementCount: 1 }]
803
+ : pieceMappingSpans(effectivePieces);
670
804
  const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending);
671
805
  applySpanEndings(resultSeparators, effectivePieces.map((piece) => ({
672
806
  start: piece.start - 1,
@@ -680,7 +814,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
680
814
  const originalBytes = base.bom + joinSeparators(base.content, base.separators);
681
815
  try {
682
816
  await throwIfStrictInput(dedupeWarnings(warnings));
683
- assertNotEmpty(base.content, composed);
684
817
  assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
685
818
  assertByteLimit(finalBytes, paths.displayPath);
686
819
  } catch (error) {
@@ -690,7 +823,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
690
823
  }
691
824
  const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
692
825
  if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
693
- if (finalBytes === originalBytes) {
826
+ if (finalBytes === originalBytes && runtime.sources.length === 0) {
694
827
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
695
828
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
696
829
  }
@@ -718,6 +851,47 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
718
851
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
719
852
  throw error;
720
853
  }
854
+
855
+ const sourcePreparations: SourcePreparation[] = [];
856
+ for (const source of runtime.sources) {
857
+ let sourceRaw: string | undefined;
858
+ try {
859
+ sourceRaw = await readFile(source.mutationTargetPath, "utf-8");
860
+ } catch (error) {
861
+ if (errCode(error) !== "ENOENT") throw error;
862
+ sourceRaw = undefined;
863
+ }
864
+ if (sourceRaw === undefined || toLF(stripBOM(sourceRaw).text) !== source.pipe.originalNormalized) {
865
+ discardBatchState(runtime);
866
+ throw new Error(withAbortSuffix(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the move source ${source.displayPath} changed after the move started. Call read for fresh anchors and retry.`, runtime.display));
867
+ }
868
+ const sourceSpan = source.pipe.spans?.[0];
869
+ const sourceSeparators = sourceSpan
870
+ ? separatorsForSpans(source.pipe.originalSeparators, source.pipe.originalHashes.length, [sourceSpan], source.pipe.result, source.pipe.originalEnding)
871
+ : undefined;
872
+ const sourceBytes = source.pipe.bom + (sourceSeparators !== undefined
873
+ ? joinSeparators(source.pipe.result, sourceSeparators)
874
+ : restoreEndings(source.pipe.result, source.pipe.originalEnding));
875
+ const sourceOriginalBytes = source.pipe.bom + joinSeparators(source.pipe.originalNormalized, source.pipe.originalSeparators);
876
+ try {
877
+ assertByteLimit(sourceBytes, source.displayPath);
878
+ await lineHashes(source.pipe.result, source.mutationTargetPath, {
879
+ content: source.pipe.originalNormalized,
880
+ hashes: source.pipe.originalHashes,
881
+ ...(source.pipe.spans !== undefined ? { spans: source.pipe.spans } : {}),
882
+ }, undefined, false, true);
883
+ } catch (error) {
884
+ discardBatchState(runtime);
885
+ if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
886
+ throw error;
887
+ }
888
+ sourcePreparations.push({
889
+ move: source,
890
+ ...(sourceSeparators !== undefined ? { separators: sourceSeparators } : {}),
891
+ bytes: sourceBytes,
892
+ originalBytes: sourceOriginalBytes,
893
+ });
894
+ }
721
895
  const undo = await saveUndo(runtime.target, {
722
896
  content: base.content,
723
897
  bom: base.bom,
@@ -731,11 +905,43 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
731
905
  discardBatchState(runtime);
732
906
  throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${paths.displayPath}. Aborts batch ${runtime.display}.`);
733
907
  }
908
+ const undoRestores: Array<() => Promise<void>> = [undo.restore];
909
+ for (const preparation of sourcePreparations) {
910
+ const sourceUndo = await saveUndo(preparation.move.mutationTargetPath, {
911
+ content: preparation.move.pipe.originalNormalized,
912
+ bom: preparation.move.pipe.bom,
913
+ originalEnding: preparation.move.pipe.originalEnding,
914
+ separators: preparation.move.pipe.originalSeparators,
915
+ hashes: preparation.move.pipe.originalHashes,
916
+ resultContent: preparation.move.pipe.result,
917
+ ...(preparation.separators !== undefined ? { resultSeparators: preparation.separators } : {}),
918
+ });
919
+ if (!sourceUndo.persisted) {
920
+ for (const restore of [...undoRestores].reverse()) await restore();
921
+ discardBatchState(runtime);
922
+ throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${preparation.move.displayPath}. Nothing was written. Aborts batch ${runtime.display}.`);
923
+ }
924
+ undoRestores.push(sourceUndo.restore);
925
+ }
926
+ const writtenFiles: Array<{ path: string; bytes: string }> = [];
734
927
  try {
735
928
  abortIf(signal);
736
- await writeAtomic(paths.absolutePath, base.bom + joinSeparators(composed, resultSeparators), base.identity);
929
+ await writeAtomic(paths.absolutePath, finalBytes, base.identity);
930
+ writtenFiles.push({ path: paths.absolutePath, bytes: originalBytes });
931
+ for (const preparation of sourcePreparations) {
932
+ abortIf(signal);
933
+ await writeAtomic(preparation.move.mutationTargetPath, preparation.bytes, preparation.move.pipe.identity);
934
+ writtenFiles.push({ path: preparation.move.mutationTargetPath, bytes: preparation.originalBytes });
935
+ }
737
936
  } catch (error) {
738
- await undo.restore();
937
+ for (const written of [...writtenFiles].reverse()) {
938
+ try {
939
+ await writeAtomic(written.path, written.bytes);
940
+ } catch (rollbackError) {
941
+ console.error("Failed to roll back a file after a batched cross-file move failed:", rollbackError);
942
+ }
943
+ }
944
+ for (const restore of [...undoRestores].reverse()) await restore();
739
945
  discardBatchState(runtime);
740
946
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
741
947
  throw error;
@@ -750,7 +956,23 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
750
956
  });
751
957
  } catch (error) {
752
958
  const detail = error instanceof Error ? error.message : String(error);
753
- throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
959
+ const written = sourcePreparations.length > 0
960
+ ? "Files were written; anchor finalization failed. One undo per file reverts."
961
+ : "File was written; anchor finalization failed. One undo reverts.";
962
+ throw new Error(`${detail} ${written} Call read for fresh anchors.`);
963
+ }
964
+
965
+ for (const preparation of sourcePreparations) {
966
+ try {
967
+ await lineHashes(preparation.move.pipe.result, preparation.move.mutationTargetPath, {
968
+ content: preparation.move.pipe.originalNormalized,
969
+ hashes: preparation.move.pipe.originalHashes,
970
+ ...(preparation.move.pipe.spans !== undefined ? { spans: preparation.move.pipe.spans } : {}),
971
+ });
972
+ } catch (error) {
973
+ const detail = error instanceof Error ? error.message : String(error);
974
+ throw new Error(`${detail} Files were written; anchor finalization failed. One undo per file reverts. Call read for fresh anchors.`);
975
+ }
754
976
  }
755
977
  const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
756
978
  if (writeReclaim !== undefined) warnings.push(writeReclaim);
@@ -759,8 +981,9 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
759
981
  let removed = 0;
760
982
  for (const piece of appliedPieces) {
761
983
  removed += piece.end - piece.start + 1;
762
- added += piece.kind === "insert" ? Math.max(0, piece.newLines.length - piece.foldedLines) : piece.newLines.length;
984
+ added += Math.max(0, piece.newLines.length - piece.foldedLines);
763
985
  }
986
+ const verb = batchVerb(runtime);
764
987
  const header = batchHeader(member);
765
988
  const changed = buildChanged(
766
989
  {
@@ -781,20 +1004,46 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
781
1004
  },
782
1005
  spans,
783
1006
  },
784
- batchVerb(runtime),
1007
+ verb,
785
1008
  await getDiffContextLines(),
1009
+ {
1010
+ separatorMoved:
1011
+ runtime.pieces.some((piece) => piece.kind === "insert") &&
1012
+ !runtime.pieces.some((piece) => piece.kind === "copy" || piece.kind === "move"),
1013
+ indentHints: !runtime.pieces.some((piece) => piece.kind === "copy" || piece.kind === "move"),
1014
+ },
786
1015
  );
1016
+
1017
+ if (sourcePreparations.length > 0) {
1018
+ const patches = [changed.details.patch ?? ""];
1019
+ let patchTruncated = changed.details.patchTruncated === true;
1020
+ for (const preparation of sourcePreparations) {
1021
+ const sourcePatch = genPatch(preparation.move.displayPath, preparation.move.pipe.originalNormalized, preparation.move.pipe.result);
1022
+ if (sourcePatch.patch.length > 0) patches.push(sourcePatch.patch);
1023
+ if (sourcePatch.truncated) patchTruncated = true;
1024
+ }
1025
+ changed.details.patch = patches.filter((patch) => patch.length > 0).join("\n");
1026
+ if (patchTruncated) changed.details.patchTruncated = true;
1027
+ }
787
1028
  if (changed.details.diff.length > 0) {
788
1029
  changed.details.diff = `${header}\n${changed.details.diff}`;
789
1030
  changed.details.diffLineNumbers?.unshift(null);
790
1031
  }
791
1032
  try {
792
- serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
1033
+ const wanted = servedHashesFromDiff(changed.details.diff);
1034
+ if (composed.length === 0) wanted.push(...resultHashes);
1035
+ serveRows(runtime.target, resultHashes, splitLines(composed), wanted);
793
1036
  } catch (error) {
794
1037
  console.error("Failed to mark batch diff served:", error);
795
1038
  }
796
1039
  const executed = runtime.applied + runtime.noops;
797
- changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; one undo reverts them.`;
1040
+ const movedLines = sourcePreparations.reduce((total, preparation) => total + preparation.move.pipe.totalRemovedLines, 0);
1041
+ const sourceFiles = sourcePreparations.map((preparation) => preparation.move.displayPath);
1042
+ const sourceNote = sourceFiles.length > 0
1043
+ ? `\nMoved ${movedLines} line(s) out of ${sourceFiles.join(", ")}; each source file keeps its own undo. Read ${sourceFiles.join(", ")} for fresh anchors.`
1044
+ : "";
1045
+ const undoNote = sourceFiles.length > 0 ? "one undo reverts the destination edits" : "one undo reverts them";
1046
+ changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; ${undoNote}.${sourceNote}`;
798
1047
  changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
799
1048
  return changed;
800
1049
  }
package/src/commit.ts CHANGED
@@ -153,8 +153,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
153
153
  ...(span ? { spans: [span] } : {}),
154
154
  };
155
155
  const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
156
- if (changed.details.diff) {
157
- serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff));
156
+ if (changed.details.diff || pipe.result.length === 0) {
157
+ const wanted = servedHashesFromDiff(changed.details.diff);
158
+ if (pipe.result.length === 0) wanted.push(...resultHashes);
159
+ serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), wanted);
158
160
  }
159
161
  return changed;
160
162
  }
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceWithinEnabled" | "requirePath" | "strictInput" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -26,7 +26,7 @@ export function configRows(config: Config): ConfigRow[] {
26
26
  { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
27
27
  { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
28
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
- { key: "replaceWithinEnabled", label: "Replace within", hint: "replace_within tool (off while disabled)", enabled: config.replaceWithinEnabled !== false },
29
+ { key: "replaceMatchEnabled", label: "Replace match", hint: "replace_match tool (off while disabled)", enabled: config.replaceMatchEnabled !== false },
30
30
  ];
31
31
  }
32
32