pi-hashline-edit-pro 2.7.2 → 2.8.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/hash-store.ts CHANGED
@@ -6,6 +6,8 @@ import { initHasher, contentChecksum } from "./hashline/hasher";
6
6
  import { HASH_RE } from "./hashline/alphabet";
7
7
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
8
8
 
9
+ export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
10
+
9
11
  type SqlParams = (string | number)[];
10
12
 
11
13
  interface RawStatement {
@@ -104,21 +106,22 @@ export function isValidHashList(value: unknown): value is string[] {
104
106
  return true;
105
107
  }
106
108
 
107
- export function parseHashList(raw: string, onInvalid: () => void): string[] | undefined {
109
+ export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
108
110
  let parsed: unknown;
109
111
  try {
110
112
  parsed = JSON.parse(raw);
111
- } catch {
113
+ } catch (error) {
114
+ console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
112
115
  onInvalid();
113
116
  return undefined;
114
117
  }
115
118
  if (!isValidHashList(parsed)) {
119
+ console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
116
120
  onInvalid();
117
121
  return undefined;
118
122
  }
119
123
  return parsed;
120
124
  }
121
-
122
125
  export function parseStoredHashes(
123
126
  row: Record<string, unknown> | undefined,
124
127
  onInvalid: () => void,
@@ -157,13 +160,14 @@ function isBusyError(error: unknown): boolean {
157
160
  return error instanceof Error && /busy|locked/i.test(error.message);
158
161
  }
159
162
 
163
+ const sleepSab = new Int32Array(new SharedArrayBuffer(4));
164
+
160
165
  function sleepSync(ms: number): void {
161
- const sab = new Int32Array(new SharedArrayBuffer(4));
162
- Atomics.wait(sab, 0, 0, ms);
166
+ Atomics.wait(sleepSab, 0, 0, ms);
163
167
  }
164
168
 
165
169
  const BUSY_RETRIES = 3;
166
- const BUSY_RETRY_DELAY_MS = 100;
170
+ const BUSY_RETRY_DELAY_MS = 50;
167
171
 
168
172
  function withBusyRetry<T>(fn: () => T): T {
169
173
  let lastError: unknown;
@@ -173,14 +177,28 @@ function withBusyRetry<T>(fn: () => T): T {
173
177
  } catch (error) {
174
178
  lastError = error;
175
179
  if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
176
- sleepSync(BUSY_RETRY_DELAY_MS);
180
+ sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
177
181
  }
178
182
  }
179
183
  throw lastError;
180
184
  }
181
185
 
182
- function openDbWithBusyRetry(storePath: string): { db: RawDb; stmts: Prepared } {
183
- return withBusyRetry(() => openDb(storePath));
186
+ async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
187
+ let lastError: unknown;
188
+ for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
189
+ try {
190
+ return fn();
191
+ } catch (error) {
192
+ lastError = error;
193
+ if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
194
+ await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
195
+ }
196
+ }
197
+ throw lastError;
198
+ }
199
+
200
+ async function openDbWithBusyRetryAsync(storePath: string): Promise<{ db: RawDb; stmts: Prepared }> {
201
+ return withBusyRetryAsync(() => openDb(storePath));
184
202
  }
185
203
 
186
204
  function retriedWrite(
@@ -341,19 +359,19 @@ async function openStore(storePath: string): Promise<HashStore> {
341
359
  let existed = existsSync(storePath);
342
360
  let opened: { db: RawDb; stmts: Prepared };
343
361
  try {
344
- opened = openDbWithBusyRetry(storePath);
362
+ opened = await openDbWithBusyRetryAsync(storePath);
345
363
  } catch (error) {
346
364
  if (!isCorruptionError(error)) throw error;
347
365
  console.error("Hash store failed to open, rebuilding:", error);
348
366
  await quarantineStore(storePath);
349
367
  existed = false;
350
- opened = openDbWithBusyRetry(storePath);
368
+ opened = await openDbWithBusyRetryAsync(storePath);
351
369
  }
352
370
  if (!isHealthy(opened.db)) {
353
371
  shutdownDb(opened.db);
354
372
  await quarantineStore(storePath);
355
373
  existed = false;
356
- opened = openDbWithBusyRetry(storePath);
374
+ opened = await openDbWithBusyRetryAsync(storePath);
357
375
  }
358
376
  const { db, stmts } = opened;
359
377
 
@@ -403,9 +421,9 @@ export function shutdownHashStore(): void {
403
421
  snapshotCache.clear();
404
422
  }
405
423
 
406
- function withStore(fn: () => void): void {
424
+ export function withStore(fn: () => void): void {
407
425
  if (!cachedDb) {
408
- throw new Error("Hash store is not open; transactional update aborted");
426
+ throw new Error(STORE_NOT_OPEN_MESSAGE);
409
427
  }
410
428
  withBusyRetry(() => {
411
429
  cachedDb!.db.exec("BEGIN IMMEDIATE");
@@ -528,6 +546,14 @@ export function upsertSnapshot(
528
546
  store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
529
547
  cacheSnapshot(path, checksum, lineCount, hashes);
530
548
  }
549
+ export function persistSnapshot(
550
+ store: HashStore,
551
+ path: string,
552
+ content: string,
553
+ hashes: string[],
554
+ ): void {
555
+ upsertSnapshot(store, path, contentChecksum(content), splitLines(content).length, hashes);
556
+ }
531
557
 
532
558
  export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
533
559
  store.stmts.undoUpsert(
@@ -593,22 +619,32 @@ export async function pruneMissing(store: HashStore): Promise<void> {
593
619
  withStore(() => {
594
620
  for (const path of missing) {
595
621
  store.stmts.deleteOne(path);
596
- snapshotCache.delete(path);
597
622
  store.stmts.servedDelete(path);
598
623
  }
599
624
  });
625
+ for (const path of missing) snapshotCache.delete(path);
600
626
  }
601
627
 
602
628
  function matchPathsByHashes(
603
629
  rows: { path: string; hashes: string }[],
604
630
  hashes: string[],
605
631
  ): string[] {
632
+ const needed = new Set(hashes);
633
+ if (needed.size === 0) return [];
606
634
  const matches: string[] = [];
607
635
  for (const row of rows) {
608
636
  try {
609
637
  const parsed = JSON.parse(row.hashes) as unknown;
610
638
  if (!isValidHashList(parsed)) continue;
611
- if (hashes.every((h) => parsed.includes(h))) matches.push(row.path);
639
+ const parsedSet = new Set(parsed);
640
+ let ok = true;
641
+ for (const h of needed) {
642
+ if (!parsedSet.has(h)) {
643
+ ok = false;
644
+ break;
645
+ }
646
+ }
647
+ if (ok) matches.push(row.path);
612
648
  } catch {
613
649
  continue;
614
650
  }
@@ -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
@@ -2,16 +2,14 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
2
2
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import { constants } from "fs";
5
- import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
5
+ import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
6
6
  import { commitEdit } from "./commit";
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
- import { resolveTarget } from "./fs-write";
8
+ import { resolveInCwd } from "./fs-write";
9
9
  import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
10
10
  import { stripAnchorRow } from "./hashline/resolve";
11
- import { toCwd } from "./paths";
12
11
  import { loadP, loadGuide } from "./prompts";
13
- import { normReq } from "./replace-normalize";
14
- import { genDiff } from "./replace-diff";
12
+ import { normReq } from "./payload-contract";
15
13
  import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
16
14
  import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
17
15
  import { clearBoundaryBypass } from "./boundary-bypass";
@@ -121,12 +119,9 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
121
119
  preloadedNorm: preload,
122
120
  skipBoundaryDedup: true,
123
121
  });
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 };
122
+ return previewFromPipe(pipe);
128
123
  } catch (error: unknown) {
129
- return { error: error instanceof Error ? error.message : String(error) };
124
+ return previewError(error);
130
125
  }
131
126
  }
132
127
 
@@ -164,16 +159,17 @@ export function buildInsertToolDef(): InsertToolDef {
164
159
  promptSnippet: loadP("../prompts/insert-snippet.md"),
165
160
  promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
166
161
  prepareArguments: makePrepareArguments(),
162
+ executionMode: "sequential",
167
163
  parameters: insertToolSchema,
168
164
  renderShell: "default",
169
165
  renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
170
- renderResult(result, { isPartial }, theme, context) {
166
+ renderResult(result, { isPartial, expanded }, theme, context) {
171
167
  return renderEditResult(
172
168
  result as {
173
169
  content?: Array<{ type: string; text?: string }>;
174
170
  details?: ReplaceDetails;
175
171
  },
176
- isPartial,
172
+ { isPartial, expanded },
177
173
  theme,
178
174
  context,
179
175
  );
@@ -185,8 +181,7 @@ export function buildInsertToolDef(): InsertToolDef {
185
181
  const req = canonical;
186
182
  const path = req.path;
187
183
  const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
188
- const absolutePath = toCwd(path, ctx.cwd);
189
- const mutationTargetPath = await resolveTarget(absolutePath);
184
+ const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
190
185
  return withFileMutationQueue(mutationTargetPath, async () => {
191
186
  abortIf(signal);
192
187
  const preload = await readNormFile(path, ctx.cwd, {
@@ -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
  : {}),
@@ -9,13 +9,13 @@ import {
9
9
  export type LineEnding = "\r\n" | "\n" | "\r";
10
10
 
11
11
  export function detectEnding(content: string): LineEnding {
12
+ const crIdx = content.indexOf("\r");
12
13
  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";
14
+ if (crIdx === -1 && lfIdx === -1) return "\n";
15
+ if (crIdx === -1) return "\n";
16
+ if (lfIdx === -1) return "\r";
17
+ if (crIdx < lfIdx) return content[crIdx + 1] === "\n" ? "\r\n" : "\r";
18
+ return "\n";
19
19
  }
20
20
 
21
21
  export function toLF(text: string): string {
@@ -69,7 +69,7 @@ export function genDiff(
69
69
  newContentHashes?: string[],
70
70
  oldContentHashes?: string[],
71
71
  limits?: DiffLimits,
72
- ): { diff: string; firstChangedLine: number | undefined } {
72
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
73
73
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
74
74
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
75
75
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
@@ -83,8 +83,9 @@ export function genDiff(
83
83
  let outBytes = 0;
84
84
  let stopped = false;
85
85
  let diffTruncated = false;
86
+ const lineNumbers: (number|undefined)[] = [];
86
87
 
87
- const emitPlain = (line: string): void => {
88
+ const emitPlain = (line: string, num?: number): void => {
88
89
  if (stopped) return;
89
90
  const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
90
91
  if (outBytes + lineBytes > maxBytes) {
@@ -94,15 +95,16 @@ export function genDiff(
94
95
  }
95
96
  outBytes += lineBytes;
96
97
  output.push(line);
98
+ lineNumbers.push(num);
97
99
  };
98
100
 
99
- const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined): void => {
101
+ const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
100
102
  if (stopped) return;
101
103
  const full = fmtDiffLine(prefix, line, hash);
102
104
  const rowBytes = Buffer.byteLength(full, "utf-8");
103
105
  if (rowBytes > maxLineBytes) {
104
106
  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));
107
+ emitPlain(fmtDiffLine(prefix, marker, hash), num);
106
108
  return;
107
109
  }
108
110
  if (outBytes + rowBytes + 1 > maxBytes) {
@@ -112,6 +114,7 @@ export function genDiff(
112
114
  }
113
115
  outBytes += rowBytes + 1;
114
116
  output.push(full);
117
+ lineNumbers.push(num);
115
118
  };
116
119
 
117
120
  for (let i = 0; i < parts.length; i++) {
@@ -127,11 +130,11 @@ export function genDiff(
127
130
  if (stopped) break;
128
131
  if (part.added) {
129
132
  const hash = effectiveNewHashes[newLineNum - 1];
130
- emitRow("+", displayLines[k]!, hash);
133
+ emitRow("+", displayLines[k]!, hash, newLineNum);
131
134
  newLineNum++;
132
135
  } else {
133
136
  const hash = oldContentHashes?.[oldLineNum - 1];
134
- emitRow("-", displayLines[k]!, hash);
137
+ emitRow("-", displayLines[k]!, hash, oldLineNum);
135
138
  oldLineNum++;
136
139
  }
137
140
  }
@@ -198,25 +201,25 @@ export function genDiff(
198
201
  }
199
202
 
200
203
  if (skipStart > 0) {
201
- emitPlain(" ...");
204
+ emitPlain(" ...", undefined);
202
205
  newLineNum += skipStart;
203
206
  oldLineNum += skipStart;
204
207
  }
205
208
  for (const line of linesToShow) {
206
209
  if (stopped) break;
207
210
  if (isEllipsisMarker(line)) {
208
- emitPlain(" ...");
211
+ emitPlain(" ...", undefined);
209
212
  newLineNum += skipMiddle;
210
213
  oldLineNum += skipMiddle;
211
214
  continue;
212
215
  }
213
216
  const hash = effectiveNewHashes[newLineNum - 1];
214
- emitRow(" ", line, hash);
217
+ emitRow(" ", line, hash, newLineNum);
215
218
  newLineNum++;
216
219
  oldLineNum++;
217
220
  }
218
221
  if (skipTail > 0) {
219
- emitPlain(" ...");
222
+ emitPlain(" ...", undefined);
220
223
  }
221
224
  } else {
222
225
  newLineNum += displayLines.length;
@@ -227,10 +230,12 @@ export function genDiff(
227
230
 
228
231
  if (diffTruncated) {
229
232
  output.push(" ...");
233
+ lineNumbers.push(undefined);
230
234
  output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
235
+ lineNumbers.push(undefined);
231
236
  }
232
237
 
233
- return { diff: output.join("\n"), firstChangedLine };
238
+ return { diff: output.join("\n"), firstChangedLine, lineNumbers };
234
239
  }
235
240
 
236
241
  export function genPatch(
@@ -239,10 +244,10 @@ export function genPatch(
239
244
  newContent: string,
240
245
  limits?: DiffLimits,
241
246
  ): { 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
- });
247
+ const patchOpts: Record<string, unknown> = { context: 4 };
248
+ const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
249
+ if (ho !== undefined) patchOpts.headerOptions = ho;
250
+ const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
246
251
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
247
252
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
248
253
  const out: string[] = [];