@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.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/core/types.ts CHANGED
@@ -12,8 +12,8 @@ export interface Anchor {
12
12
 
13
13
  /**
14
14
  * Edit operation. Every line-numbered op references a line via {@link Anchor} —
15
- * the line number is for humans, the hash for machine verification; both must
16
- * match the snapshot at once.
15
+ * the line number is the address, the hash a checksum that the line at that
16
+ * address is still what was read; both must match at apply time.
17
17
  */
18
18
  export type Edit =
19
19
  | { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
@@ -25,47 +25,63 @@ export type Edit =
25
25
 
26
26
  export type LineEnding = "lf" | "crlf";
27
27
 
28
- /** File snapshot: original text recorded at read time + per-line context-aware hash. */
29
- export interface FileSnapshot {
30
- readonly path: string;
31
- /** `lineHashes[i]` = hash of line (i+1); length always equals the file's line count. */
32
- readonly lineHashes: readonly string[];
33
- readonly text: string;
34
- /** Hash length used when generating lineHashes; apply must reuse it for the new snapshot to avoid length drift on the next verification. */
35
- readonly hashLen: number;
36
- /** Original file line ending (lf/crlf); apply restores it so a CRLF file keeps its line endings after edit. */
37
- readonly lineEnding: LineEnding;
38
- }
28
+ /**
29
+ * Outcome of shifted-anchor recovery. When a cited anchor's hash no longer
30
+ * matches the live content, the applicator rescans ±radius lines for the
31
+ * original content holding the ORIGINAL line number fixed and re-hashing each
32
+ * candidate's content (`computeLineHash(citedLine, candidateContent) === citedHash`
33
+ * iff the candidate is the original content). A ready-to-resend anchor (with the
34
+ * freshly computed hash) is returned so the model can retry without a re-read.
35
+ *
36
+ * - `found` exactly one nearby line holds the original content; resend the op
37
+ * with the provided anchor.
38
+ * - `ambiguous` — several nearby lines match (e.g. duplicate content); the model
39
+ * picks the right one from the candidates (each carries its own new hash).
40
+ * - `none` — the content genuinely changed; re-read.
41
+ */
42
+ export type AnchorRecovery =
43
+ | { readonly kind: "found"; readonly newLine: number; readonly newHash: string }
44
+ | {
45
+ readonly kind: "ambiguous";
46
+ readonly candidates: ReadonlyArray<{ readonly line: number; readonly hash: string }>;
47
+ }
48
+ | { readonly kind: "none" };
39
49
 
40
- /** A single parsed file patch. */
41
- export interface ParsedPatch {
42
- readonly path: string;
43
- readonly edits: Edit[];
50
+ /**
51
+ * A single anchor that failed verification, with its recovery attempt.
52
+ *
53
+ * `opIndex` is the 0-based position in the input `edits[]`; `which` names the
54
+ * op's anchor (`"anchor"` = start, `"end"` = range end); `op` is the op kind.
55
+ * `current` is the cited line's live content + hash (null if the line number is
56
+ * out of range) — surfaced when recovery is `none` so the model can self-diagnose.
57
+ */
58
+ export interface AnchorFailure {
59
+ readonly opIndex: number;
60
+ readonly which: "anchor" | "end";
61
+ readonly op: Edit["op"];
62
+ readonly cited: Anchor;
63
+ readonly recovery: AnchorRecovery;
64
+ readonly current: { readonly hash: string; readonly content: string } | null;
44
65
  }
45
66
 
46
- /** Error kinds. */
47
- export type PatchErrorKind =
48
- | "parse" // malformed input
49
- | "stale" // file changed (current text !== snapshot.text)
50
- | "anchor" // anchor hash does not match the snapshot (model misremembered) or line out of range
51
- | "collision" // hash appears at multiple lines, cannot locate uniquely
52
- | "range" // illegal operation range (overlap, reverse order, spanning a gap, etc.)
53
- | "noop"; // edit produced no change (body byte-identical to the target)
54
-
55
- export interface PatchError {
56
- readonly kind: PatchErrorKind;
57
- readonly message: string;
58
- /** Line number (1-based) in the input patch, for error localization. */
59
- readonly line?: number;
60
- }
67
+ /** Batch-level failure. `anchor` carries every per-anchor failure collected across the batch. */
68
+ export type ApplyFailure =
69
+ | { readonly kind: "anchor"; readonly failures: readonly AnchorFailure[] }
70
+ | { readonly kind: "range"; readonly message: string }
71
+ | { readonly kind: "noop"; readonly message: string };
61
72
 
62
- /** Apply result. */
73
+ /**
74
+ * Apply result. On success, `touchedLines` lists the 0-based line indices in
75
+ * the NEW file that this edit produced (inserted or replaced) — callers use it
76
+ * to surface fresh `LINE#HASH` anchors so the model can chain edits without a
77
+ * re-read. On failure, `failure` is either the collected set of anchor failures
78
+ * (each with recovery) or a single range/noop error; nothing is written.
79
+ */
63
80
  export type ApplyResult =
64
81
  | {
65
82
  readonly ok: true;
66
83
  readonly text: string;
67
- readonly newSnapshot: FileSnapshot;
68
84
  readonly changed: boolean;
69
- readonly diff: string;
85
+ readonly touchedLines: readonly number[];
70
86
  }
71
- | { readonly ok: false; readonly error: PatchError };
87
+ | { readonly ok: false; readonly failure: ApplyFailure };
package/src/index.ts CHANGED
@@ -1,32 +1,31 @@
1
1
  /**
2
2
  * pi-hashline-edit extension entry.
3
3
  *
4
- * Overrides the built-in read/edit: read outputs "lineNo#hash│content" and
5
- * records a snapshot; edit accepts only a hashline patch (LINE#HASH anchors),
6
- * and legacy oldText/newText is rejected explicitly (no silent degradation).
7
- * The renderer is inherited from the built-in automatically.
4
+ * Overrides the built-in read/edit: read outputs "lineNo#hash│content";
5
+ * edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
6
+ * legacy oldText/newText is rejected explicitly (no silent degradation). Each
7
+ * tool carries its own renderer.
8
8
  *
9
9
  * @module pi-hashline-edit
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { loadConfig } from "./pi/config.ts";
14
- import { clearSnapshots, getState } from "./pi/state.ts";
14
+ import { getState } from "./pi/state.ts";
15
15
  import { makeEditOverride } from "./pi/edit-tool.ts";
16
16
  import { makeReadOverride } from "./pi/read-tool.ts";
17
+ import { makeGrepOverride } from "./pi/grep-tool.ts";
17
18
 
18
19
  export default function (pi: ExtensionAPI) {
19
20
  const cwd = process.cwd();
20
21
 
21
- // refresh config and snapshots on session start / reload
22
+ // refresh config on session start / reload
22
23
  pi.on("session_start", async () => {
23
- const config = loadConfig(cwd);
24
24
  const state = getState();
25
- state.config = config;
26
- state.hashLen = config.hashLen;
27
- clearSnapshots();
25
+ state.config = loadConfig(cwd);
28
26
  });
29
27
 
30
28
  pi.registerTool(makeReadOverride(cwd));
31
29
  pi.registerTool(makeEditOverride(cwd));
30
+ pi.registerTool(makeGrepOverride(cwd));
32
31
  }
package/src/pi/config.ts CHANGED
@@ -15,9 +15,11 @@ export interface HashlineEditConfig {
15
15
  enabled: boolean;
16
16
  /** Line hash length (default 4). */
17
17
  hashLen: number;
18
+ /** ±line radius for shifted-anchor recovery (default 15; 0 disables rescue). */
19
+ shiftRadius: number;
18
20
  }
19
21
 
20
- const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
22
+ const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
21
23
 
22
24
  function getAgentDir(): string {
23
25
  const envDir = process.env.PI_AGENT_DIR;
@@ -49,5 +51,9 @@ export function loadConfig(cwd?: string): HashlineEditConfig {
49
51
  typeof raw.hashLen === "number" && raw.hashLen >= 2 && raw.hashLen <= 8
50
52
  ? raw.hashLen
51
53
  : DEFAULT_CONFIG.hashLen,
54
+ shiftRadius:
55
+ typeof raw.shiftRadius === "number" && raw.shiftRadius >= 0 && raw.shiftRadius <= 100
56
+ ? raw.shiftRadius
57
+ : DEFAULT_CONFIG.shiftRadius,
52
58
  };
53
59
  }
@@ -1,101 +1,176 @@
1
1
  /**
2
- * Override edit: accepts only a hashline patch (`input`).
2
+ * Override edit: hashline ops via structured `edits` (LINE#HASH anchors).
3
3
  *
4
- * Not backward-compatible with legacy oldText/newText when legacy input is
5
- * detected it errors explicitly so the developer knows the model didn't use the
6
- * new approach, rather than silently degrading. Tolerance is limited to
7
- * format-only normalizations that don't affect the result (optional colon, CRLF
8
- * at the parse layer); paradigm-level compatibility is refused outright.
4
+ * Each op in `edits` references line anchors copied from read output (or from a
5
+ * prior edit's "Updated anchors"). The core verifies each anchor live against
6
+ * the current file content — no snapshot, no global stale check: a cited line
7
+ * that changed (or was misremembered) fails its own anchor; unchanged lines
8
+ * elsewhere never block the edit. Legacy oldText/newText is not accepted — the
9
+ * schema requires an `op` discriminator, so legacy payloads are rejected at the
10
+ * schema layer (a visible failure, never a silent degradation).
9
11
  *
10
- * Concurrency safety: the read-modify-write is wrapped in
11
- * withFileMutationQueue, serializing multiple edits to the same file to prevent
12
- * data loss under pi's default parallel execution. AbortSignal is honored —
13
- * checked after read / before write, so a user cancel never touches the disk.
12
+ * On success the result carries fresh `LINE#HASH` anchors for the lines this
13
+ * edit produced (and the line that shifted into a deletion gap), so the model
14
+ * can chain edits without a re-read.
15
+ *
16
+ * Concurrency safety: read-modify-write is wrapped in withFileMutationQueue.
17
+ * AbortSignal is honored — checked after read / before write.
14
18
  *
15
19
  * @module pi-hashline-edit/pi
16
20
  */
17
21
 
18
- import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
22
+ import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, type EditToolDetails } from "@earendil-works/pi-coding-agent";
19
23
  import { Type, type Static } from "typebox";
24
+ import { Text } from "@earendil-works/pi-tui";
20
25
  import { readFile, writeFile } from "node:fs/promises";
21
- import { applyEdits, parsePatch } from "../core/index.ts";
22
- import type { Edit, FileSnapshot, PatchError } from "../core/types.ts";
26
+ import { applyEdits, hashFileLines } from "../core/index.ts";
27
+ import { splitLines } from "../core/lines.ts";
28
+ import type { ApplyFailure, Edit } from "../core/types.ts";
23
29
  import { canonicalPath } from "./read-tool.ts";
24
- import { getState, getSnapshot, putSnapshot, recordSnapshot } from "./state.ts";
25
- import { Text } from "@earendil-works/pi-tui";
30
+ import { getState } from "./state.ts";
31
+
32
+ /** Cap on the number of updated anchors returned inline (bounds token cost for large inserts). */
33
+ const MAX_ANCHOR_LINES = 40;
26
34
 
27
- // The schema deliberately omits additionalProperties:false: when the model
28
- // mistakenly sends legacy edits/oldText/newText, those extra fields reach
29
- // execute as-is and are caught and rejected explicitly by missingInputError.
30
- // This relies on typebox allowing extra properties by default + pi validation
31
- // not stripping them — do not change either of these.
35
+ const anchorSchema = Type.Object({
36
+ line: Type.Number({ description: "1-based line number" }),
37
+ hash: Type.String({ description: "Line content hash copied from read output (the #HASH after the line number)" }),
38
+ });
39
+
40
+ const editOpSchema = Type.Object({
41
+ op: Type.Union(
42
+ [
43
+ Type.Literal("replace"),
44
+ Type.Literal("delete"),
45
+ Type.Literal("insert_after"),
46
+ Type.Literal("insert_before"),
47
+ Type.Literal("append"),
48
+ Type.Literal("prepend"),
49
+ ],
50
+ { description: "Operation kind" },
51
+ ),
52
+ anchor: Type.Optional(anchorSchema),
53
+ end: Type.Optional(anchorSchema),
54
+ body: Type.Optional(Type.Array(Type.String(), { description: "New content lines (required for replace/insert/append/prepend; omit for delete)" })),
55
+ });
32
56
 
33
57
  const editSchema = Type.Object({
34
58
  path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
35
- input: Type.Optional(
36
- Type.String({
37
- description:
38
- "Hashline patch referencing LINE#HASH anchors from your latest read. Ops: replace / delete / insert_after / insert_before / append / prepend. Body rows start with `+`. This tool does NOT accept legacy oldText/newText.",
39
- }),
40
- ),
59
+ edits: Type.Array(editOpSchema, { description: "Hashline ops, each referencing LINE#HASH anchors from your latest read or edit result" }),
41
60
  });
42
61
 
43
- /** Turn a PatchError into helpful hint text for the model. */
44
- function errorText(e: PatchError, path: string): string {
45
- switch (e.kind) {
46
- case "stale":
47
- return `File ${path} changed since your last read. Re-read it before editing.`;
48
- case "anchor":
49
- return `Anchor mismatch: ${e.message}. Re-read ${path} to get current line hashes (LINE#HASH).`;
50
- case "collision":
51
- return `Hash collision: ${e.message}. Re-read ${path}.`;
52
- case "range":
53
- return `Bad range: ${e.message}`;
54
- case "noop":
55
- return `Edit produced no change: ${e.message}`;
56
- case "parse":
57
- return `Parse error${e.line ? ` at line ${e.line}` : ""}: ${e.message}`;
62
+ type EditOpInput = Static<typeof editOpSchema>;
63
+
64
+ /**
65
+ * Turn an ApplyFailure into LLM-facing text. The FIRST line is a terse summary
66
+ * (the TUI's renderResult shows only the first line of an error result); the
67
+ * remaining lines carry the structured detail the model needs to retry without a
68
+ * re-read rescued anchors / ambiguous candidates / the cited line's live
69
+ * content. range/noop are already terse single-line messages.
70
+ */
71
+ function formatFailure(failure: ApplyFailure, path: string): string {
72
+ if (failure.kind === "range") return failure.message;
73
+ if (failure.kind === "noop") return failure.message;
74
+
75
+ const lines: string[] = [];
76
+ let found = 0;
77
+ let ambiguous = 0;
78
+ let none = 0;
79
+ for (const f of failure.failures) {
80
+ const where = `op #${f.opIndex} ${f.op} ${f.which} (line ${f.cited.line})`;
81
+ switch (f.recovery.kind) {
82
+ case "found": {
83
+ found++;
84
+ lines.push(
85
+ `• ${where}: content shifted to line ${f.recovery.newLine}. Resend this op with ${f.which} { "line": ${f.recovery.newLine}, "hash": "${f.recovery.newHash}" }.`,
86
+ );
87
+ break;
88
+ }
89
+ case "ambiguous": {
90
+ ambiguous++;
91
+ const nums = f.recovery.candidates.map((c) => c.line).join(", ");
92
+ const list = f.recovery.candidates
93
+ .map((c) => `{ "line": ${c.line}, "hash": "${c.hash}" }`)
94
+ .join(" / ");
95
+ lines.push(
96
+ `• ${where}: ambiguous — same content at lines ${nums}. Pick the right one and resend ${f.which} ${list}.`,
97
+ );
98
+ break;
99
+ }
100
+ case "none": {
101
+ none++;
102
+ const cur =
103
+ f.current != null
104
+ ? `current line ${f.cited.line}: ${f.cited.line}#${f.current.hash}│${f.current.content}`
105
+ : `line ${f.cited.line} is out of range`;
106
+ lines.push(`• ${where}: not found nearby — content changed. ${cur}. Re-read ${path} for fresh anchors.`);
107
+ break;
108
+ }
109
+ }
58
110
  }
111
+ const parts: string[] = [];
112
+ if (found) parts.push(`${found} rescued`);
113
+ if (ambiguous) parts.push(`${ambiguous} ambiguous`);
114
+ if (none) parts.push(`${none} need re-read`);
115
+ const brief = `Anchor mismatch: ${parts.join(", ")}.`;
116
+ return `${brief}\n${lines.join("\n")}`;
59
117
  }
60
118
 
61
- /** Get the first changed line after applying (for firstChangedLine / TUI jump). insert_after uses anchor + 1. */
62
- function minAnchorLine(edits: readonly Edit[]): number | undefined {
63
- let min: number | undefined;
64
- for (const e of edits) {
65
- let line: number | undefined;
66
- if (e.op === "replace" || e.op === "delete") line = e.start.line;
67
- else if (e.op === "insert_after") line = e.anchor.line + 1;
68
- else if (e.op === "insert_before") line = e.anchor.line;
69
- else if (e.op === "prepend") line = 1;
70
- // append has no anchor, skip (trailing append)
71
- if (line !== undefined && (min === undefined || line < min)) min = line;
119
+ /** Translate JSON edit ops into core Edit[]. Validates conditional required fields (anchor/body per op). */
120
+ function toCoreEdits(ops: readonly EditOpInput[]): { ok: true; edits: Edit[] } | { ok: false; error: string } {
121
+ const edits: Edit[] = [];
122
+ for (const o of ops) {
123
+ switch (o.op) {
124
+ case "replace":
125
+ if (!o.anchor) return { ok: false, error: "replace needs `anchor` {line, hash}" };
126
+ if (!o.body) return { ok: false, error: "replace needs `body`" };
127
+ edits.push({ op: "replace", start: o.anchor, end: o.end, body: o.body });
128
+ break;
129
+ case "delete":
130
+ if (!o.anchor) return { ok: false, error: "delete needs `anchor` {line, hash}" };
131
+ edits.push({ op: "delete", start: o.anchor, end: o.end });
132
+ break;
133
+ case "insert_after":
134
+ case "insert_before":
135
+ if (!o.anchor) return { ok: false, error: `${o.op} needs \`anchor\` {line, hash}` };
136
+ if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
137
+ edits.push({ op: o.op, anchor: o.anchor, body: o.body });
138
+ break;
139
+ case "append":
140
+ case "prepend":
141
+ if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
142
+ edits.push({ op: o.op, body: o.body });
143
+ break;
144
+ }
72
145
  }
73
- return min;
146
+ return { ok: true, edits };
74
147
  }
75
148
 
76
149
  /**
77
- * The model didn't use hashline (sent legacy oldText/newText or is missing
78
- * input) tell it explicitly, don't silently degrade. Exported for testing.
79
- * params is `any` so it can detect legacy fields outside the schema.
150
+ * Fail the edit by throwing. pi's contract: a tool failure is signaled by throwing,
151
+ * not by returning `{ isError: true }` the framework derives `context.isError` from
152
+ * whether execute threw, and overwrites `result.isError` with it
153
+ * (`updateResult({ ...result, isError: event.isError })`). Returning an isError object
154
+ * left the TUI rendering failures as success (green). The thrown message reaches the
155
+ * LLM verbatim; renderResult shows its first line in red.
80
156
  */
81
- export function missingInputError(path: string, params: any): string {
82
- const legacy =
83
- Array.isArray(params?.edits) ||
84
- typeof params?.oldText === "string" ||
85
- typeof params?.newText === "string";
86
- if (legacy) {
87
- return `⚠ ${path}: you sent the legacy oldText/newText format, but this edit tool ONLY accepts hashline \`input\`. The legacy path was intentionally removed so this is surfaced, not silently degraded. Re-read ${path} to get LINE#HASH anchors, then send \`input\` (e.g. \`replace 4#aF3:\` followed by \`+\` body rows).`;
88
- }
89
- return `Edit ${path}: missing \`input\` (hashline patch). Read ${path} first, then send \`input\` referencing LINE#HASH anchors.`;
157
+ function errResult(text: string): never {
158
+ throw new Error(text);
90
159
  }
91
160
 
92
- /** Build an error result (with isError: true so the TUI/agent loop treats it as a failure, not a success). */
93
- function errResult(text: string) {
94
- return {
95
- isError: true as const,
96
- content: [{ type: "text" as const, text }],
97
- details: undefined,
98
- };
161
+ /**
162
+ * Format the updated anchors (fresh LINE#HASH│content) for the touched new-file
163
+ * lines, so the model can chain edits without a re-read. Capped to bound tokens.
164
+ */
165
+ function formatUpdatedAnchors(newText: string, touched: readonly number[], hashLen: number): string {
166
+ const newLines = splitLines(newText);
167
+ const newHashes = hashFileLines(newLines, hashLen);
168
+ const idxs = [...new Set(touched)].sort((a, b) => a - b);
169
+ if (idxs.length === 0) return "";
170
+ const rows = idxs.map((i) => `${i + 1}#${newHashes[i]}│${newLines[i]}`);
171
+ const shown = rows.length > MAX_ANCHOR_LINES ? rows.slice(0, MAX_ANCHOR_LINES) : rows;
172
+ const more = rows.length > MAX_ANCHOR_LINES ? `\n… (${rows.length - MAX_ANCHOR_LINES} more; re-read for full anchors)` : "";
173
+ return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
99
174
  }
100
175
 
101
176
  export function makeEditOverride(cwd: string) {
@@ -105,32 +180,30 @@ export function makeEditOverride(cwd: string) {
105
180
  name: "edit" as const,
106
181
  label: "edit",
107
182
  description:
108
- "Edit a file via hashline patch (LINE#HASH anchors, content-verified). Does NOT accept legacy oldText/newText.",
109
- promptSnippet: "Edit files via hashline LINE#HASH anchors (input patch); legacy oldText/newText not accepted",
183
+ "Edit a file via hashline ops (LINE#HASH anchors, content-verified). Each op in `edits` references line anchors from your latest read or edit result.",
184
+ promptSnippet: "Edit files via hashline ops (edits[] with LINE#HASH anchors from read)",
110
185
  promptGuidelines: [
111
- "Pass `input`: a hashline patch referencing `LINE#HASH` anchors copied from your latest read output (e.g. `replace 12#aF3:`).",
112
- "Ops: `replace LINE#HASH[..LINE#HASH]:` · `delete LINE#HASH` · `insert_after LINE#HASH:` · `insert_before LINE#HASH:` · `append:` · `prepend:`.",
113
- "Body rows start with `+` followed by the literal line. `+` alone = blank line. Literal `+`/`-` lines become `++`/`+-`.",
114
- "After each successful edit, re-ground: line numbers shift, so take the next edit's anchors from a fresh read.",
115
- "This tool does NOT accept legacy oldText/newTextsending those returns an error (intentional, so it's visible).",
186
+ "Pass `edits`: an array of ops. Each op = {op, anchor?, end?, body?}.",
187
+ "op replace | delete | insert_after | insert_before | append | prepend.",
188
+ "anchor & end = {line, hash} copied from your latest read or edit result (the `#HASH` after each line number). replace/delete take anchor (+ optional end for a range); insert_after/insert_before take anchor; append/prepend take neither.",
189
+ "body = string[] of new content lines (required for replace/insert/append/prepend; omit for delete).",
190
+ "A successful edit returns `Updated anchors` for the changed lines use those (not stale line numbers) for the next edit to the same file; re-read only if you need lines outside that set.",
116
191
  ],
117
192
  parameters: editSchema,
118
- renderShell: "self" as const,
193
+ renderShell: "default" as const,
119
194
 
120
195
  renderCall(args: Static<typeof editSchema>, theme: any) {
121
196
  let text = theme.fg("toolTitle", theme.bold("edit "));
122
197
  text += theme.fg("accent", args.path);
123
- if (args.input) {
124
- const firstOp = args.input.split("\n").find((l) => l.trim() && !l.startsWith("+"));
125
- if (firstOp) text += theme.fg("dim", ` — ${firstOp.trim()}`);
126
- }
198
+ const n = args.edits?.length ?? 0;
199
+ if (n) text += theme.fg("dim", ` ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
127
200
  return new Text(text, 0, 0);
128
201
  },
129
202
 
130
- renderResult(result: any, { isPartial, expanded }: any, theme: any) {
203
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
131
204
  if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
132
205
  const content = result.content?.[0];
133
- if (result.isError) {
206
+ if (context.isError) {
134
207
  const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
135
208
  return new Text(theme.fg("error", t), 0, 0);
136
209
  }
@@ -160,19 +233,19 @@ export function makeEditOverride(cwd: string) {
160
233
 
161
234
  const path = params.path;
162
235
  const absPath = canonicalPath(cwd, path);
163
- const input = params.input;
164
236
 
165
- // No input → tell explicitly (distinguish legacy vs missing), don't silently degrade
166
- if (typeof input !== "string" || input.trim() === "") {
167
- return errResult(missingInputError(path, params as any));
237
+ if (!params.edits?.length) {
238
+ return errResult(`Edit ${path}: \`edits\` is empty or missing.`);
168
239
  }
169
240
  // withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
170
- return await withFileMutationQueue(absPath, () => runHashline(absPath, path, input, signal));
241
+ return await withFileMutationQueue(absPath, () => runHashline(absPath, path, params.edits, signal));
171
242
  },
172
243
  };
173
244
  }
174
245
 
175
- async function runHashline(absPath: string, displayPath: string, input: string, signal: AbortSignal | undefined) {
246
+ async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
247
+ const { hashLen, shiftRadius } = getState().config;
248
+
176
249
  let currentText: string;
177
250
  try {
178
251
  currentText = (await readFile(absPath)).toString("utf-8");
@@ -183,20 +256,17 @@ async function runHashline(absPath: string, displayPath: string, input: string,
183
256
  // Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
184
257
  if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
185
258
 
186
- // Use the recorded snapshot, or if there is none (the model didn't read) build one from the
187
- // current file — anchor verification still forces the model to use a real hash (it can't guess
188
- // correctly without reading), naturally steering it to read first.
189
- const snap: FileSnapshot = getSnapshot(absPath) ?? recordSnapshot(absPath, currentText);
190
-
191
- // input has no file header; prepend one so parsePatch passes (path is just a label)
192
- const parsed = parsePatch(`file: ${absPath}\n\n${input}`);
193
- if (!parsed.ok) {
194
- return errResult(errorText(parsed.error, displayPath));
195
- }
259
+ const translated = toCoreEdits(editOps);
260
+ if (!translated.ok) return errResult(translated.error);
196
261
 
197
- const result = applyEdits(currentText, parsed.patch.edits, snap);
262
+ // Anchors are verified against the current content. A line that changed (or a
263
+ // hash the model didn't actually read) fails its own anchor — but first we try
264
+ // shifted recovery: if the content merely moved within ±shiftRadius, a fresh
265
+ // anchor is returned so the model can retry without a re-read. All failures in
266
+ // the batch are collected (nothing written on any failure).
267
+ const result = applyEdits(currentText, translated.edits, hashLen, shiftRadius);
198
268
  if (!result.ok) {
199
- return errResult(errorText(result.error, displayPath));
269
+ return errResult(formatFailure(result.failure, displayPath));
200
270
  }
201
271
 
202
272
  // Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
@@ -209,20 +279,16 @@ async function runHashline(absPath: string, displayPath: string, input: string,
209
279
  return errResult(`Error writing ${displayPath}: ${msg}`);
210
280
  }
211
281
 
212
- // Update the snapshot: consecutive edits need no re-read (result.newSnapshot is based on the new text), via LRU
213
- putSnapshot(absPath, result.newSnapshot);
214
-
282
+ // pi's generateDiffString returns the display diff (colored by the renderer) and the first changed line
283
+ const { diff, firstChangedLine } = generateDiffString(currentText, result.text);
284
+ const details: EditToolDetails = {
285
+ diff,
286
+ patch: generateUnifiedPatch(displayPath, currentText, result.text),
287
+ firstChangedLine,
288
+ };
289
+ const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
215
290
  return {
216
- content: [
217
- { type: "text" as const, text: `Edited ${displayPath} (${parsed.patch.edits.length} op(s)).` },
218
- ],
219
- details: {
220
- // details.diff must use pi's generateDiffString (+N content format); the built-in
221
- // renderer's parseDiffLine only recognizes this format — core's standard unified diff
222
- // would be grayed out as plain text
223
- diff: generateDiffString(currentText, result.text),
224
- patch: generateUnifiedPatch(displayPath, currentText, result.text),
225
- firstChangedLine: minAnchorLine(parsed.patch.edits),
226
- },
291
+ content: [{ type: "text" as const, text: `Edited ${displayPath} (${translated.edits.length} op(s)).${anchors}` }],
292
+ details,
227
293
  };
228
294
  }