pi-hashline-edit-pro 0.4.3 → 0.6.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-editing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 4-character content hashes over a 64-character URL-safe base64 alphabet give 24 bits of entropy per anchor, so collisions are effectively zero in any realistic file.
3
+ A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 4-character content hashes over a 64-character URL-safe base64 alphabet give 24 bits of entropy per anchor, so collisions are effectively zero in any realistic file.
4
4
 
5
5
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design in two ways: a 4-character hash length and an occurrence-aware discriminator that makes identical content at different positions hash to different values.
6
6
 
@@ -13,8 +13,7 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
13
13
  This fork makes two changes that compound:
14
14
 
15
15
  1. **Bump hash length to 4 characters** of the 64-char URL-safe base64 alphabet. That gives 24 bits / 16 777 216 buckets. Birthday-paradox collisions are effectively nullified for any realistic file.
16
- 2. **Make the hash occurrence-aware.** The hash for line N is `xxHash32("C{occurrence}:{content}")` where `occurrence` is the running count of that content string earlier in the file. Symbol-only lines use `"S{lineNumber}"` as the discriminator. Two `import {...}` statements at different positions now hash to different values, so the model can target a specific occurrence without resorting to `offset` + a small `limit` window.
17
-
16
+ 2. **Make the hash occurrence-aware.** The hash for line N is `xxHash32("C{occurrence}:{content}")` where `occurrence` is the running count of that content string earlier in the file. Two `import {...}` statements at different positions now hash to different values, so the model can target a specific occurrence without resorting to `offset` + a small `limit` window. All lines — including symbol-only lines like `}` — use the same occurrence-based discrimination.
18
17
  ## Installation
19
18
 
20
19
  From npm:
@@ -56,42 +55,42 @@ Optional parameters:
56
55
  - `offset` -- start reading from this line number (1-indexed).
57
56
  - `limit` -- maximum number of lines to return.
58
57
 
59
- Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
58
+ Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files return an advisory suggesting using replace to insert content.
60
59
 
61
- ### `edit` -- hash-anchored modifications
60
+ ### `replace` -- hash-anchored modifications
62
61
 
63
- Edits use the `HASH│content` anchors from `read` output to target lines precisely:
62
+ Replaces using the `HASH│content` anchors from `read` output to target lines precisely:
64
63
 
65
64
  ```json
66
65
  {
67
66
  "path": "src/main.ts",
68
67
  "edits": [
69
- { "op": "replace", "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
68
+ { "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
70
69
  ]
71
70
  }
72
71
  ```
73
72
 
74
- | Op | Purpose | Fields |
75
- |---|---|---|
76
- | `replace` | Replace the inclusive range `start`..`end`. To replace a single line, set `start` = `end`. | `start` required, `end` required, `lines` |
77
- | `append` | Insert lines after `pos`. Omit `pos` to append at EOF. | `pos` optional, `lines` |
78
- | `prepend` | Insert lines before `pos`. Omit `pos` to prepend at BOF. | `pos` optional, `lines` |
73
+ | Field | Purpose |
74
+ |---|---|
75
+ | `start` | Range-start anchor (required). |
76
+ | `end` | Range-end anchor (required). Single-line replace: set `start` = `end`. |
77
+ | `lines` | Replacement content (one string per line). Use `[]` to delete. |
79
78
 
80
79
  - **Request structure validation.** The request envelope (path, edits, returnMode, returnRanges) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_OP]`.
81
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect and `op: "replace_text"` are rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{op:"replace", start:"<HASH>", end:"<HASH>", lines:[...]}` (or `append`/`prepend` with `pos`).
80
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
82
81
 
83
82
  All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
84
83
 
85
84
  ### Chained edits
86
85
 
87
- After a successful edit, the result text contains an `--- Anchors ---` block with fresh `HASH│content` references for the changed region. These can be used directly in the next `edit` call on the same file without a full re-read, provided the next edit targets the same or nearby lines. For distant changes, use `read` first.
86
+ After a successful replace, the result text contains an `--- Anchors ---` block with fresh `HASH│content` references for the changed region. These can be used directly in the next `replace` call on the same file without a full re-read, provided the next replace targets the same or nearby lines. For distant changes, use `read` first.
88
87
 
89
88
  ### Auto-read after write
90
89
 
91
90
  After a successful `write`, the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the newly written file without requiring a separate `read` call. The workflow becomes:
92
91
 
93
92
  1. `write` a file, result includes hashline anchors
94
- 2. `edit` using those anchors directly
93
+ 2. `replace` using those anchors directly
95
94
 
96
95
  For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use `read` with `offset` to see more.
97
96
 
@@ -104,7 +103,7 @@ The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is ex
104
103
  - **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes fresh `>>> HASH│content` lines for the affected region. The model copies the HASH portion and retries.
105
104
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
106
105
  - **Strict patch content.** If `lines` contains `+HASH│` display prefixes (or `-N ` diff rows), the edit is rejected with `[E_INVALID_PATCH]`. Bare `HASH│` content (the first 5 chars of a `lines` entry looking like 4 base64 chars + `│`) is also rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
107
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect and `op: "replace_text"` are rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{op:"replace", start:"<HASH>", end:"<HASH>", lines:[...]}` (or `append`/`prepend` with `pos`).
106
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
108
107
  - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
109
108
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
110
109
 
@@ -114,12 +113,12 @@ Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (
114
113
 
115
114
  The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply. The full 64 chars give maximum entropy per character, with case and digits included.
116
115
 
117
- Hashes are occurrence-aware: a discriminator prefix is mixed into the xxHash input before the line content. Symbol-only lines (lone `}`, etc.) use `S{lineNumber}` as the discriminator; content lines use `C{occurrence}` where `occurrence` is the running count of that canonical content earlier in the file. This way:
116
+ Hashes are occurrence-aware: a discriminator prefix is mixed into the xxHash input before the line content. All lines (including symbol-only lines like `}`) use `C{occurrence}` as the discriminator, where `occurrence` is the running count of that canonical content earlier in the file. This way:
118
117
 
119
- - `}` on line 5 and `}` on line 17 hash differently (different `S{...}` prefix).
120
- - `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (different `C{...}` prefix, 1 vs 2).
118
+ - `}` on line 5 and `}` on line 17 hash differently (1st vs 2nd occurrence of `}`).
119
+ - `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (1st vs 2nd occurrence).
121
120
 
122
- The runtime always precomputes the full per-line hash array for a file via `computeLineHashes(content)`, then looks up by line number during validation and during `read` / `edit` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
121
+ The runtime always precomputes the full per-line hash array for a file via `computeLineHashes(content)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
123
122
 
124
123
  `HASH_LENGTH` and `HASH_ALPHABET` are constants at the top of `src/hashline/hash.ts`; bump the length to 5 if you ever need even more entropy.
125
124
 
package/index.ts CHANGED
@@ -2,14 +2,20 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "fs/promises";
3
3
  import { join, isAbsolute } from "path";
4
4
  import { computeLineHashes, formatHashlineRegion } from "./src/hashline";
5
- import { registerEditTool } from "./src/edit";
5
+ import { registerReplaceTool } from "./src/replace";
6
6
  import { registerReadTool } from "./src/read";
7
- import { normalizeToLF } from "./src/edit-diff";
7
+ import { normalizeToLF } from "./src/replace-diff";
8
8
  import { getVisibleLines } from "./src/utils";
9
9
 
10
10
  export default function (pi: ExtensionAPI): void {
11
11
  registerReadTool(pi);
12
- registerEditTool(pi);
12
+ registerReplaceTool(pi);
13
+
14
+ // Disable the built-in `edit` tool so the model uses `replace` instead.
15
+ pi.on("session_start", async (_event, ctx) => {
16
+ const active = pi.getActiveTools();
17
+ pi.setActiveTools(active.filter((t) => t !== "edit"));
18
+ });
13
19
 
14
20
  // Auto-read after write: append hashline read output to write results
15
21
  // so the model immediately has anchors for subsequent edits.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.4.3",
4
- "description": "Strict hashline read/edit tool override for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
3
+ "version": "0.6.0",
4
+ "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
5
5
  "main": "index.ts",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1,3 +1,2 @@
1
- - Use read before edit when you do not have current HASH anchors for the file.
2
- - Copy exactly the HASH (the 4 characters before the `│`); never include the `│` or line content in `pos`/`end`.
3
- - A HASH may contain `-` as a normal alphabet character.
1
+ - Use `read` before `replace` when you do not have current HASH anchors for the file.
2
+ - A HASH may contain `-` as a normal alphabet character.
@@ -1 +1 @@
1
- Read a text file with HASH│content anchors for edit (copy the HASH into `start`/`end`/`pos`)
1
+ Read a text file and return HASH│content lines
package/prompts/read.md CHANGED
@@ -1,31 +1,17 @@
1
- Read a text file. Each line is returned as `HASH│content`. The HASH is 4 base64 characters; the content after the `│` separator is the line verbatim. Pass the HASH (e.g. `aB3x`) into `edit`'s `start`/`end` (for `replace`) or `pos` (for `append`/`prepend`) — never include the line content.
1
+ Read a text file. Each line is returned as `HASH│content`.
2
2
 
3
- HASH shape:
4
- - 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
5
- - The line number is not part of the wire format. Anchor by HASH, never by reading a line number off the rendered output.
6
-
7
- HASH → edit:
8
- - Copy the full 4-character HASH. Use that HASH as `start` or `end` (for `replace`) or `pos` (for `append`/`prepend`) in the next `edit` call.
9
- - Do not include the `│`, the line content, or surrounding whitespace. The wire format for `start`/`end`/`pos` is the HASH only.
3
+ HASH format:
4
+ - The HASH is 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
5
+ - The content after the `│` separator is the line verbatim.
6
+ - The line number is not part of the output. Use the HASH to reference lines.
10
7
 
11
8
  Pagination:
12
9
  - Large files return a truncated preview with a `nextOffset` line. Call `read` again with `offset=nextOffset` to continue.
13
- - For nearby follow-up edits, prefer the `--- Anchors ---` block from a previous `edit` call — fresh HASHes, cheaper than re-reading.
14
- - Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
15
-
16
- Error recovery:
17
- - `[E_STALE_ANCHOR]` — the file changed since your last read. The error includes fresh `>>> HASH│content` lines; copy the HASH portion (the 4 chars before `│`) and retry.
18
- - `[E_BAD_REF]` — malformed HASH. Re-read and try again with a valid HASH anchor (e.g. `aB3x`).
19
10
 
20
11
  File kinds:
21
12
  - Text files are returned as `HASH│content` lines.
22
- - Images (JPEG, PNG, GIF, WebP) are returned as visual attachments; the HASH-line protocol does not apply.
13
+ - Images (JPEG, PNG, GIF, WebP) are returned as visual attachments.
23
14
  - Binary files and directories are rejected with a descriptive error.
24
15
 
25
16
  Non-UTF-8 bytes:
26
- - Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens; editing such a file rewrites it as UTF-8. Recover the original encoding with `iconv` afterwards if it must survive.
27
-
28
- Auto-read after write:
29
- - After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with `HASH│content` for the written file.
30
- - Use those anchors directly for `edit` calls without a separate `read`.
31
- - The auto-read output follows the same format and rules as `read` output.
17
+ - Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens.
@@ -0,0 +1,3 @@
1
+ - Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
2
+ - After a successful `replace`, the returned `--- Anchors ---` block has fresh hashes for nearby follow-up replaces.
3
+ - On `[E_STALE_ANCHOR]`, retry with the `>>>` lines quoted in the error instead of re-reading the whole file.
@@ -0,0 +1 @@
1
+ Replace lines in a text file via HASH anchors from read, batching all edits to a file in one call
@@ -0,0 +1,69 @@
1
+ Replace lines in a text file using HASH anchors from `read`.
2
+
3
+ Put all operations on one file in a single `replace` call. Stack every region into the `edits` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.
4
+
5
+ How to use:
6
+
7
+ 1. Call `read` to get HASH anchors:
8
+ ```
9
+ read({ path: "src/main.ts" })
10
+ // Returns:
11
+ // MQXV│const x = 1;
12
+ // ZPMQ│const y = 2;
13
+ // VRWS│const z = 3;
14
+ ```
15
+
16
+ 2. Copy the 4-character HASH (before `│`) into `start`/`end`:
17
+ ```json
18
+ { "path": "src/main.ts", "edits": [
19
+ { "start": "MQXV", "end": "MQXV", "lines": ["const x = 99;"] }
20
+ ] }
21
+ ```
22
+
23
+ Examples:
24
+
25
+ 1. Single line replace:
26
+ ```json
27
+ { "path": "src/main.ts", "edits": [
28
+ { "start": "MQXV", "end": "MQXV", "lines": ["const x = 1;"] }
29
+ ] }
30
+ ```
31
+
32
+ 2. Range replace (3 lines → 3 new lines):
33
+ ```json
34
+ { "path": "src/main.ts", "edits": [
35
+ { "start": "ZPMQ", "end": "VRWS", "lines": [
36
+ "function greet(name) {",
37
+ " return `Hello, ${name}`;",
38
+ "}"
39
+ }
40
+ ] }
41
+ ```
42
+
43
+ 3. Multiple regions in one call (delete two non-adjacent ranges):
44
+ ```json
45
+ { "path": "src/server.ts", "edits": [
46
+ { "start": "aB3x", "end": "xY7q", "lines": [] },
47
+ { "start": "MQXV", "end": "ZPMQ", "lines": [] }
48
+ ] }
49
+ ```
50
+
51
+ Rules:
52
+ - `start` and `end` are required. A single-line replace is `start=X, end=X`.
53
+ - To delete a range, use `lines: []`.
54
+ - `start`, `end` are HASH anchors only (e.g. `aB3x`). Do not include `│` or line content.
55
+ - `lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix, no `+`/`-` diff markers.
56
+ - Don't add `""` for spacing unless you actually want a new blank line.
57
+ - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
58
+ - All edits in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
59
+ - If `lines` matches current content, the replace is classified as `noop` (file unchanged).
60
+
61
+ On success, the response contains an `--- Anchors ---` block with fresh HASH anchors for the changed region. Use those for nearby follow-up replaces instead of re-reading.
62
+
63
+ Auto-read after write:
64
+ - After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block.
65
+ - Use those anchors directly for `replace` calls without a separate `read`.
66
+
67
+ Error recovery:
68
+ - `[E_STALE_ANCHOR]` — file changed since last read. The error includes fresh `>>> HASH│content` lines; copy the HASH and retry.
69
+ - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
@@ -42,14 +42,12 @@ export function buildLineIndex(content: string): LineIndex {
42
42
 
43
43
 
44
44
  type ResolvedEditSpan = {
45
- kind: "replace" | "insert";
45
+ kind: "replace";
46
46
  index: number;
47
47
  label: string;
48
48
  start: number;
49
49
  end: number;
50
50
  replacement: string;
51
- boundary?: number;
52
- insertMode?: "append-empty-origin" | "prepend-empty-origin";
53
51
  };
54
52
 
55
53
  function assertDoesNotEmptyFile(originalContent: string, result: string): void {
@@ -71,27 +69,6 @@ function throwEditConflict(
71
69
  );
72
70
  }
73
71
 
74
- function computeInsertionBoundary(
75
- edit: Extract<ResolvedHashlineEdit, { op: "append" | "prepend" }>,
76
- lineIndex: LineIndex,
77
- ): number {
78
- switch (edit.op) {
79
- case "append": {
80
- const fileLineCount = lineIndex.fileLines.length;
81
- const eofBoundary =
82
- lineIndex.hasTerminalNewline && fileLineCount > 0
83
- ? fileLineCount - 1
84
- : fileLineCount;
85
- return edit.pos
86
- ? lineIndex.hasTerminalNewline && edit.pos.line === fileLineCount
87
- ? eofBoundary
88
- : edit.pos.line
89
- : eofBoundary;
90
- }
91
- case "prepend":
92
- return edit.pos ? edit.pos.line - 1 : 0;
93
- }
94
- }
95
72
 
96
73
  function resolveEditToSpan(
97
74
  edit: ResolvedHashlineEdit,
@@ -102,155 +79,64 @@ function resolveEditToSpan(
102
79
  ): ResolvedEditSpan | null {
103
80
  const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
104
81
 
105
- switch (edit.op) {
106
- case "replace": {
107
- const startLine = edit.start.line;
108
- const endLine = edit.end.line;
109
- const originalLines = fileLines.slice(startLine - 1, endLine);
110
- if (
111
- originalLines.length === edit.lines.length &&
112
- originalLines.every(
113
- (line, lineIndex) => line === edit.lines[lineIndex],
114
- )
115
- ) {
116
- noopEdits.push({
117
- editIndex: index,
118
- loc: edit.start.hash,
119
- currentContent: originalLines.join("\n"),
120
- });
121
- return null;
122
- }
123
-
124
- if (edit.lines.length > 0) {
125
- return {
126
- kind: "replace",
127
- index,
128
- label: describeEdit(edit),
129
- start: lineStarts[startLine - 1]!,
130
- end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
131
- replacement: edit.lines.join("\n"),
132
- };
133
- }
134
-
135
- if (startLine === 1 && endLine === fileLines.length) {
136
- return {
137
- kind: "replace",
138
- index,
139
- label: describeEdit(edit),
140
- start: 0,
141
- end: content.length,
142
- replacement: "",
143
- };
144
- }
145
-
146
- if (endLine < fileLines.length) {
147
- return {
148
- kind: "replace",
149
- index,
150
- label: describeEdit(edit),
151
- start: lineStarts[startLine - 1]!,
152
- end: lineStarts[endLine]!,
153
- replacement: "",
154
- };
155
- }
156
-
157
- return {
158
- kind: "replace",
159
- index,
160
- label: describeEdit(edit),
161
- start: Math.max(0, lineStarts[startLine - 1]! - 1),
162
- end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
163
- replacement: "",
164
- };
165
- }
166
- case "append": {
167
- if (edit.lines.length === 0) {
168
- noopEdits.push({
169
- editIndex: index,
170
- loc: edit.pos ? edit.pos.hash : "EOF",
171
- currentContent: edit.pos
172
- ? (fileLines[edit.pos.line - 1] ?? "")
173
- : "",
174
- });
175
- return null;
176
- }
82
+ const startLine = edit.start.line;
83
+ const endLine = edit.end.line;
84
+ const originalLines = fileLines.slice(startLine - 1, endLine);
85
+ if (
86
+ originalLines.length === edit.lines.length &&
87
+ originalLines.every(
88
+ (line, lineIndex) => line === edit.lines[lineIndex],
89
+ )
90
+ ) {
91
+ noopEdits.push({
92
+ editIndex: index,
93
+ loc: edit.start.hash,
94
+ currentContent: originalLines.join("\n"),
95
+ });
96
+ return null;
97
+ }
177
98
 
178
- const insertedText = edit.lines.join("\n");
179
- if (content.length === 0) {
180
- return {
181
- kind: "insert",
182
- index,
183
- label: describeEdit(edit),
184
- start: 0,
185
- end: 0,
186
- replacement: insertedText,
187
- boundary: computeInsertionBoundary(edit, lineIndex),
188
- insertMode: "append-empty-origin",
189
- };
190
- }
99
+ if (edit.lines.length > 0) {
100
+ return {
101
+ kind: "replace",
102
+ index,
103
+ label: describeEdit(edit),
104
+ start: lineStarts[startLine - 1]!,
105
+ end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
106
+ replacement: edit.lines.join("\n"),
107
+ };
108
+ }
191
109
 
192
- if (!edit.pos) {
193
- return {
194
- kind: "insert",
195
- index,
196
- label: describeEdit(edit),
197
- start: content.length,
198
- end: content.length,
199
- replacement: hasTerminalNewline
200
- ? `${insertedText}\n`
201
- : `\n${insertedText}`,
202
- boundary: computeInsertionBoundary(edit, lineIndex),
203
- };
204
- }
110
+ if (startLine === 1 && endLine === fileLines.length) {
111
+ return {
112
+ kind: "replace",
113
+ index,
114
+ label: describeEdit(edit),
115
+ start: 0,
116
+ end: content.length,
117
+ replacement: "",
118
+ };
119
+ }
205
120
 
206
- const isSentinelAppend =
207
- hasTerminalNewline && edit.pos.line === fileLines.length;
208
- return {
209
- kind: "insert",
210
- index,
211
- label: describeEdit(edit),
212
- start: isSentinelAppend
213
- ? content.length
214
- : lineStarts[edit.pos.line - 1]! +
215
- fileLines[edit.pos.line - 1]!.length,
216
- end: isSentinelAppend
217
- ? content.length
218
- : lineStarts[edit.pos.line - 1]! +
219
- fileLines[edit.pos.line - 1]!.length,
220
- replacement: isSentinelAppend
221
- ? `${insertedText}\n`
222
- : `\n${insertedText}`,
223
- boundary: computeInsertionBoundary(edit, lineIndex),
224
- };
225
- }
226
- case "prepend": {
227
- if (edit.lines.length === 0) {
228
- noopEdits.push({
229
- editIndex: index,
230
- loc: edit.pos ? edit.pos.hash : "BOF",
231
- currentContent: edit.pos
232
- ? (fileLines[edit.pos.line - 1] ?? "")
233
- : "",
234
- });
235
- return null;
236
- }
237
- const insertedText = edit.lines.join("\n");
238
- const start = edit.pos ? lineStarts[edit.pos.line - 1]! : 0;
239
- return {
240
- kind: "insert",
241
- index,
242
- label: describeEdit(edit),
243
- start,
244
- end: start,
245
- replacement:
246
- content.length === 0 ? insertedText : `${insertedText}\n`,
247
- boundary: computeInsertionBoundary(edit, lineIndex),
248
- ...(content.length === 0
249
- ? { insertMode: "prepend-empty-origin" as const }
250
- : {}),
251
- };
252
- }
121
+ if (endLine < fileLines.length) {
122
+ return {
123
+ kind: "replace",
124
+ index,
125
+ label: describeEdit(edit),
126
+ start: lineStarts[startLine - 1]!,
127
+ end: lineStarts[endLine]!,
128
+ replacement: "",
129
+ };
253
130
  }
131
+
132
+ return {
133
+ kind: "replace",
134
+ index,
135
+ label: describeEdit(edit),
136
+ start: Math.max(0, lineStarts[startLine - 1]! - 1),
137
+ end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
138
+ replacement: "",
139
+ };
254
140
  }
255
141
 
256
142
  function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
@@ -263,38 +149,11 @@ function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
263
149
  ) {
264
150
  const right = spans[rightIndex]!;
265
151
 
266
- if (left.kind === "insert" && right.kind === "insert") {
267
- if (left.boundary === right.boundary) {
268
- throwEditConflict(
269
- left,
270
- right,
271
- "target the same insertion boundary",
272
- );
273
- }
274
- continue;
275
- }
276
-
277
- if (left.kind === "replace" && right.kind === "replace") {
278
- if (left.start < right.end && right.start < left.end) {
279
- throwEditConflict(
280
- left,
281
- right,
282
- "overlap on the same original line range",
283
- );
284
- }
285
- continue;
286
- }
287
-
288
- const replaceSpan = left.kind === "replace" ? left : right;
289
- const insertSpan = left.kind === "insert" ? left : right;
290
- if (
291
- insertSpan.start >= replaceSpan.start &&
292
- insertSpan.start < replaceSpan.end
293
- ) {
152
+ if (left.start < right.end && right.start < left.end) {
294
153
  throwEditConflict(
295
154
  left,
296
155
  right,
297
- "cannot be applied together because one inserts inside a replaced original range",
156
+ "overlap on the same original line range",
298
157
  );
299
158
  }
300
159
  }
@@ -324,9 +183,7 @@ function resolveEditSpans(
324
183
  }
325
184
 
326
185
  const spanKey =
327
- span.kind === "insert"
328
- ? `insert:${span.boundary}:${span.replacement}`
329
- : `replace:${span.start}:${span.end}:${span.replacement}`;
186
+ `replace:${span.start}:${span.end}:${span.replacement}`;
330
187
  if (seenSpanKeys.has(spanKey)) {
331
188
  continue;
332
189
  }
@@ -335,20 +192,10 @@ function resolveEditSpans(
335
192
  }
336
193
 
337
194
  assertNoConflictingSpans(resolvedSpans);
338
-
339
195
  return [...resolvedSpans].sort((left, right) => {
340
196
  if (right.end !== left.end) {
341
197
  return right.end - left.end;
342
198
  }
343
- if (left.kind !== right.kind) {
344
- return left.kind === "replace" ? -1 : 1;
345
- }
346
- if (left.kind === "insert" && right.kind === "insert") {
347
- return (
348
- (right.boundary ?? -1) - (left.boundary ?? -1) ||
349
- left.index - right.index
350
- );
351
- }
352
199
  return left.index - right.index;
353
200
  });
354
201
  }
@@ -361,18 +208,8 @@ function assembleEditResult(
361
208
  let result = content;
362
209
  for (const span of spans) {
363
210
  throwIfAborted(signal);
364
- const replacement =
365
- span.insertMode === "append-empty-origin"
366
- ? result.length === 0
367
- ? span.replacement
368
- : `\n${span.replacement}`
369
- : span.insertMode === "prepend-empty-origin"
370
- ? result.length === 0
371
- ? span.replacement
372
- : `${span.replacement}\n`
373
- : span.replacement;
374
211
  result =
375
- result.slice(0, span.start) + replacement + result.slice(span.end);
212
+ result.slice(0, span.start) + span.replacement + result.slice(span.end);
376
213
  }
377
214
  return result;
378
215
  }
@@ -401,7 +238,6 @@ export function applyHashlineEdits(
401
238
 
402
239
  // Normalize lines: [""] to lines: [] for deletion.
403
240
  edits = edits.map((edit) =>
404
- edit.op === "replace" &&
405
241
  edit.lines.length === 1 &&
406
242
  edit.lines[0] === ""
407
243
  ? { ...edit, lines: [] }