pi-hashline-edit-pro 6.2.0 → 6.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.
package/src/insert.ts CHANGED
@@ -3,9 +3,9 @@ import { Type } from "typebox";
3
3
  import { constants } from "node:fs";
4
4
  import { execPipeline, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
- import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
6
+ import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
- import { MAX_HASH_LINES, parseHashRef, parsePayloadText, resEdit, resolveAnchorLine, type Anchor, type HEdit, type HTEdit, type StripWarningLocation } from "./hashline";
8
+ import { MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, type Anchor, type HTEdit, type StripWarningLocation } from "./hashline";
9
9
  import type { LineEnding } from "./normalize";
10
10
  import { stripAnchorRow } from "./hashline/resolve";
11
11
  import { withAnchorSession } from "./anchor-registry";
@@ -97,6 +97,37 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
97
97
  return { label: "text", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
98
98
  }
99
99
 
100
+ function planInsertBatchMember(input: BatchPlannerInput): BatchMemberPlan {
101
+ const req = normReq(input.member.args);
102
+ assertInsertReq(req);
103
+ const insertWarnings = literalEscapeHints([req.text], "text");
104
+ const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
105
+ const basePreload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
106
+ const built = buildInsertEdit(req, basePreload, ref, input.displayPath);
107
+ const resWarnings: string[] = [];
108
+ const hedit = resEdit(built.editParams, resWarnings);
109
+ const stripWarning = insertStripWarning(built.anchorLine, req.direction);
110
+ const effectiveHedit = preserveDeletionSeparators(hedit, input.base.baseLines, input.base.hashes);
111
+ const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
112
+ filePath: input.displayPath,
113
+ servedHashes: input.served,
114
+ signal: input.signal,
115
+ baseFileLines: input.base.baseLines,
116
+ stripWarning,
117
+ });
118
+ return {
119
+ hedit,
120
+ warnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
121
+ planned,
122
+ foldedLines: built.anchorLine === undefined ? 0 : 1,
123
+ direction: req.direction,
124
+ stripWarning,
125
+ contentSeparators: built.contentSeparators,
126
+ };
127
+ }
128
+
129
+ registerBatchPlanner("insert", planInsertBatchMember);
130
+
100
131
  export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
101
132
  try {
102
133
  const normalized = normReq(request);
@@ -180,12 +211,13 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
180
211
  assertInsertReq(canonical);
181
212
  const req = canonical;
182
213
  const insertWarnings: string[] = [...literalEscapeHints([req.text], "text")];
214
+ const member = batchMemberFor(_toolCallId);
183
215
  const targetPath = await resolveEditTargetWithRequirement({
184
216
  anchor: req.anchor,
185
217
  providedPath: req.path,
186
218
  cwd: ctx.cwd,
219
+ anchorTarget: member?.target,
187
220
  }).catch((error: unknown) => {
188
- const member = batchMemberFor(_toolCallId);
189
221
  if (member) noteBatchFailure(member, error);
190
222
  throw error;
191
223
  });
@@ -193,40 +225,14 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
193
225
  let anchorWarnings: string[];
194
226
  try {
195
227
  ({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
196
- await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
228
+ if (member === undefined) await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
197
229
  } catch (error) {
198
- const member = batchMemberFor(_toolCallId);
199
230
  if (member) noteBatchFailure(member, error);
200
231
  throw error;
201
232
  }
202
233
  return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
203
- const member = batchMemberFor(_toolCallId);
204
234
  if (member) {
205
- const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
206
- const basePreload = { normalized: base.content, fileHashes: base.hashes } as NormFile;
207
- const built = buildInsertEdit(req, basePreload, ref, targetPath);
208
- let hedit: HEdit;
209
- const resWarnings: string[] = [];
210
- try {
211
- hedit = resEdit(built.editParams, resWarnings);
212
- } catch (error) {
213
- noteBatchFailure(member, error);
214
- throw error;
215
- }
216
- return executeBatchMember({
217
- kind: "insert",
218
- direction: req.direction,
219
- member,
220
- targetPath,
221
- mutationTargetPath,
222
- cwd: ctx.cwd,
223
- signal,
224
- hedit,
225
- extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
226
- foldedLines: built.anchorLine === undefined ? 0 : 1,
227
- stripWarning: insertStripWarning(built.anchorLine, req.direction),
228
- contentSeparators: built.contentSeparators,
229
- });
235
+ return executeBatchMember({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
230
236
  }
231
237
  const preload = await readNormFile(targetPath, ctx.cwd, {
232
238
  signal,
@@ -75,9 +75,18 @@ export function separatorsForSpans(
75
75
  spans: readonly EndingSpan[],
76
76
  resultContent: string,
77
77
  fallback: LineEnding,
78
+ baseLines?: readonly string[],
78
79
  ): LineEnding[] {
80
+ const consumed = new Set<number>();
81
+ if (baseLines !== undefined && baseSeparators.length < baseLineCount) {
82
+ for (const span of spans) {
83
+ if (span.replacementCount !== 0 || span.end !== baseLineCount - 1) continue;
84
+ const previous = baseLines[span.start - 1];
85
+ if (previous !== undefined && previous.trim().length > 0) consumed.add(span.start - 1);
86
+ }
87
+ }
79
88
  const mapped = sourceLineMap(baseLineCount, spans).map((source) =>
80
- source >= 0 && source < baseSeparators.length ? baseSeparators[source]! : "",
89
+ source >= 0 && source < baseSeparators.length && !consumed.has(source) ? baseSeparators[source]! : "",
81
90
  );
82
91
  const needed = countNewlines(resultContent);
83
92
  const result: LineEnding[] = [];
@@ -23,6 +23,7 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
23
23
  languageName: parsed.languageName,
24
24
  totalLines: lines.length,
25
25
  hashes: input.hashes,
26
+ lines,
26
27
  symbols: parsed.symbols,
27
28
  ...(input.maxRows !== undefined ? { maxRows: input.maxRows } : {}),
28
29
  ...(input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}),
@@ -27,6 +27,7 @@ export interface SymbolOutlineInput {
27
27
  languageName?: string;
28
28
  totalLines: number;
29
29
  hashes: string[];
30
+ lines: string[];
30
31
  symbols: OutlineSymbol[];
31
32
  maxRows?: number;
32
33
  maxDepth?: number;
@@ -62,7 +63,8 @@ function symbolLabel(symbol: OutlineSymbol, depth: number): string {
62
63
  ? ` ${symbol.detail.length > MAX_DETAIL_CHARS ? `${symbol.detail.slice(0, MAX_DETAIL_CHARS - 3)}...` : symbol.detail}`
63
64
  : "";
64
65
  const children = symbol.children !== undefined && symbol.children.length > 0 ? ` (${symbol.children.length} children)` : "";
65
- return `${indent}${symbol.type}${name}${detail}${children} [limit ${symbol.endLine - symbol.startLine + 1}]`;
66
+ const span = symbol.endLine - symbol.startLine + 1;
67
+ return `${indent}${symbol.type}${name}${detail}${children}${span > 1 ? ` [limit ${span}]` : ""}`;
66
68
  }
67
69
 
68
70
  export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
@@ -92,7 +94,9 @@ export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
92
94
 
93
95
  const walk = (symbols: OutlineSymbol[], depth: number): boolean => {
94
96
  for (const symbol of symbols) {
95
- if (!addRow(symbol.startLine, symbolLabel(symbol, depth))) return false;
97
+ const contentLine = symbol.endLine === symbol.startLine ? input.lines[symbol.startLine - 1] : undefined;
98
+ const text = contentLine === undefined ? symbolLabel(symbol, depth) : clipLine(contentLine, PREVIEW_LINE_CHARS);
99
+ if (!addRow(symbol.startLine, text)) return false;
96
100
  const children = symbol.children ?? [];
97
101
  if (children.length === 0) continue;
98
102
  if (depth >= maxDepth) {
package/src/read.ts CHANGED
@@ -49,10 +49,11 @@ export function formatPaginationHint(
49
49
  endLine: number,
50
50
  totalLines: number,
51
51
  nextOffset: number,
52
+ lastAnchor?: string,
52
53
  byteLimit?: number,
53
54
  ): string {
54
55
  const sizeSuffix = byteLimit !== undefined ? ` (${formatSize(byteLimit)} limit)` : "";
55
- return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${nextOffset} to continue.]`;
56
+ return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${lastAnchor ?? nextOffset} to continue.]`;
56
57
  }
57
58
 
58
59
  export async function fmtReadPreview(
@@ -147,7 +148,7 @@ export async function fmtReadPreview(
147
148
  let nextOffset: number | undefined;
148
149
  if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
149
150
  nextOffset = lastShownLine + 1;
150
- preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
151
+ preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, servedHashes.at(-1), skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
151
152
  } else {
152
153
  preview += `\n\n${warning}`;
153
154
  }
@@ -175,13 +176,13 @@ export async function fmtReadPreview(
175
176
  const endLineDisplay = startLine + truncation.outputLines - 1;
176
177
  nextOffset = endLineDisplay + 1;
177
178
  if (truncation.truncatedBy === "lines") {
178
- preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset)}`;
179
+ preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, servedHashes.at(-1))}`;
179
180
  } else {
180
- preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, truncation.maxBytes)}`;
181
+ preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, servedHashes.at(-1), truncation.maxBytes)}`;
181
182
  }
182
183
  } else if (endIdx < totalLines) {
183
184
  nextOffset = endIdx + 1;
184
- preview += `\n\n${formatPaginationHint(startLine, endIdx, totalLines, nextOffset)}`;
185
+ preview += `\n\n${formatPaginationHint(startLine, endIdx, totalLines, nextOffset, servedHashes.at(-1))}`;
185
186
  }
186
187
 
187
188
  return {
@@ -209,10 +210,10 @@ export function resolveReadOffset(
209
210
  if (owner === undefined) {
210
211
  const folded = ownersDifferingOnlyByCase(ref.hash);
211
212
  const hint = folded.length > 0 ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.` : "";
212
- throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is not owned in this session.${hint} Call read() on ${resolvedPath} first.`);
213
+ throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is not owned in this session.${hint} Use a line number, or read again for fresh anchors.`);
213
214
  }
214
215
  if (owner.path !== resolvedPath) {
215
- throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is owned by ${owner.path}. Call read() on ${resolvedPath} for fresh anchors.`);
216
+ throw new Error(`[E_STALE_ANCHOR] "${ref.hash}" is owned by ${owner.path}. Use a line number, or read ${owner.path} to use that anchor.`);
216
217
  }
217
218
  try {
218
219
  return resolveAnchorLine(ref, fileLines, fileHashes, resolvedPath);
@@ -272,11 +273,15 @@ function renderReadCall(args: unknown, theme: any, context: any): Text {
272
273
  const index = text.lastIndexOf(marker);
273
274
  if (index < 0) return rendered;
274
275
  const line = anchorLineOf(ref.anchor, ref.path, context?.cwd);
275
- if (line === undefined && ref.limit === undefined) return rendered;
276
- const lineLabel = line === undefined ? "" : ` (${line})`;
277
- const limitLabel = ref.limit === undefined ? "" : ` +${ref.limit}`;
278
- const suffix = theme.fg("warning", `${lineLabel}${limitLabel}`);
279
- rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
276
+ if (line === undefined) {
277
+ if (ref.limit === undefined) return rendered;
278
+ const suffix = theme.fg("warning", ` +${ref.limit}`);
279
+ rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
280
+ return rendered;
281
+ }
282
+ const range = ref.limit === undefined ? `${line}` : `${line}-${line + ref.limit - 1}`;
283
+ const replacement = theme.fg("warning", `:${range} (${ref.anchor})`);
284
+ rendered.setText(`${text.slice(0, index)}${replacement}${text.slice(index + marker.length)}`);
280
285
  return rendered;
281
286
  }
282
287
 
@@ -3,9 +3,9 @@ import { constants } from "node:fs";
3
3
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
4
4
  import { commitEdit } from "./commit";
5
5
  import { readNormFile, type NormFile } from "./file-reader";
6
- import { fmtRegion, MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor, type HEdit } from "./hashline";
6
+ import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
7
7
  import { servedForPath, withAnchorSession } from "./anchor-registry";
8
- import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
8
+ import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
9
9
  import { loadP, loadGuide } from "./prompts";
10
10
  import {
11
11
  assertReplaceMatchReq,
@@ -109,6 +109,33 @@ export function buildReplaceMatchEdit(
109
109
  };
110
110
  }
111
111
 
112
+ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan {
113
+ const req = normReq(input.member.args, "replace");
114
+ assertReplaceMatchReq(req);
115
+ const { refs, warnings } = parseMatchAnchors(req);
116
+ const hints = [...literalEscapeHints([req.old_string], "old_string"), ...literalEscapeHints([req.new_string], "new_string")];
117
+ const preload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
118
+ const matchPlan = buildReplaceMatchEdit(req, refs, preload, input.displayPath, input.served);
119
+ const resWarnings: string[] = [];
120
+ const hedit = resEdit(matchPlan.editParams, resWarnings);
121
+ const effectiveHedit = preserveDeletionSeparators(hedit, input.base.baseLines, input.base.hashes);
122
+ const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
123
+ filePath: input.displayPath,
124
+ servedHashes: matchPlan.servedOverride ?? input.served,
125
+ signal: input.signal,
126
+ baseFileLines: input.base.baseLines,
127
+ });
128
+ return {
129
+ hedit,
130
+ warnings: [...warnings, ...hints, ...resWarnings],
131
+ planned,
132
+ foldedLines: 0,
133
+ ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
134
+ };
135
+ }
136
+
137
+ registerBatchPlanner("replace_match", planReplaceMatchBatchMember);
138
+
112
139
  export async function replaceMatchPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
113
140
  try {
114
141
  const normalized = normReq(request, "replace");
@@ -173,13 +200,13 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
173
200
  const normalized = normReq(params, "replace");
174
201
  assertReplaceMatchReq(normalized);
175
202
  const req = normalized;
203
+ const member = batchMemberFor(_toolCallId);
176
204
  let refs: MatchRefs;
177
205
  let warnings: string[];
178
206
  try {
179
207
  ({ refs, warnings } = parseMatchAnchors(req));
180
- await throwIfStrictInput(warnings);
208
+ if (member === undefined) await throwIfStrictInput(warnings);
181
209
  } catch (error) {
182
- const member = batchMemberFor(_toolCallId);
183
210
  if (member) noteBatchFailure(member, error);
184
211
  throw error;
185
212
  }
@@ -189,53 +216,27 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
189
216
  removeTo: req.replace_to,
190
217
  providedPath: req.path,
191
218
  cwd: ctx.cwd,
219
+ anchorTarget: member?.target,
192
220
  }).catch((error: unknown) => {
193
- const member = batchMemberFor(_toolCallId);
194
221
  if (member) noteBatchFailure(member, error);
195
222
  throw error;
196
223
  });
197
224
  return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
198
- const member = batchMemberFor(_toolCallId);
199
- let preload: NormFile;
200
225
  if (member) {
201
- const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
202
- preload = { normalized: base.content, fileHashes: base.hashes } as NormFile;
203
- } else {
204
- preload = await readNormFile(targetPath, ctx.cwd, {
205
- signal,
206
- accessMode: constants.R_OK | constants.W_OK,
207
- maxLines: MAX_HASH_LINES,
208
- });
226
+ return executeBatchMember({ member, targetPath, mutationTargetPath, cwd: ctx.cwd, signal });
209
227
  }
228
+ const preload = await readNormFile(targetPath, ctx.cwd, {
229
+ signal,
230
+ accessMode: constants.R_OK | constants.W_OK,
231
+ maxLines: MAX_HASH_LINES,
232
+ });
210
233
  let plan: MatchPlan;
211
234
  try {
212
- plan = buildReplaceMatchEdit(req, refs, preload, targetPath, member ? batchServedFor(member) : servedForPath(preload.absolutePath));
235
+ plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
213
236
  } catch (error) {
214
237
  await noteAnchorError(mutationTargetPath, error);
215
- if (member) noteBatchFailure(member, error);
216
238
  throw error;
217
239
  }
218
- if (member) {
219
- let hedit: HEdit;
220
- const resWarnings: string[] = [];
221
- try {
222
- hedit = resEdit(plan.editParams, resWarnings);
223
- } catch (error) {
224
- noteBatchFailure(member, error);
225
- throw error;
226
- }
227
- return executeBatchMember({
228
- kind: "replace_match",
229
- member,
230
- targetPath,
231
- mutationTargetPath,
232
- cwd: ctx.cwd,
233
- signal,
234
- hedit,
235
- extraWarnings: [...warnings, ...hints, ...resWarnings],
236
- ...(plan.servedOverride !== undefined ? { servedOverride: plan.servedOverride } : {}),
237
- });
238
- }
239
240
  const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
240
241
  accessMode: constants.R_OK | constants.W_OK,
241
242
  signal,
@@ -184,7 +184,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
184
184
  );
185
185
 
186
186
  const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
187
- const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
187
+ const currentHashes = await lineHashes(currentNormalized, mutationTargetPath, undefined, undefined, false, true);
188
188
  const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
189
189
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
190
190
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
package/src/replace.ts CHANGED
@@ -16,6 +16,7 @@ import { loadP, loadGuide } from "./prompts";
16
16
  import { type FileIdentity } from "./fs-write";
17
17
  import { applyEdit,
18
18
  lineHashes,
19
+ planEdit,
19
20
  resEdit,
20
21
  preserveDeletionSeparators,
21
22
  MAX_HASH_LINES,
@@ -36,7 +37,7 @@ import { resolveTarget } from "./fs-write";
36
37
  import { toCwd, toDisplayPath } from "./paths";
37
38
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withReplacePrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
38
39
  import { commitEdit } from "./commit";
39
- import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
40
+ import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
40
41
 
41
42
  export { editToolSchema, type ReqParams, assertReq };
42
43
 
@@ -126,12 +127,32 @@ function countLineChanges(
126
127
  export function buildReplaceHEdit(params: RawReqParams): { edit: HEdit; warnings: string[] } {
127
128
  const editWarnings: string[] = [];
128
129
  const anchors = { remove_from: params.remove_from, remove_to: params.remove_to };
129
- const edit = typeof params.text === "string"
130
- ? resEdit({ ...anchors, text: params.text }, editWarnings)
131
- : resEdit({ ...anchors, text: params.text }, editWarnings);
130
+ const edit = resEdit({ ...anchors, text: params.text }, editWarnings);
132
131
  return { edit, warnings: editWarnings };
133
132
  }
134
133
 
134
+ function planReplaceBatchMember(input: BatchPlannerInput): BatchMemberPlan {
135
+ const req = normReq(input.member.args, "remove");
136
+ assertReq(req);
137
+ const literalEscapes = literalEscapeHints([req.text], "text");
138
+ const built = buildReplaceHEdit(req);
139
+ const effectiveHedit = preserveDeletionSeparators(built.edit, input.base.baseLines, input.base.hashes);
140
+ const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
141
+ filePath: input.displayPath,
142
+ servedHashes: input.served,
143
+ signal: input.signal,
144
+ baseFileLines: input.base.baseLines,
145
+ });
146
+ return {
147
+ hedit: built.edit,
148
+ warnings: [...literalEscapes, ...built.warnings],
149
+ planned,
150
+ foldedLines: 0,
151
+ };
152
+ }
153
+
154
+ registerBatchPlanner("replace", planReplaceBatchMember);
155
+
135
156
  function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[] | undefined): HEdit {
136
157
  if (overrides === undefined) return edit;
137
158
  const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
@@ -292,18 +313,18 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
292
313
  assertReq(canonical);
293
314
  const normalizedParams = canonical;
294
315
  const literalEscapes = literalEscapeHints([normalizedParams.text], "text");
316
+ const member = batchMemberFor(_toolCallId);
295
317
  const targetPath = await resolveEditTargetWithRequirement({
296
318
  removeFrom: normalizedParams.remove_from,
297
319
  removeTo: normalizedParams.remove_to,
298
320
  providedPath: normalizedParams.path,
299
321
  cwd: ctx.cwd,
322
+ anchorTarget: member?.target,
300
323
  }).catch((error: unknown) => {
301
- const member = batchMemberFor(_toolCallId);
302
324
  if (member) noteBatchFailure(member, error);
303
325
  throw error;
304
326
  });
305
327
  return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
306
- const member = batchMemberFor(_toolCallId);
307
328
  if (!member) {
308
329
  const pipe = await execPipeline(
309
330
  targetPath,
@@ -320,22 +341,12 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
320
341
  signal,
321
342
  });
322
343
  }
323
- let built: { edit: HEdit; warnings: string[] };
324
- try {
325
- built = buildReplaceHEdit(normalizedParams);
326
- } catch (error) {
327
- noteBatchFailure(member, error);
328
- throw error;
329
- }
330
344
  return executeBatchMember({
331
- kind: "replace",
332
345
  member,
333
346
  targetPath,
334
347
  mutationTargetPath,
335
348
  cwd: ctx.cwd,
336
349
  signal,
337
- hedit: built.edit,
338
- extraWarnings: [...literalEscapes, ...built.warnings],
339
350
  });
340
351
  });
341
352
  }));
@@ -1,3 +1,3 @@
1
1
  - `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
2
- - `read`: pass a served anchor as `offset` with `limit` to read from it without line-number math; an outline row's `[limit N]` is the span to request.
3
- - `read`: an outline row shows a symbol label, not the file line; edit by anchor, and read the anchor (or copy `old_string` from a served row) before `replace_match`.
2
+ - `read`: pass a served anchor as `offset` with `limit` to read from it without line-number math; an outline row's `[limit N]` is the span to request, and a row without one is a single line shown as its content.
3
+ - `read`: an outline row shows a symbol label, or the line content for a single-line symbol; edit by anchor, and read the anchor (or copy `old_string` from a served row) before `replace_match`.