@jerryan/pi-hashline-edit 0.7.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RimuruW
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ ![pi-hashline-edit](assets/banner.jpeg)
2
+
3
+ # pi-hashline-edit
4
+
5
+ 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.
6
+
7
+ Every line returned by `read` carries a short content hash. Edits reference these hashes instead of raw text, so the tool can detect stale context and reject outdated changes before they reach the file.
8
+
9
+ Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi).
10
+
11
+ ## Differences from upstream
12
+
13
+ This is a fork of the original [pi-hashline-edit](https://github.com/earendil-works/pi-hashline-edit). The core protocol (hash-anchored reads, stale-anchor rejection, atomic writes) is unchanged from upstream. Key differences:
14
+
15
+ - **Single edit shape.** One entry type: `{ range: [start, end], lines: [...] }`. No `op` field, no `append`/`prepend`/`replace_text` ops, no `after`/`before`. The tuple enforces explicit endpoint anchors, eliminating the common "forgot `end`" failure mode.
16
+ - **Standard hex hash alphabet.** `0-9 A-F` instead of `ZPMQVRWSNKTXJBYH`. Hex pairs are more likely to be single tokens.
17
+ - **Symmetric boundary-duplication detection.** Runtime warnings catch duplicated boundary lines on both sides of a replacement, not just trailing.
18
+ - **`read` raw mode.** `raw: true` returns plain text without `LINE#HASH:` anchors, for reads that don't plan to edit.
19
+ - **Inline FNV-1a hashing.** Replaces `xxhashjs` dependency. Always incorporates line index.
20
+ - **Minimal prompt surface.** Prompt text describes what the model needs to use the tool; return-format documentation and error catalogues are omitted.
21
+ - **No legacy compatibility.** The `{ oldText, newText }` substring-replace format is not accepted. The schema is hashline-only.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ # From npm
27
+ pi install npm:pi-hashline-edit
28
+
29
+ # From a local checkout
30
+ pi install /path/to/pi-hashline-edit
31
+ ```
32
+
33
+ ## How It Works
34
+
35
+ ### `read` — tagged line output
36
+
37
+ Text files are returned with a `LINE#HASH:` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH:` columns align:
38
+
39
+ ```text
40
+ 8#A4:function hello() {
41
+ 9#3F: console.log("world");
42
+ 10#B2:}
43
+ ```
44
+
45
+ - `LINE` — 1-indexed line number.
46
+ - `HASH` — 2-character content hash (hex digits `0-9 A-F`).
47
+
48
+ Optional parameters:
49
+ - `offset` — start reading from this line number (1-indexed).
50
+ - `limit` — maximum number of lines to return.
51
+ - `raw` — when `true`, returns plain text without LINE#HASH anchors. Saves tokens when you don't plan to edit this file.
52
+
53
+ 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.
54
+
55
+ ### `edit` — hash-anchored modifications
56
+
57
+ Each edit entry replaces an inclusive anchor range:
58
+
59
+ ```json
60
+ {
61
+ "path": "src/main.ts",
62
+ "edits": [
63
+ { "range": ["11#3F", "11#3F"], "lines": [" console.log('hashline');"] },
64
+ { "range": ["42#B2", "45#C7"], "lines": ["function foo() {", " return 42;", "}"] }
65
+ ]
66
+ }
67
+ ```
68
+
69
+ - `range` — `[start, end]` pair of LINE#HASH anchors. Use the same anchor twice for single-line.
70
+ - `lines` — new content replacing the range (string array). Use `[]` to delete.
71
+
72
+ All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
73
+
74
+ ### Chained edits
75
+
76
+ After a successful edit, the result includes an `--- Updated anchors ---` block with fresh `LINE#HASH` 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.
77
+
78
+ ### Diff preview
79
+
80
+ Each edit result includes a compact `Diff preview:` block showing the changed lines with `+`/`-` markers and their new `LINE#HASH` anchors, making quick follow-up edits possible without a full re-read.
81
+
82
+ ## Design Decisions
83
+
84
+ - **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes a snippet with fresh `LINE#HASH` references for the affected lines for immediate retry.
85
+ - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
86
+ - **Strict patch content.** If `lines` contains `LINE#HASH:` display prefixes or diff `+`/`-` markers, the edit is rejected with `[E_INVALID_PATCH]`. The model must send literal file content; the runtime does not silently strip accidental prefixes.
87
+ - **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.
88
+ - **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.
89
+ - **Schema-delegated validation.** Field-type and schema validation are the responsibility of pi's AJV layer. The extension's runtime guard only prevents crashes from missing required top-level fields.
90
+
91
+ ## Hashing
92
+
93
+ Hashes are computed with inline FNV-1a (32-bit, mask-reduced to 8 bits), then mapped to a 2-character hex string from `0-9 A-F`.
94
+
95
+ The line index is always incorporated into the hash, so identical content on different lines produces different hashes.
96
+
97
+ ## Development
98
+
99
+ Requires [Node.js](https://nodejs.org) and npm.
100
+
101
+ ```bash
102
+ npm install
103
+ npm test
104
+ ```
105
+
106
+ Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
107
+
108
+ ## Credits
109
+
110
+ Thanks to [can1357](https://github.com/can1357) for the original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept.
111
+
112
+ ## License
113
+
114
+ [MIT](LICENSE)
package/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerEditTool } from "./src/edit";
3
+ import { registerReadTool } from "./src/read";
4
+
5
+ export default function (pi: ExtensionAPI): void {
6
+ registerReadTool(pi);
7
+ registerEditTool(pi);
8
+
9
+ const debugValue = process.env.PI_HASHLINE_DEBUG;
10
+ if (debugValue === "1" || debugValue === "true") {
11
+ pi.on("session_start", async (_event, ctx) => {
12
+ ctx.ui.notify("Hashline Edit mode active", "info");
13
+ });
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@jerryan/pi-hashline-edit",
3
+ "version": "0.7.0",
4
+ "description": "Hashline read/edit tool override for pi-coding-agent",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/JerryAZR/pi-hashline-edit.git"
8
+ },
9
+ "author": "JerryAZR",
10
+ "publishConfig": {
11
+ "registry": "https://registry.npmjs.org/"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi",
16
+ "coding-agent",
17
+ "extension",
18
+ "hashline"
19
+ ],
20
+ "license": "MIT",
21
+ "files": [
22
+ "index.ts",
23
+ "src",
24
+ "prompts",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "dependencies": {
34
+ "diff": "^8.0.2",
35
+ "file-type": "^21.3.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-ai": ">=0.74.0",
39
+ "@earendil-works/pi-coding-agent": ">=0.74.0",
40
+ "@earendil-works/pi-tui": "*",
41
+ "@sinclair/typebox": "*"
42
+ },
43
+ "scripts": {
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-coding-agent": "^0.74.0",
49
+ "@types/node": "^22.0.0",
50
+ "ajv": "^8.20.0",
51
+ "vitest": "^3.0.0"
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ Edit a text file via LINE#HASH anchors from read
@@ -0,0 +1,23 @@
1
+ Patch a UTF-8 text file using `LINE#HASH` anchors copied verbatim from `read`.
2
+
3
+ Submit one `edit` call per file. All operations go in a single `edits` array; anchors must all come from the same pre-edit read.
4
+
5
+ Each edit entry replaces an inclusive anchor range:
6
+ ```json
7
+ { "range": [startAnchor, endAnchor], "lines": [...] }
8
+ ```
9
+ - `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read`.
10
+ Use the same anchor twice for single-line: `["42#A4", "42#A4"]`.
11
+ - `lines` — new content replacing the range (string array). Use `[]` to delete.
12
+ Must be literal file content, not LINE#HASH-prefixed output. Match indentation exactly.
13
+
14
+ Example:
15
+ ```json
16
+ { "path": "src/main.ts", "edits": [
17
+ { "range": ["12#3F", "12#3F"], "lines": ["const x = 1;"] },
18
+ { "range": ["20#B2", "25#C7"], "lines": ["function foo() {", " return 42;", "}"] }
19
+ ] }
20
+
21
+ Rules:
22
+ - Do not guess, shift, or construct anchors. Copy them from the most recent `read` of this file.
23
+ - Do not emit overlapping or adjacent edits — merge them into one.
@@ -0,0 +1,2 @@
1
+ - Use read before edit when you do not have current LINE#HASH anchors for the file.
2
+ - If read is truncated, continue with the `offset` it suggests — do not guess unseen lines.
@@ -0,0 +1 @@
1
+ Read a text file with LINE#HASH anchors for edit
@@ -0,0 +1,5 @@
1
+ Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH:content` — copy those anchors verbatim into `edit`.
2
+
3
+ Use `offset` and `limit` to page through. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; when truncated, the tail of the output tells you the next `offset`.
4
+
5
+ Set `raw: true` to skip LINE#HASH prefixing and return plain text. Don't use if you plan to edit this file — saves tokens on exploration, documentation, and reference reads.
@@ -0,0 +1,428 @@
1
+ import * as Diff from "diff";
2
+ import {
3
+ computeLineHash,
4
+ FUZZY_HYPHENS_RE,
5
+ FUZZY_DOUBLE_QUOTES_RE,
6
+ FUZZY_SINGLE_QUOTES_RE,
7
+ FUZZY_UNICODE_SPACES_RE,
8
+ } from "./hashline";
9
+
10
+ // ─── Line ending normalization ──────────────────────────────────────────
11
+
12
+ export function detectLineEnding(content: string): "\r\n" | "\n" {
13
+ const crlfIdx = content.indexOf("\r\n");
14
+ const lfIdx = content.indexOf("\n");
15
+ if (lfIdx === -1 || crlfIdx === -1) return "\n";
16
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
17
+ }
18
+
19
+ export function normalizeToLF(text: string): string {
20
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
21
+ }
22
+
23
+ export function restoreLineEndings(
24
+ text: string,
25
+ ending: "\r\n" | "\n",
26
+ ): string {
27
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
28
+ }
29
+
30
+ export function stripBom(content: string): { bom: string; text: string } {
31
+ return content.startsWith("\uFEFF")
32
+ ? { bom: "\uFEFF", text: content.slice(1) }
33
+ : { bom: "", text: content };
34
+ }
35
+
36
+ // ─── Fuzzy text matching ────────────────────────────────────────────────
37
+
38
+ function normalizeFuzzyChar(ch: string): string {
39
+ return ch
40
+ .replace(FUZZY_SINGLE_QUOTES_RE, "'")
41
+ .replace(FUZZY_DOUBLE_QUOTES_RE, '"')
42
+ .replace(FUZZY_HYPHENS_RE, "-")
43
+ .replace(FUZZY_UNICODE_SPACES_RE, " ");
44
+ }
45
+
46
+ function normalizeForFuzzyMatch(text: string): string {
47
+ return text
48
+ .split("\n")
49
+ .map((line) => line.trimEnd())
50
+ .join("\n")
51
+ .replace(FUZZY_SINGLE_QUOTES_RE, "'")
52
+ .replace(FUZZY_DOUBLE_QUOTES_RE, '"')
53
+ .replace(FUZZY_HYPHENS_RE, "-")
54
+ .replace(FUZZY_UNICODE_SPACES_RE, " ");
55
+ }
56
+
57
+ function buildNormalizedWithMap(text: string): {
58
+ normalized: string;
59
+ indexMap: number[];
60
+ } {
61
+ const lines = text.split("\n");
62
+ const normalizedChars: string[] = [];
63
+ const indexMap: number[] = [];
64
+ let originalOffset = 0;
65
+
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const line = lines[i]!;
68
+ const trimmed = line.replace(/\s+$/u, "");
69
+
70
+ for (let j = 0; j < trimmed.length; j++) {
71
+ normalizedChars.push(normalizeFuzzyChar(trimmed[j]!));
72
+ indexMap.push(originalOffset + j);
73
+ }
74
+
75
+ if (i < lines.length - 1) {
76
+ normalizedChars.push("\n");
77
+ indexMap.push(originalOffset + line.length);
78
+ }
79
+
80
+ originalOffset += line.length + 1;
81
+ }
82
+
83
+ return { normalized: normalizedChars.join(""), indexMap };
84
+ }
85
+
86
+ function mapNormalizedSpanToOriginal(
87
+ indexMap: number[],
88
+ normalizedStart: number,
89
+ normalizedLength: number,
90
+ ): { index: number; matchLength: number } | null {
91
+ if (normalizedStart < 0 || normalizedLength <= 0) return null;
92
+ const normalizedEnd = normalizedStart + normalizedLength;
93
+ if (normalizedEnd > indexMap.length) return null;
94
+
95
+ const start = indexMap[normalizedStart];
96
+ const end = indexMap[normalizedEnd - 1];
97
+ if (start === undefined || end === undefined || end < start) return null;
98
+
99
+ return { index: start, matchLength: end - start + 1 };
100
+ }
101
+
102
+ /**
103
+ * Find `oldText` in `content` with optional fuzzy whitespace/unicode matching.
104
+ * Always returns an index/length in the original content.
105
+ */
106
+ export function fuzzyFindText(
107
+ content: string,
108
+ oldText: string,
109
+ ): {
110
+ found: boolean;
111
+ index: number;
112
+ matchLength: number;
113
+ usedFuzzyMatch: boolean;
114
+ } {
115
+ const exactIndex = content.indexOf(oldText);
116
+ if (exactIndex !== -1) {
117
+ return {
118
+ found: true,
119
+ index: exactIndex,
120
+ matchLength: oldText.length,
121
+ usedFuzzyMatch: false,
122
+ };
123
+ }
124
+
125
+ const normalizedNeedle = normalizeForFuzzyMatch(oldText);
126
+ if (!normalizedNeedle.length)
127
+ return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
128
+
129
+ const { normalized, indexMap } = buildNormalizedWithMap(content);
130
+ const normalizedIndex = normalized.indexOf(normalizedNeedle);
131
+ if (normalizedIndex === -1) {
132
+ return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
133
+ }
134
+
135
+ const mapped = mapNormalizedSpanToOriginal(
136
+ indexMap,
137
+ normalizedIndex,
138
+ normalizedNeedle.length,
139
+ );
140
+ if (!mapped) {
141
+ return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
142
+ }
143
+
144
+ return {
145
+ found: true,
146
+ index: mapped.index,
147
+ matchLength: mapped.matchLength,
148
+ usedFuzzyMatch: true,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Replace `oldText` with `newText` in `content`.
154
+ * Fuzzy matching only determines target spans; replacement always applies to
155
+ * the original content (never normalizes the whole file).
156
+ */
157
+ export function replaceText(
158
+ content: string,
159
+ oldText: string,
160
+ newText: string,
161
+ opts: { all?: boolean },
162
+ ): { content: string; count: number } {
163
+ if (!oldText.length) return { content, count: 0 };
164
+ const normalizedNew = normalizeToLF(newText);
165
+
166
+ if (opts.all) {
167
+ const exactCount = content.split(oldText).length - 1;
168
+ if (exactCount > 0) {
169
+ return {
170
+ content: content.split(oldText).join(normalizedNew),
171
+ count: exactCount,
172
+ };
173
+ }
174
+
175
+ const normalizedNeedle = normalizeForFuzzyMatch(oldText);
176
+ if (!normalizedNeedle.length) return { content, count: 0 };
177
+
178
+ const { normalized, indexMap } = buildNormalizedWithMap(content);
179
+ const spans: Array<{ index: number; matchLength: number }> = [];
180
+ let searchFrom = 0;
181
+
182
+ while (searchFrom <= normalized.length - normalizedNeedle.length) {
183
+ const pos = normalized.indexOf(normalizedNeedle, searchFrom);
184
+ if (pos === -1) break;
185
+ const mapped = mapNormalizedSpanToOriginal(
186
+ indexMap,
187
+ pos,
188
+ normalizedNeedle.length,
189
+ );
190
+ if (mapped) {
191
+ const prev = spans[spans.length - 1];
192
+ if (!prev || mapped.index >= prev.index + prev.matchLength) {
193
+ spans.push(mapped);
194
+ }
195
+ }
196
+ searchFrom = pos + Math.max(1, normalizedNeedle.length);
197
+ }
198
+
199
+ if (!spans.length) return { content, count: 0 };
200
+
201
+ let out = content;
202
+ for (let i = spans.length - 1; i >= 0; i--) {
203
+ const span = spans[i]!;
204
+ out =
205
+ out.substring(0, span.index) +
206
+ normalizedNew +
207
+ out.substring(span.index + span.matchLength);
208
+ }
209
+ return { content: out, count: spans.length };
210
+ }
211
+
212
+ const result = fuzzyFindText(content, oldText);
213
+ if (!result.found) return { content, count: 0 };
214
+
215
+ return {
216
+ content:
217
+ content.substring(0, result.index) +
218
+ normalizedNew +
219
+ content.substring(result.index + result.matchLength),
220
+ count: 1,
221
+ };
222
+ }
223
+
224
+ // ─── Diff generation ────────────────────────────────────────────────────
225
+
226
+ function formatDiffPreviewLine(
227
+ prefix: " " | "+" | "-",
228
+ lineNum: number,
229
+ lineNumWidth: number,
230
+ line: string,
231
+ includeHash: boolean,
232
+ ): string {
233
+ const paddedLineNum = String(lineNum).padStart(lineNumWidth, " ");
234
+ if (!includeHash) {
235
+ return `${prefix}${paddedLineNum} ${line}`;
236
+ }
237
+ return `${prefix}${paddedLineNum}#${computeLineHash(lineNum, line)}:${line}`;
238
+ }
239
+
240
+ export function generateDiffString(
241
+ oldContent: string,
242
+ newContent: string,
243
+ contextLines = 4,
244
+ ): { diff: string; firstChangedLine: number | undefined } {
245
+ const parts = Diff.diffLines(oldContent, newContent);
246
+ const output: string[] = [];
247
+ const maxLineNum = Math.max(
248
+ oldContent.split("\n").length,
249
+ newContent.split("\n").length,
250
+ );
251
+ const lineNumWidth = String(maxLineNum).length;
252
+ let oldLineNum = 1;
253
+ let newLineNum = 1;
254
+ let lastWasChange = false;
255
+ let firstChangedLine: number | undefined;
256
+
257
+ for (let i = 0; i < parts.length; i++) {
258
+ const part = parts[i]!;
259
+ const raw = part.value.split("\n");
260
+ if (raw[raw.length - 1] === "") raw.pop();
261
+
262
+ if (part.added || part.removed) {
263
+ if (firstChangedLine === undefined) firstChangedLine = newLineNum;
264
+ for (const line of raw) {
265
+ if (part.added) {
266
+ output.push(
267
+ formatDiffPreviewLine("+", newLineNum, lineNumWidth, line, true),
268
+ );
269
+ newLineNum++;
270
+ } else {
271
+ output.push(
272
+ formatDiffPreviewLine("-", oldLineNum, lineNumWidth, line, false),
273
+ );
274
+ oldLineNum++;
275
+ }
276
+ }
277
+ lastWasChange = true;
278
+ continue;
279
+ }
280
+
281
+ const nextPartIsChange =
282
+ i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed);
283
+ if (lastWasChange || nextPartIsChange) {
284
+ let linesToShow = raw;
285
+ let skipStart = 0;
286
+ let skipEnd = 0;
287
+
288
+ if (!lastWasChange) {
289
+ skipStart = Math.max(0, raw.length - contextLines);
290
+ linesToShow = raw.slice(skipStart);
291
+ }
292
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
293
+ skipEnd = linesToShow.length - contextLines;
294
+ linesToShow = linesToShow.slice(0, contextLines);
295
+ }
296
+
297
+ if (skipStart > 0) {
298
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
299
+ oldLineNum += skipStart;
300
+ newLineNum += skipStart;
301
+ }
302
+ for (const line of linesToShow) {
303
+ output.push(
304
+ formatDiffPreviewLine(" ", newLineNum, lineNumWidth, line, true),
305
+ );
306
+ oldLineNum++;
307
+ newLineNum++;
308
+ }
309
+ if (skipEnd > 0) {
310
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
311
+ oldLineNum += skipEnd;
312
+ newLineNum += skipEnd;
313
+ }
314
+ } else {
315
+ oldLineNum += raw.length;
316
+ newLineNum += raw.length;
317
+ }
318
+ lastWasChange = false;
319
+ }
320
+
321
+ return { diff: output.join("\n"), firstChangedLine };
322
+ }
323
+
324
+ export interface CompactHashlineDiffPreview {
325
+ preview: string;
326
+ addedLines: number;
327
+ removedLines: number;
328
+ }
329
+
330
+ type DiffPreviewKind = "context" | "addition" | "deletion";
331
+
332
+ function classifyDiffPreviewLine(line: string): DiffPreviewKind | null {
333
+ if (line.startsWith("+")) return "addition";
334
+ if (line.startsWith("-")) return "deletion";
335
+ if (line.startsWith(" ")) return "context";
336
+ return null;
337
+ }
338
+
339
+ function summarizeOmitted(count: number, label: string): string {
340
+ return `... ${count} more ${label} line${count === 1 ? "" : "s"}`;
341
+ }
342
+
343
+ function collapseDiffPreviewRun(
344
+ lines: string[],
345
+ maxVisible: number,
346
+ label: string,
347
+ ): string[] {
348
+ if (lines.length <= maxVisible) {
349
+ return lines;
350
+ }
351
+
352
+ return [
353
+ ...lines.slice(0, maxVisible),
354
+ summarizeOmitted(lines.length - maxVisible, label),
355
+ ];
356
+ }
357
+
358
+ export function buildCompactHashlineDiffPreview(
359
+ diff: string,
360
+ options: {
361
+ maxUnchangedRun?: number;
362
+ maxAdditionRun?: number;
363
+ maxDeletionRun?: number;
364
+ maxOutputLines?: number;
365
+ } = {},
366
+ ): CompactHashlineDiffPreview {
367
+ const {
368
+ maxUnchangedRun = 2,
369
+ maxAdditionRun = 4,
370
+ maxDeletionRun = 4,
371
+ maxOutputLines = 12,
372
+ } = options;
373
+
374
+ if (!diff.trim()) {
375
+ return { preview: "", addedLines: 0, removedLines: 0 };
376
+ }
377
+
378
+ const lines = diff.split("\n").filter((line) => line.length > 0);
379
+ const previewLines: string[] = [];
380
+ let addedLines = 0;
381
+ let removedLines = 0;
382
+
383
+ for (let index = 0; index < lines.length; ) {
384
+ const kind = classifyDiffPreviewLine(lines[index]!);
385
+ let end = index + 1;
386
+ while (end < lines.length && classifyDiffPreviewLine(lines[end]!) === kind) {
387
+ end += 1;
388
+ }
389
+
390
+ const run = lines.slice(index, end);
391
+ switch (kind) {
392
+ case "addition":
393
+ addedLines += run.length;
394
+ previewLines.push(...collapseDiffPreviewRun(run, maxAdditionRun, "added"));
395
+ break;
396
+ case "deletion":
397
+ removedLines += run.length;
398
+ previewLines.push(...collapseDiffPreviewRun(run, maxDeletionRun, "removed"));
399
+ break;
400
+ case "context":
401
+ previewLines.push(...collapseDiffPreviewRun(run, maxUnchangedRun, "unchanged"));
402
+ break;
403
+ default:
404
+ previewLines.push(...run);
405
+ break;
406
+ }
407
+
408
+ index = end;
409
+ }
410
+
411
+ if (previewLines.length > maxOutputLines) {
412
+ const visibleLines = previewLines.slice(0, maxOutputLines);
413
+ visibleLines.push(
414
+ summarizeOmitted(previewLines.length - maxOutputLines, "preview"),
415
+ );
416
+ return {
417
+ preview: visibleLines.join("\n"),
418
+ addedLines,
419
+ removedLines,
420
+ };
421
+ }
422
+
423
+ return {
424
+ preview: previewLines.join("\n"),
425
+ addedLines,
426
+ removedLines,
427
+ };
428
+ }