pi-hashline-edit-pro 2.7.2 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +7 -1
  2. package/index.ts +9 -6
  3. package/package.json +10 -10
  4. package/prompts/grep-guidelines.md +3 -7
  5. package/prompts/grep-snippet.md +1 -1
  6. package/prompts/grep.md +1 -1
  7. package/prompts/insert-guidelines.md +3 -7
  8. package/prompts/insert-snippet.md +1 -1
  9. package/prompts/insert.md +1 -1
  10. package/prompts/read-guidelines.md +1 -1
  11. package/prompts/read.md +1 -1
  12. package/prompts/replace-guidelines.md +5 -9
  13. package/prompts/replace-snippet.md +1 -1
  14. package/prompts/replace.md +1 -1
  15. package/prompts/undo-last-change-guidelines.md +2 -4
  16. package/prompts/undo-last-change-snippet.md +1 -1
  17. package/prompts/undo-last-change.md +1 -1
  18. package/src/commit.ts +2 -1
  19. package/src/edit-common.ts +45 -0
  20. package/src/file-kind.ts +14 -8
  21. package/src/file-reader.ts +34 -13
  22. package/src/fs-write.ts +60 -3
  23. package/src/grep.ts +119 -32
  24. package/src/hash-store/cache.ts +18 -0
  25. package/src/hash-store/retry.ts +48 -0
  26. package/src/hash-store/validation.ts +62 -0
  27. package/src/hash-store.ts +68 -133
  28. package/src/hashline/hash.ts +11 -9
  29. package/src/hashline/parse.ts +15 -1
  30. package/src/hashline/resolve.ts +3 -2
  31. package/src/insert.ts +11 -32
  32. package/src/normalize.ts +27 -0
  33. package/src/payload-contract.ts +102 -0
  34. package/src/read.ts +15 -1
  35. package/src/replace-diff.ts +28 -45
  36. package/src/replace-render.ts +18 -40
  37. package/src/replace-response.ts +1 -0
  38. package/src/replace-undo.ts +28 -13
  39. package/src/replace.ts +49 -128
  40. package/src/served.ts +26 -5
  41. package/src/utils.ts +58 -0
  42. package/src/validation.ts +2 -2
  43. package/src/write-hook.ts +59 -0
  44. package/src/replace-normalize.ts +0 -13
@@ -1,12 +1,12 @@
1
- import { splitLines, truncateToBytes } from "../utils";
1
+ import { splitLines, truncateToBytes, getCached } from "../utils";
2
2
  import { MAX_HASH_SOURCE_BYTES } from "../constants";
3
3
  import {
4
4
  loadHashStore,
5
5
  type HashStore,
6
6
  getSnapshot,
7
- upsertSnapshot,
7
+ persistSnapshot,
8
8
  } from "../hash-store";
9
- import { xxh32, contentChecksum, initHasher } from "./hasher";
9
+ import { xxh32, initHasher } from "./hasher";
10
10
  import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
11
11
  export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
12
12
 
@@ -120,9 +120,10 @@ export function _lineHashesPure(content: string): string[] {
120
120
  const hashes = new Array<string>(lines.length);
121
121
  const used = new Uint32Array(BITSET_WORDS);
122
122
  const hint = { value: 0 };
123
+ const hashSourceCache = new Map<string, string>();
123
124
 
124
125
  for (let i = 0; i < lines.length; i++) {
125
- const c = hashSource(lines[i]!);
126
+ const c = getCached(hashSourceCache, lines[i]!, hashSource);
126
127
  const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
127
128
  hashes[i] = assignHash(used, baseIdx, hint);
128
129
  }
@@ -151,7 +152,7 @@ export async function lineHashes(
151
152
  );
152
153
  if (persist !== false) {
153
154
  try {
154
- upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
155
+ persistSnapshot(hashStore, path, content, newHashes);
155
156
  } catch (error) {
156
157
  console.error("Failed to persist hash snapshot:", error);
157
158
  }
@@ -172,7 +173,7 @@ export async function lineHashes(
172
173
  const newHashes = _lineHashesPure(content);
173
174
  if (persist !== false) {
174
175
  try {
175
- upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
176
+ persistSnapshot(hashStore, path, content, newHashes);
176
177
  } catch (error) {
177
178
  console.error("Failed to persist hash snapshot:", error);
178
179
  }
@@ -224,6 +225,7 @@ function mapStableHashes(
224
225
  const newHashes = new Array<string>(newLines.length);
225
226
  const used = new Uint32Array(BITSET_WORDS);
226
227
  const hint = { value: 0 };
228
+ const hashSourceCache = new Map<string, string>();
227
229
  const removed = removedHashes ?? new Set<string>();
228
230
 
229
231
  const oldHashIndex = new Map<string, number>();
@@ -260,7 +262,7 @@ function mapStableHashes(
260
262
 
261
263
  const newByContent = new Map<string, number[]>();
262
264
  for (let i = 0; i < newLines.length; i++) {
263
- const key = hashSource(newLines[i]!);
265
+ const key = getCached(hashSourceCache, newLines[i]!, hashSource);
264
266
  const list = newByContent.get(key);
265
267
  if (list) list.push(i);
266
268
  else newByContent.set(key, [i]);
@@ -275,7 +277,7 @@ function mapStableHashes(
275
277
  };
276
278
 
277
279
  for (const entry of survivors) {
278
- const candidates = newByContent.get(hashSource(oldLines[entry.index]!));
280
+ const candidates = newByContent.get(getCached(hashSourceCache, oldLines[entry.index]!, hashSource));
279
281
  if (!candidates || candidates.length === 0) continue;
280
282
  const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
281
283
  const pos = nearestNew(candidates, target);
@@ -306,7 +308,7 @@ function mapStableHashes(
306
308
 
307
309
  for (let i = 0; i < newLines.length; i++) {
308
310
  if (newHashes[i]) continue;
309
- const c = hashSource(newLines[i]!);
311
+ const c = getCached(hashSourceCache, newLines[i]!, hashSource);
310
312
  const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
311
313
  newHashes[i] = assignHash(used, baseIdx, hint);
312
314
  }
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
+ HASH_CLASS,
4
5
  } from "./hash";
5
6
  import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
6
7
 
8
+ const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
9
+
7
10
  export type Anchor = { hash: string };
8
11
 
9
12
  function diagRef(ref: string): string {
@@ -16,7 +19,18 @@ function diagRef(ref: string): string {
16
19
  if (/^\d+/.test(trimmed)) {
17
20
  return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
18
21
  }
19
-
22
+ if (trimmed.includes("│") && trimmed.includes("\n")) {
23
+ const lines = trimmed.split(/\r?\n/);
24
+ const first = lines[0] ?? "";
25
+ const last = lines[lines.length - 1] ?? "";
26
+ const hashRe = HASH_EXTRACT_RE;
27
+ const firstMatch = first.match(hashRe);
28
+ const lastMatch = last.match(hashRe);
29
+ const firstHash = firstMatch?.[0] ?? "aB3";
30
+ const lastHash = lastMatch?.[0] ?? "aB3";
31
+ const preview = first.slice(0, 60);
32
+ return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 3-char hash (e.g. "aB3"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
33
+ }
20
34
  if (trimmed.includes("│")) {
21
35
  return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
22
36
  }
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
1
+ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
2
2
  import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
@@ -462,7 +462,8 @@ export function valEdit(
462
462
  }
463
463
  const endLine = endResolved.line;
464
464
  const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
465
- const canonLines = fileLines.map((line) => canon(line));
465
+ const canonCache = new Map<string, string>();
466
+ const canonLines = fileLines.map((line) => getCached(canonCache, line, canon));
466
467
  boundaryDups.push(
467
468
  ...trailingDups(edit.content_lines, fileLines, endLine),
468
469
  ...leadingDups(edit.content_lines, fileLines, startResolved.line),
package/src/insert.ts CHANGED
@@ -1,20 +1,17 @@
1
1
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
2
  import { Type } from "typebox";
4
3
  import { constants } from "fs";
5
- import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
4
+ import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
6
5
  import { commitEdit } from "./commit";
7
6
  import { readNormFile, type NormFile } from "./file-reader";
8
- import { resolveTarget } from "./fs-write";
9
7
  import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
10
8
  import { stripAnchorRow } from "./hashline/resolve";
11
- import { toCwd } from "./paths";
12
9
  import { loadP, loadGuide } from "./prompts";
13
- import { normReq } from "./replace-normalize";
14
- import { genDiff } from "./replace-diff";
15
- import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
16
- import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
10
+ import { normReq } from "./payload-contract";
11
+ import { isRec, rejectUnknownFields, splitLines } from "./utils";
17
12
  import { clearBoundaryBypass } from "./boundary-bypass";
13
+ import type { RPreview, RRState } from "./replace-render";
14
+ import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
18
15
 
19
16
  const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
20
17
 
@@ -121,12 +118,9 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
121
118
  preloadedNorm: preload,
122
119
  skipBoundaryDedup: true,
123
120
  });
124
- if (pipe.originalNormalized === pipe.result) {
125
- return { error: `No changes made to ${normalized.path}. The edit produced identical content.` };
126
- }
127
- return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
121
+ return previewFromPipe(pipe);
128
122
  } catch (error: unknown) {
129
- return { error: error instanceof Error ? error.message : String(error) };
123
+ return previewError(error);
130
124
  }
131
125
  }
132
126
 
@@ -163,32 +157,17 @@ export function buildInsertToolDef(): InsertToolDef {
163
157
  description: loadP("../prompts/insert.md"),
164
158
  promptSnippet: loadP("../prompts/insert-snippet.md"),
165
159
  promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
166
- prepareArguments: makePrepareArguments(),
160
+ ...editToolBase,
167
161
  parameters: insertToolSchema,
168
- renderShell: "default",
169
- renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
170
- renderResult(result, { isPartial }, theme, context) {
171
- return renderEditResult(
172
- result as {
173
- content?: Array<{ type: string; text?: string }>;
174
- details?: ReplaceDetails;
175
- },
176
- isPartial,
177
- theme,
178
- context,
179
- );
180
- },
181
-
162
+ renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
163
+ renderResult: editRenderResultWrapper,
182
164
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
183
165
  const canonical = normReq(params);
184
166
  assertInsertReq(canonical);
185
167
  const req = canonical;
186
168
  const path = req.path;
187
169
  const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
188
- const absolutePath = toCwd(path, ctx.cwd);
189
- const mutationTargetPath = await resolveTarget(absolutePath);
190
- return withFileMutationQueue(mutationTargetPath, async () => {
191
- abortIf(signal);
170
+ return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
192
171
  const preload = await readNormFile(path, ctx.cwd, {
193
172
  signal,
194
173
  accessMode: constants.R_OK | constants.W_OK,
@@ -0,0 +1,27 @@
1
+ export type LineEnding = "\r\n" | "\n" | "\r";
2
+
3
+ export function detectEnding(content: string): LineEnding {
4
+ const crIdx = content.indexOf("\r");
5
+ const lfIdx = content.indexOf("\n");
6
+ if (crIdx === -1 && lfIdx === -1) return "\n";
7
+ if (crIdx === -1) return "\n";
8
+ if (lfIdx === -1) return "\r";
9
+ if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
10
+ return "\n";
11
+ }
12
+
13
+ export function toLF(text: string): string {
14
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
15
+ }
16
+
17
+ export function restoreEndings(text: string, ending: LineEnding): string {
18
+ if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
19
+ if (ending === "\r") return text.replace(/\n/g, "\r");
20
+ return text;
21
+ }
22
+
23
+ export function stripBOM(content: string): { bom: string; text: string } {
24
+ return content.startsWith("\uFEFF")
25
+ ? { bom: "\uFEFF", text: content.slice(1) }
26
+ : { bom: "", text: content };
27
+ }
@@ -0,0 +1,102 @@
1
+ import { Type } from "typebox";
2
+ import { isRec, normalizeFilePath, rejectUnknownFields } from "./utils";
3
+
4
+ const replacementLinesSchema = Type.Array(
5
+ Type.String({
6
+ description:
7
+ "One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
8
+ }),
9
+ {
10
+ description:
11
+ "Replacement lines as an array of strings, one element per line. Use [] to delete the range.",
12
+ },
13
+ );
14
+
15
+ const removeFromSchema = Type.String({
16
+ description:
17
+ "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
18
+ });
19
+
20
+ const removeToSchema = Type.String({
21
+ description:
22
+ "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
23
+ });
24
+
25
+ export const editToolSchema = Type.Object(
26
+ {
27
+ path: Type.Optional(
28
+ Type.String({
29
+ description:
30
+ "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake.",
31
+ }),
32
+ ),
33
+ remove_from: removeFromSchema,
34
+ remove_to: removeToSchema,
35
+ replacement_lines: replacementLinesSchema,
36
+ },
37
+ { additionalProperties: false },
38
+ );
39
+
40
+ export type ReqParams = {
41
+ path: string;
42
+ remove_from: string;
43
+ remove_to: string;
44
+ replacement_lines: string[];
45
+ };
46
+
47
+ const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
48
+
49
+ export function assertReq(request: unknown): asserts request is ReqParams {
50
+ if (!isRec(request)) {
51
+ throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
52
+ }
53
+ rejectUnknownFields(request, ROOT_KS, "Edit request");
54
+ if (typeof request.path !== "string" || request.path.length === 0) {
55
+ throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
56
+ }
57
+ if (
58
+ typeof request.remove_from !== "string" ||
59
+ typeof request.remove_to !== "string" ||
60
+ !Array.isArray(request.replacement_lines) ||
61
+ request.replacement_lines.some((line) => typeof line !== "string")
62
+ ) {
63
+ throw new Error(
64
+ '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
65
+ );
66
+ }
67
+ }
68
+
69
+ export function normReq(input: unknown): unknown {
70
+ if (!isRec(input)) {
71
+ return input;
72
+ }
73
+ const record: Record<string, unknown> = { ...input };
74
+ normalizeFilePath(record);
75
+ return record;
76
+ }
77
+
78
+ export function getPreviewInput(args: unknown): ReqParams | null {
79
+ let normalized: unknown;
80
+ try {
81
+ normalized = normReq(args);
82
+ } catch {
83
+ return null;
84
+ }
85
+ if (!isRec(normalized) || typeof normalized.path !== "string") {
86
+ return null;
87
+ }
88
+ if (
89
+ typeof normalized.remove_from !== "string" ||
90
+ typeof normalized.remove_to !== "string" ||
91
+ !Array.isArray(normalized.replacement_lines) ||
92
+ normalized.replacement_lines.some((line) => typeof line !== "string")
93
+ ) {
94
+ return null;
95
+ }
96
+ return {
97
+ path: normalized.path,
98
+ remove_from: normalized.remove_from,
99
+ remove_to: normalized.remove_to,
100
+ replacement_lines: normalized.replacement_lines,
101
+ };
102
+ }
package/src/read.ts CHANGED
@@ -13,10 +13,11 @@ import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
13
13
  import { readNormFile, safeSnapId } from "./file-reader";
14
14
  import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
15
15
  import { toCwd } from "./paths";
16
- import { abortIf, makePrepareArguments, visLines } from "./utils";
16
+ import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
17
17
  import { recordServedSafe } from "./served";
18
18
  import { loadP, loadGuide } from "./prompts";
19
19
  import { valAccess } from "./validation";
20
+ import { Text } from "@earendil-works/pi-tui";
20
21
 
21
22
  const R_DESC = loadP("../prompts/read.md");
22
23
 
@@ -190,6 +191,18 @@ export function regRead(pi: ExtensionAPI): void {
190
191
  }),
191
192
  ),
192
193
  }),
194
+ executionMode: "sequential",
195
+ renderResult(result, { isPartial, expanded }, theme, context) {
196
+ if (isPartial) return new Text((theme as unknown as { fg: (a:string,b:string)=>string }).fg("warning", "Reading..."), 0, 0);
197
+ const raw = (result.content?.[0] as { text?: string } | undefined)?.text;
198
+ if (typeof raw !== "string") return new Text("", 0, 0);
199
+ if ((context as unknown as { isError?: boolean }).isError) return new Text((theme as unknown as { fg: (a:string,b:string)=>string }).fg("error", raw), 0, 0);
200
+ const isExpanded = expanded === true || (context as unknown as { expanded?: boolean }).expanded === true;
201
+ if (!isExpanded) return new Text("", 0, 0);
202
+ const details = (result as unknown as { details?: { offset?: number } }).details;
203
+ const off = details?.offset ?? (context as unknown as { args?: { offset?: number } }).args?.offset ?? 1;
204
+ return new Text(numberedRead(raw, off), 0, 0);
205
+ },
193
206
 
194
207
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
195
208
  const rawPath = params.path;
@@ -235,6 +248,7 @@ export function regRead(pi: ExtensionAPI): void {
235
248
  details: {
236
249
  truncation: preview.truncation,
237
250
  snapshotId,
251
+ offset: params.offset ?? 1,
238
252
  ...(preview.nextOffset !== undefined
239
253
  ? { nextOffset: preview.nextOffset }
240
254
  : {}),
@@ -5,37 +5,15 @@ import {
5
5
  ANCHOR_LEN,
6
6
  HASH_SEP,
7
7
  } from "./hashline";
8
+ import {
9
+ detectEnding,
10
+ toLF,
11
+ restoreEndings,
12
+ stripBOM,
13
+ type LineEnding,
14
+ } from "./normalize";
8
15
 
9
- export type LineEnding = "\r\n" | "\n" | "\r";
10
-
11
- export function detectEnding(content: string): LineEnding {
12
- const lfIdx = content.indexOf("\n");
13
- if (lfIdx === -1) {
14
- return content.indexOf("\r") >= 0 ? "\r" : "\n";
15
- }
16
- const crlfIdx = content.indexOf("\r\n");
17
- if (crlfIdx === -1) return "\n";
18
- return crlfIdx < lfIdx ? "\r\n" : "\n";
19
- }
20
-
21
- export function toLF(text: string): string {
22
- return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
23
- }
24
-
25
- export function restoreEndings(
26
- text: string,
27
- ending: LineEnding,
28
- ): string {
29
- if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
30
- if (ending === "\r") return text.replace(/\n/g, "\r");
31
- return text;
32
- }
33
-
34
- export function stripBOM(content: string): { bom: string; text: string } {
35
- return content.startsWith("\uFEFF")
36
- ? { bom: "\uFEFF", text: content.slice(1) }
37
- : { bom: "", text: content };
38
- }
16
+ export { detectEnding, toLF, restoreEndings, stripBOM, type LineEnding };
39
17
 
40
18
  function fmtDiffLine(
41
19
  prefix: " " | "+" | "-",
@@ -69,7 +47,7 @@ export function genDiff(
69
47
  newContentHashes?: string[],
70
48
  oldContentHashes?: string[],
71
49
  limits?: DiffLimits,
72
- ): { diff: string; firstChangedLine: number | undefined } {
50
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
73
51
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
74
52
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
75
53
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
@@ -83,8 +61,9 @@ export function genDiff(
83
61
  let outBytes = 0;
84
62
  let stopped = false;
85
63
  let diffTruncated = false;
64
+ const lineNumbers: (number|undefined)[] = [];
86
65
 
87
- const emitPlain = (line: string): void => {
66
+ const emitPlain = (line: string, num?: number): void => {
88
67
  if (stopped) return;
89
68
  const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
90
69
  if (outBytes + lineBytes > maxBytes) {
@@ -94,15 +73,16 @@ export function genDiff(
94
73
  }
95
74
  outBytes += lineBytes;
96
75
  output.push(line);
76
+ lineNumbers.push(num);
97
77
  };
98
78
 
99
- const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined): void => {
79
+ const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
100
80
  if (stopped) return;
101
81
  const full = fmtDiffLine(prefix, line, hash);
102
82
  const rowBytes = Buffer.byteLength(full, "utf-8");
103
83
  if (rowBytes > maxLineBytes) {
104
84
  const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
105
- emitPlain(fmtDiffLine(prefix, marker, hash));
85
+ emitPlain(fmtDiffLine(prefix, marker, hash), num);
106
86
  return;
107
87
  }
108
88
  if (outBytes + rowBytes + 1 > maxBytes) {
@@ -112,6 +92,7 @@ export function genDiff(
112
92
  }
113
93
  outBytes += rowBytes + 1;
114
94
  output.push(full);
95
+ lineNumbers.push(num);
115
96
  };
116
97
 
117
98
  for (let i = 0; i < parts.length; i++) {
@@ -127,11 +108,11 @@ export function genDiff(
127
108
  if (stopped) break;
128
109
  if (part.added) {
129
110
  const hash = effectiveNewHashes[newLineNum - 1];
130
- emitRow("+", displayLines[k]!, hash);
111
+ emitRow("+", displayLines[k]!, hash, newLineNum);
131
112
  newLineNum++;
132
113
  } else {
133
114
  const hash = oldContentHashes?.[oldLineNum - 1];
134
- emitRow("-", displayLines[k]!, hash);
115
+ emitRow("-", displayLines[k]!, hash, oldLineNum);
135
116
  oldLineNum++;
136
117
  }
137
118
  }
@@ -198,25 +179,25 @@ export function genDiff(
198
179
  }
199
180
 
200
181
  if (skipStart > 0) {
201
- emitPlain(" ...");
182
+ emitPlain(" ...", undefined);
202
183
  newLineNum += skipStart;
203
184
  oldLineNum += skipStart;
204
185
  }
205
186
  for (const line of linesToShow) {
206
187
  if (stopped) break;
207
188
  if (isEllipsisMarker(line)) {
208
- emitPlain(" ...");
189
+ emitPlain(" ...", undefined);
209
190
  newLineNum += skipMiddle;
210
191
  oldLineNum += skipMiddle;
211
192
  continue;
212
193
  }
213
194
  const hash = effectiveNewHashes[newLineNum - 1];
214
- emitRow(" ", line, hash);
195
+ emitRow(" ", line, hash, newLineNum);
215
196
  newLineNum++;
216
197
  oldLineNum++;
217
198
  }
218
199
  if (skipTail > 0) {
219
- emitPlain(" ...");
200
+ emitPlain(" ...", undefined);
220
201
  }
221
202
  } else {
222
203
  newLineNum += displayLines.length;
@@ -227,10 +208,12 @@ export function genDiff(
227
208
 
228
209
  if (diffTruncated) {
229
210
  output.push(" ...");
211
+ lineNumbers.push(undefined);
230
212
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
213
+ lineNumbers.push(undefined);
231
214
  }
232
215
 
233
- return { diff: output.join("\n"), firstChangedLine };
216
+ return { diff: output.join("\n"), firstChangedLine, lineNumbers };
234
217
  }
235
218
 
236
219
  export function genPatch(
@@ -239,10 +222,10 @@ export function genPatch(
239
222
  newContent: string,
240
223
  limits?: DiffLimits,
241
224
  ): { patch: string; truncated: boolean } {
242
- const full = Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
243
- context: 4,
244
- headerOptions: Diff.FILE_HEADERS_ONLY,
245
- });
225
+ const patchOpts: Record<string, unknown> = { context: 4 };
226
+ const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
227
+ if (ho !== undefined) patchOpts.headerOptions = ho;
228
+ const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
246
229
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
247
230
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
248
231
  const out: string[] = [];
@@ -1,8 +1,9 @@
1
1
  import { Markdown, Text } from "@earendil-works/pi-tui";
2
2
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
- import { normReq } from "./replace-normalize";
4
- import type { ReqParams, ReplaceDetails } from "./replace";
5
- import { isRec } from "./utils";
3
+ import type { ReplaceDetails } from "./replace";
4
+ import { withLineNumbers } from "./utils";
5
+ import { getPreviewInput } from "./payload-contract";
6
+ export { getPreviewInput };
6
7
 
7
8
  export type FgT = Pick<Theme, "fg">;
8
9
  export type CallT = Pick<Theme, "fg" | "bold">;
@@ -20,42 +21,12 @@ export type RRState = {
20
21
  previewTimer?: ReturnType<typeof setTimeout>;
21
22
  };
22
23
 
23
- export function getPreviewInput(
24
- args: unknown,
25
- ): ReqParams | null {
26
- let normalized: unknown;
27
- try {
28
- normalized = normReq(args);
29
- } catch {
30
- return null;
31
- }
32
- if (!isRec(normalized) || typeof normalized.path !== "string") {
33
- return null;
34
- }
35
-
36
- if (
37
- typeof normalized.remove_from !== "string" ||
38
- typeof normalized.remove_to !== "string" ||
39
- !Array.isArray(normalized.replacement_lines) ||
40
- normalized.replacement_lines.some((line) => typeof line !== "string")
41
- ) {
42
- return null;
43
- }
44
-
45
- const request: ReqParams = {
46
- path: normalized.path,
47
- remove_from: normalized.remove_from,
48
- remove_to: normalized.remove_to,
49
- replacement_lines: normalized.replacement_lines,
50
- };
51
- return request;
52
- }
53
-
54
24
  type DiffRowKind = "added" | "removed" | "context";
55
25
 
56
26
  function diffRowKind(line: string): DiffRowKind {
57
- if (line.startsWith("+") && !line.startsWith("+++")) return "added";
58
- if (line.startsWith("-") && !line.startsWith("---")) return "removed";
27
+ const stripped = line.replace(/^\s*\d+\s+│\s*/, "");
28
+ if (stripped.startsWith("+") && !stripped.startsWith("+++")) return "added";
29
+ if (stripped.startsWith("-") && !stripped.startsWith("---")) return "removed";
59
30
  return "context";
60
31
  }
61
32
 
@@ -67,6 +38,9 @@ export function colorLines(lines: string[], theme: FgT): string[] {
67
38
  return theme.fg("dim", line);
68
39
  });
69
40
  }
41
+ export function toNumberedDiff(diff: string, lineNumbers: (number|undefined)[]): string {
42
+ return withLineNumbers(diff, lineNumbers);
43
+ }
70
44
 
71
45
  export function fmtPreview(
72
46
  diff: string,
@@ -173,10 +147,11 @@ export function buildAppliedText(
173
147
  const summary = extractSummary(text);
174
148
  if (summary) sections.push(summary);
175
149
  if (details?.diff) {
150
+ const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
176
151
  const diffLines = details.diff.split("\n");
177
152
  const diffSection = expanded
178
- ? fmtResult(details.diff, theme)
179
- : fmtPreview(details.diff, false, theme);
153
+ ? fmtResult(rawDiff, theme)
154
+ : fmtPreview(rawDiff, false, theme);
180
155
  const hint =
181
156
  !expanded && diffLines.length > RESULT_PREVIEW_LINES
182
157
  ? ` (${expandHint()})`
@@ -322,10 +297,12 @@ export function makeRenderCall(
322
297
 
323
298
  export function renderEditResult(
324
299
  result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
325
- isPartial: boolean,
300
+ options: { isPartial: boolean; expanded?: boolean } | boolean,
326
301
  theme: FgT,
327
302
  context: any,
328
303
  ): Text | Markdown {
304
+ const isPartial = typeof options === "boolean" ? options : options.isPartial;
305
+ const optionsExpanded = typeof options === "boolean" ? undefined : options.expanded;
329
306
  if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
330
307
  const renderedText = getResultText(result);
331
308
  const renderState = context.state as RRState | undefined;
@@ -343,7 +320,8 @@ export function renderEditResult(
343
320
  : new Text("", 0, 0);
344
321
  }
345
322
  if (isApplied(result.details)) {
346
- const appliedText = buildAppliedText(renderedText, result.details, theme, context.expanded === true);
323
+ const isExpanded = optionsExpanded === true || context.expanded === true;
324
+ const appliedText = buildAppliedText(renderedText, result.details, theme, isExpanded);
347
325
  return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
348
326
  }
349
327
  if (!renderedText) return new Text("", 0, 0);
@@ -164,6 +164,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
164
164
  editMeta.firstChangedLine ?? diffResult.firstChangedLine,
165
165
  snapshotId,
166
166
  metrics,
167
+ diffLineNumbers: diffResult.lineNumbers,
167
168
  },
168
169
  };
169
170
  }