@oh-my-pi/pi-coding-agent 14.2.1 → 14.4.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/package.json +19 -19
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/cli/args.ts +10 -1
  5. package/src/cli/shell-cli.ts +15 -3
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/config/model-registry.ts +67 -15
  8. package/src/config/prompt-templates.ts +5 -5
  9. package/src/config/settings-schema.ts +63 -4
  10. package/src/cursor.ts +3 -8
  11. package/src/debug/system-info.ts +6 -2
  12. package/src/discovery/claude.ts +58 -36
  13. package/src/discovery/helpers.ts +3 -3
  14. package/src/discovery/opencode.ts +20 -2
  15. package/src/edit/diff.ts +50 -47
  16. package/src/edit/index.ts +87 -57
  17. package/src/edit/line-hash.ts +735 -19
  18. package/src/edit/modes/apply-patch.ts +0 -9
  19. package/src/edit/modes/atom.ts +658 -0
  20. package/src/edit/modes/chunk.ts +144 -78
  21. package/src/edit/modes/hashline.ts +223 -146
  22. package/src/edit/modes/patch.ts +5 -9
  23. package/src/edit/modes/replace.ts +6 -11
  24. package/src/edit/renderer.ts +112 -143
  25. package/src/edit/streaming.ts +385 -0
  26. package/src/exec/bash-executor.ts +58 -5
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +4 -12
  29. package/src/extensibility/custom-tools/types.ts +2 -0
  30. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  31. package/src/internal-urls/docs-index.generated.ts +7 -7
  32. package/src/internal-urls/pi-protocol.ts +0 -2
  33. package/src/lsp/client.ts +8 -1
  34. package/src/lsp/defaults.json +2 -1
  35. package/src/lsp/index.ts +1 -1
  36. package/src/mcp/render.ts +1 -8
  37. package/src/modes/acp/acp-agent.ts +76 -2
  38. package/src/modes/components/assistant-message.ts +5 -34
  39. package/src/modes/components/diff.ts +23 -14
  40. package/src/modes/components/footer.ts +21 -16
  41. package/src/modes/components/hook-editor.ts +1 -1
  42. package/src/modes/components/settings-defs.ts +6 -1
  43. package/src/modes/components/todo-reminder.ts +1 -8
  44. package/src/modes/components/tool-execution.ts +112 -105
  45. package/src/modes/controllers/input-controller.ts +1 -1
  46. package/src/modes/controllers/selector-controller.ts +1 -1
  47. package/src/modes/interactive-mode.ts +0 -2
  48. package/src/modes/print-mode.ts +8 -0
  49. package/src/modes/theme/mermaid-cache.ts +13 -52
  50. package/src/modes/theme/theme.ts +2 -2
  51. package/src/prompts/agents/librarian.md +1 -1
  52. package/src/prompts/agents/reviewer.md +4 -4
  53. package/src/prompts/ci-green-request.md +1 -1
  54. package/src/prompts/review-request.md +1 -1
  55. package/src/prompts/system/subagent-system-prompt.md +3 -3
  56. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  57. package/src/prompts/system/system-prompt.md +4 -1
  58. package/src/prompts/tools/ask.md +3 -2
  59. package/src/prompts/tools/ast-edit.md +15 -19
  60. package/src/prompts/tools/ast-grep.md +18 -24
  61. package/src/prompts/tools/atom.md +96 -0
  62. package/src/prompts/tools/browser.md +1 -0
  63. package/src/prompts/tools/chunk-edit.md +58 -179
  64. package/src/prompts/tools/debug.md +4 -5
  65. package/src/prompts/tools/exit-plan-mode.md +4 -5
  66. package/src/prompts/tools/find.md +4 -8
  67. package/src/prompts/tools/github.md +18 -0
  68. package/src/prompts/tools/grep.md +8 -8
  69. package/src/prompts/tools/hashline.md +22 -89
  70. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  71. package/src/prompts/tools/inspect-image.md +6 -6
  72. package/src/prompts/tools/lsp.md +6 -0
  73. package/src/prompts/tools/patch.md +12 -19
  74. package/src/prompts/tools/python.md +3 -2
  75. package/src/prompts/tools/read-chunk.md +46 -8
  76. package/src/prompts/tools/read.md +9 -6
  77. package/src/prompts/tools/ssh.md +8 -17
  78. package/src/prompts/tools/todo-write.md +54 -41
  79. package/src/sdk.ts +22 -14
  80. package/src/session/agent-session.ts +61 -22
  81. package/src/session/session-manager.ts +228 -57
  82. package/src/session/streaming-output.ts +11 -0
  83. package/src/system-prompt.ts +7 -2
  84. package/src/task/executor.ts +44 -48
  85. package/src/task/render.ts +11 -13
  86. package/src/tools/ask.ts +7 -7
  87. package/src/tools/ast-edit.ts +45 -41
  88. package/src/tools/ast-grep.ts +77 -85
  89. package/src/tools/bash.ts +21 -9
  90. package/src/tools/browser.ts +32 -30
  91. package/src/tools/calculator.ts +4 -4
  92. package/src/tools/cancel-job.ts +1 -1
  93. package/src/tools/checkpoint.ts +2 -2
  94. package/src/tools/debug.ts +41 -37
  95. package/src/tools/exit-plan-mode.ts +1 -1
  96. package/src/tools/find.ts +4 -4
  97. package/src/tools/gh-renderer.ts +12 -4
  98. package/src/tools/gh.ts +514 -712
  99. package/src/tools/grep.ts +115 -130
  100. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  101. package/src/tools/index.ts +14 -32
  102. package/src/tools/inspect-image.ts +3 -3
  103. package/src/tools/json-tree.ts +114 -114
  104. package/src/tools/match-line-format.ts +9 -8
  105. package/src/tools/notebook.ts +8 -7
  106. package/src/tools/poll-tool.ts +2 -1
  107. package/src/tools/python.ts +9 -23
  108. package/src/tools/read.ts +32 -21
  109. package/src/tools/render-mermaid.ts +1 -1
  110. package/src/tools/render-utils.ts +18 -0
  111. package/src/tools/renderers.ts +2 -2
  112. package/src/tools/report-tool-issue.ts +3 -2
  113. package/src/tools/resolve.ts +1 -1
  114. package/src/tools/review.ts +12 -10
  115. package/src/tools/search-tool-bm25.ts +2 -4
  116. package/src/tools/sqlite-reader.ts +116 -3
  117. package/src/tools/ssh.ts +4 -4
  118. package/src/tools/todo-write.ts +172 -147
  119. package/src/tools/vim.ts +14 -15
  120. package/src/tools/write.ts +4 -4
  121. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  122. package/src/utils/edit-mode.ts +2 -1
  123. package/src/utils/file-display-mode.ts +10 -5
  124. package/src/utils/git.ts +9 -5
  125. package/src/utils/shell-snapshot.ts +2 -3
  126. package/src/vim/render.ts +4 -4
  127. package/src/web/search/providers/codex.ts +129 -6
  128. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  129. package/src/prompts/tools/gh-issue-view.md +0 -11
  130. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  131. package/src/prompts/tools/gh-pr-diff.md +0 -12
  132. package/src/prompts/tools/gh-pr-push.md +0 -11
  133. package/src/prompts/tools/gh-pr-view.md +0 -11
  134. package/src/prompts/tools/gh-repo-view.md +0 -11
  135. package/src/prompts/tools/gh-run-watch.md +0 -12
  136. package/src/prompts/tools/gh-search-issues.md +0 -11
  137. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -2,18 +2,19 @@
2
2
  * Hashline edit mode — a line-addressable edit format using text hashes.
3
3
  *
4
4
  * Each line in a file is identified by its 1-indexed line number and a short
5
- * hexadecimal hash derived from the normalized line text (xxHash32, truncated to 2
6
- * hex chars).
7
- * The combined `LINE#ID` reference acts as both an address and a staleness check:
5
+ * BPE-bigram hash derived from the normalized line text (xxHash32 mod 647,
6
+ * mapped through HASHLINE_BIGRAMS).
7
+ * The combined `LINE+ID` reference acts as both an address and a staleness check:
8
8
  * if the file has changed since the caller last read it, hash mismatches are caught
9
9
  * before any mutation occurs.
10
10
  *
11
- * Displayed format: `LINENUM#HASH:TEXT`
12
- * Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
11
+ * Displayed format: `LINE+ID:TEXT`
12
+ * Reference format: `"LINE+ID"` (e.g. `"1ab"`)
13
+ *
14
+ * In tool JSON, each edit's `content` is `string[]` (one string per logical line) or
15
+ * `null` to delete the targeted range.
13
16
  */
14
17
 
15
- import * as fs from "node:fs/promises";
16
- import * as nodePath from "node:path";
17
18
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
18
19
  import { isEnoent } from "@oh-my-pi/pi-utils";
19
20
  import { type Static, Type } from "@sinclair/typebox";
@@ -21,16 +22,13 @@ import type { BunFile } from "bun";
21
22
  import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
22
23
  import type { ToolSession } from "../../tools";
23
24
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
- import {
25
- invalidateFsScanAfterDelete,
26
- invalidateFsScanAfterRename,
27
- invalidateFsScanAfterWrite,
28
- } from "../../tools/fs-cache-invalidation";
25
+ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
29
26
  import { outputMeta } from "../../tools/output-meta";
30
27
  import { resolveToCwd } from "../../tools/path-utils";
31
28
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
29
+ import { formatCodeFrameLine } from "../../tools/render-utils";
32
30
  import { generateDiffString } from "../diff";
33
- import { computeLineHash, formatLineHash } from "../line-hash";
31
+ import { computeLineHash, formatLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
34
32
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
35
33
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
36
34
 
@@ -49,15 +47,25 @@ export type HashlineEdit =
49
47
  | { op: "append_file"; lines: string[] }
50
48
  | { op: "prepend_file"; lines: string[] };
51
49
 
52
- const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
53
- const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
50
+ // Tight prefix matchers for the new format `LINE+ID:content`. Hard
51
+ // cutover do not accept legacy `LINENUM#BIGRAM:content` or tab separators.
52
+ // The terminator must be a literal colon; line-number digits are mandatory.
53
+ const HASHLINE_CONTENT_SEPARATOR_RE = HASHLINE_CONTENT_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ const HASHLINE_PREFIX_RE = new RegExp(
55
+ `^\\s*(?:>>>|>>)?\\s*(?:\\+\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
56
+ );
57
+ const HASHLINE_PREFIX_PLUS_RE = new RegExp(
58
+ `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
59
+ );
54
60
  const DIFF_PLUS_RE = /^[+](?![+])/;
61
+ const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
55
62
 
56
63
  type LinePrefixStats = {
57
64
  nonEmpty: number;
58
65
  hashPrefixCount: number;
59
66
  diffPlusHashPrefixCount: number;
60
67
  diffPlusCount: number;
68
+ truncationNoticeCount: number;
61
69
  };
62
70
 
63
71
  function collectLinePrefixStats(lines: string[]): LinePrefixStats {
@@ -66,10 +74,15 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
66
74
  hashPrefixCount: 0,
67
75
  diffPlusHashPrefixCount: 0,
68
76
  diffPlusCount: 0,
77
+ truncationNoticeCount: 0,
69
78
  };
70
79
 
71
80
  for (const line of lines) {
72
81
  if (line.length === 0) continue;
82
+ if (READ_TRUNCATION_NOTICE_RE.test(line)) {
83
+ stats.truncationNoticeCount++;
84
+ continue;
85
+ }
73
86
  stats.nonEmpty++;
74
87
  if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
75
88
  if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
@@ -79,6 +92,20 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
79
92
  return stats;
80
93
  }
81
94
 
95
+ function stripLeadingHashlinePrefixes(line: string): string {
96
+ let result = line;
97
+ let prev: string;
98
+ do {
99
+ prev = result;
100
+ result = result.replace(HASHLINE_PREFIX_RE, "");
101
+ } while (result !== prev);
102
+ return result;
103
+ }
104
+
105
+ function _filterTruncationNotices(lines: string[]): string[] {
106
+ return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line));
107
+ }
108
+
82
109
  export function stripNewLinePrefixes(lines: string[]): string[] {
83
110
  const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines);
84
111
  if (nonEmpty === 0) return lines;
@@ -88,27 +115,27 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
88
115
  !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
89
116
  if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
90
117
 
91
- return lines.map(line => {
92
- if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "");
93
- if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
94
- if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
95
- return line.replace(HASHLINE_PREFIX_RE, "");
96
- }
97
- return line;
98
- });
118
+ const mapped = lines
119
+ .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
120
+ .map(line => {
121
+ if (stripHash) return stripLeadingHashlinePrefixes(line);
122
+ if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
123
+ if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
124
+ return line.replace(HASHLINE_PREFIX_RE, "");
125
+ }
126
+ return line;
127
+ });
128
+ return mapped;
99
129
  }
100
130
 
101
131
  export function stripHashlinePrefixes(lines: string[]): string[] {
102
132
  const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines);
103
- if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines;
104
- return lines.map(line => line.replace(HASHLINE_PREFIX_RE, ""));
133
+ if (nonEmpty === 0) return lines;
134
+ if (hashPrefixCount !== nonEmpty) return lines;
135
+ return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
105
136
  }
106
137
 
107
- const linesSchema = Type.Union([
108
- Type.Array(Type.String(), { description: "content (preferred format)" }),
109
- Type.String(),
110
- Type.Null(),
111
- ]);
138
+ const linesSchema = Type.Union([Type.Array(Type.String()), Type.Null()]);
112
139
 
113
140
  const locSchema = Type.Union(
114
141
  [
@@ -128,17 +155,16 @@ const locSchema = Type.Union(
128
155
 
129
156
  export const hashlineEditSchema = Type.Object(
130
157
  {
131
- path: Type.String({ description: "File path" }),
158
+ path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
132
159
  loc: Type.Optional(locSchema),
133
160
  content: Type.Optional(linesSchema),
134
- delete: Type.Optional(Type.Boolean({ description: "Delete the file" })),
135
- move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })),
136
161
  },
137
162
  { additionalProperties: false },
138
163
  );
139
164
 
140
165
  export const hashlineEditParamsSchema = Type.Object(
141
166
  {
167
+ path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
142
168
  edits: Type.Array(hashlineEditSchema, { description: "edits" }),
143
169
  },
144
170
  { additionalProperties: false },
@@ -157,6 +183,11 @@ export interface ExecuteHashlineSingleOptions {
157
183
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
158
184
  }
159
185
 
186
+ /**
187
+ * Normalize line payloads for apply: strip read/grep line prefixes. The tool schema
188
+ * supplies `string[]` (one element per line). `null` / `undefined` yield `[]`.
189
+ * A single multiline `string` is still split on `\n` for the same normalization path.
190
+ */
160
191
  export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
161
192
  if (edit == null) return [];
162
193
  if (typeof edit === "string") {
@@ -166,15 +197,6 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
166
197
  return stripNewLinePrefixes(edit);
167
198
  }
168
199
 
169
- export function isHashlineParams(params: unknown): params is HashlineParams {
170
- if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits))
171
- return false;
172
- if (params.edits.length === 0) return true;
173
- const first = params.edits[0];
174
- if (typeof first !== "object" || first === null) return false;
175
- return "loc" in first || "delete" in first || "move" in first;
176
- }
177
-
178
200
  function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
179
201
  return edits.map(resolveEditAnchor);
180
202
  }
@@ -199,6 +221,15 @@ function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[]
199
221
  });
200
222
  }
201
223
 
224
+ export function formatFullAnchorRequirement(raw?: string): string {
225
+ const suffix = typeof raw === "string" ? raw.trim() : "";
226
+ const hashOnlyHint = /^[A-Za-z]{2}$/.test(suffix)
227
+ ? ` It looks like you supplied only the 2-letter suffix (${JSON.stringify(suffix)}). Copy the full anchor exactly as shown (for example, "160${suffix}").`
228
+ : "";
229
+ const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
230
+ return `the full anchor exactly as shown by read/grep (line number + 2-letter suffix, for example "160sr")${received}${hashOnlyHint}`;
231
+ }
232
+
202
233
  function tryParseTag(raw: string): Anchor | undefined {
203
234
  try {
204
235
  return parseTag(raw);
@@ -209,14 +240,24 @@ function tryParseTag(raw: string): Anchor | undefined {
209
240
 
210
241
  function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
211
242
  const anchor = tryParseTag(raw);
212
- if (!anchor) throw new Error(`${op} requires a valid anchor.`);
243
+ if (!anchor) throw new Error(`${op} requires ${formatFullAnchorRequirement(raw)}.`);
213
244
  return anchor;
214
245
  }
215
246
 
216
247
  function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
217
248
  const pos = tryParseTag(range.pos);
218
249
  const end = tryParseTag(range.end);
219
- if (!pos || !end) throw new Error("range requires valid pos and end anchors.");
250
+ if (!pos || !end) {
251
+ const invalid = [
252
+ !pos ? `pos=${JSON.stringify(range.pos)}` : null,
253
+ !end ? `end=${JSON.stringify(range.end)}` : null,
254
+ ]
255
+ .filter(Boolean)
256
+ .join(", ");
257
+ throw new Error(
258
+ `range requires valid pos and end anchors. Use ${formatFullAnchorRequirement()}. Invalid: ${invalid}.`,
259
+ );
260
+ }
220
261
  return { pos, end };
221
262
  }
222
263
 
@@ -333,7 +374,7 @@ function createHashlineChunkEmitter(
333
374
  }
334
375
 
335
376
  function formatHashlineStreamLine(lineNumber: number, line: string): string {
336
- return `${formatLineHash(lineNumber, line)}:${line}`;
377
+ return `${formatLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
337
378
  }
338
379
 
339
380
  function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
@@ -459,20 +500,18 @@ export async function* streamHashLinesFromLines(
459
500
  }
460
501
 
461
502
  /**
462
- * Parse a line reference string like `"5#abcd"` into structured form.
503
+ * Parse a line reference string like `"5th"` into structured form.
463
504
  *
464
- * @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
505
+ * @throws Error if the format is invalid (not `NUMBERBIGRAM`)
465
506
  */
466
507
  export function parseTag(ref: string): { line: number; hash: string } {
467
- // This regex captures:
468
- // 1. optional leading ">+" and whitespace
508
+ // Captures:
509
+ // 1. optional leading ">+-" markers and whitespace
469
510
  // 2. line number (1+ digits)
470
- // 3. "#" with optional surrounding spaces
471
- // 4. hash (2 hex chars)
472
- // 5. optional trailing display suffix (":..." or " ...")
473
- const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
511
+ // 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
512
+ const match = ref.match(new RegExp(`^\\s*[>+-]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
474
513
  if (!match) {
475
- throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
514
+ throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
476
515
  }
477
516
  const line = Number.parseInt(match[1], 10);
478
517
  if (line < 1) {
@@ -491,8 +530,8 @@ const MISMATCH_CONTEXT = 2;
491
530
  /**
492
531
  * Error thrown when one or more hashline references have stale hashes.
493
532
  *
494
- * Displays grep-style output with `>>>` markers on mismatched lines,
495
- * showing the correct `LINE#ID` so the caller can fix all refs at once.
533
+ * Displays grep-style output with `:` separator on mismatched lines and `-` on
534
+ * surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
496
535
  */
497
536
  export class HashlineMismatchError extends Error {
498
537
  readonly remaps: ReadonlyMap<string, string>;
@@ -505,11 +544,50 @@ export class HashlineMismatchError extends Error {
505
544
  const remaps = new Map<string, string>();
506
545
  for (const m of mismatches) {
507
546
  const actual = computeLineHash(m.line, fileLines[m.line - 1]);
508
- remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
547
+ remaps.set(`${m.line}${m.expected}`, `${m.line}${actual}`);
509
548
  }
510
549
  this.remaps = remaps;
511
550
  }
512
551
 
552
+ /**
553
+ * User-visible variant of {@link formatMessage} — omits the bigram fingerprint
554
+ * and uses a `│` gutter so TUI rendering is clean. The model still receives
555
+ * the full `LINE+ID:content` form via {@link Error.message}.
556
+ */
557
+ get displayMessage(): string {
558
+ return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
559
+ }
560
+
561
+ static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
562
+ const mismatchSet = new Set<number>();
563
+ for (const m of mismatches) mismatchSet.add(m.line);
564
+
565
+ const displayLines = new Set<number>();
566
+ for (const m of mismatches) {
567
+ const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
568
+ const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
569
+ for (let i = lo; i <= hi; i++) displayLines.add(i);
570
+ }
571
+
572
+ const sorted = [...displayLines].sort((a, b) => a - b);
573
+ const out: string[] = [
574
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
575
+ "Realign your edit to the file state shown below. Copy the full anchors exactly as shown (for example `160sr`, not just `sr`).",
576
+ "",
577
+ ];
578
+
579
+ const lineNumberWidth = sorted.reduce((width, lineNum) => Math.max(width, String(lineNum).length), 0);
580
+ let prevLine = -1;
581
+ for (const lineNum of sorted) {
582
+ if (prevLine !== -1 && lineNum > prevLine + 1) out.push("...");
583
+ prevLine = lineNum;
584
+ const text = fileLines[lineNum - 1];
585
+ const marker = mismatchSet.has(lineNum) ? "*" : " ";
586
+ out.push(formatCodeFrameLine(marker, lineNum, text ?? "", lineNumberWidth));
587
+ }
588
+ return out.join("\n");
589
+ }
590
+
513
591
  static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
514
592
  const mismatchSet = new Map<number, HashMismatch>();
515
593
  for (const m of mismatches) {
@@ -530,7 +608,8 @@ export class HashlineMismatchError extends Error {
530
608
  const lines: string[] = [];
531
609
 
532
610
  lines.push(
533
- `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`,
611
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
612
+ "Use the updated anchors shown below (`:` marks changed lines, `-` marks context) and retry the edit.",
534
613
  );
535
614
  lines.push("");
536
615
 
@@ -538,18 +617,18 @@ export class HashlineMismatchError extends Error {
538
617
  for (const lineNum of sorted) {
539
618
  // Gap separator between non-contiguous regions
540
619
  if (prevLine !== -1 && lineNum > prevLine + 1) {
541
- lines.push(" ...");
620
+ lines.push("...");
542
621
  }
543
622
  prevLine = lineNum;
544
623
 
545
624
  const text = fileLines[lineNum - 1];
546
625
  const hash = computeLineHash(lineNum, text);
547
- const prefix = `${lineNum}#${hash}`;
626
+ const prefix = `${lineNum}${hash}`;
548
627
 
549
628
  if (mismatchSet.has(lineNum)) {
550
- lines.push(`>>> ${prefix}:${text}`);
629
+ lines.push(`${prefix}:${text}`);
551
630
  } else {
552
- lines.push(` ${prefix}:${text}`);
631
+ lines.push(`${prefix}-${text}`);
553
632
  }
554
633
  }
555
634
  return lines.join("\n");
@@ -574,6 +653,39 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
574
653
  }
575
654
  }
576
655
 
656
+ /**
657
+ * Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
658
+ */
659
+ export const ANCHOR_REBASE_WINDOW = 2;
660
+
661
+ /**
662
+ * Look for the requested hash within ±`window` lines of `anchor.line`.
663
+ *
664
+ * Returns the new line number when exactly one nearby line matches the hash;
665
+ * otherwise `null` (genuine mismatch or ambiguous). The caller is expected to
666
+ * mutate `anchor.line` in place and surface a warning so the model knows the
667
+ * edit was retargeted.
668
+ *
669
+ * The exact-position match (anchor.line itself) is intentionally skipped: the
670
+ * caller has already determined the requested line's hash does not match.
671
+ */
672
+ export function tryRebaseAnchor(
673
+ anchor: { line: number; hash: string },
674
+ fileLines: string[],
675
+ window: number = ANCHOR_REBASE_WINDOW,
676
+ ): number | null {
677
+ const lo = Math.max(1, anchor.line - window);
678
+ const hi = Math.min(fileLines.length, anchor.line + window);
679
+ let found: number | null = null;
680
+ for (let line = lo; line <= hi; line++) {
681
+ if (line === anchor.line) continue;
682
+ if (computeLineHash(line, fileLines[line - 1]) !== anchor.hash) continue;
683
+ if (found !== null) return null; // ambiguous: more than one match in window
684
+ found = line;
685
+ }
686
+ return found;
687
+ }
688
+
577
689
  function isEscapedTabAutocorrectEnabled(): boolean {
578
690
  switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
579
691
  case "0":
@@ -729,7 +841,7 @@ function applyHashlineEditToLines(
729
841
  if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
730
842
  noopEdits.push({
731
843
  editIndex,
732
- loc: `${edit.pos.line}#${edit.pos.hash}`,
844
+ loc: `${edit.pos.line}${edit.pos.hash}`,
733
845
  current: origLines.join("\n"),
734
846
  });
735
847
  break;
@@ -740,6 +852,15 @@ function applyHashlineEditToLines(
740
852
  }
741
853
  case "replace_range": {
742
854
  const count = edit.end.line - edit.pos.line + 1;
855
+ const origRange = originalFileLines.slice(edit.pos.line - 1, edit.pos.line - 1 + count);
856
+ if (count === edit.lines.length && origRange.every((line, i) => line === edit.lines[i])) {
857
+ noopEdits.push({
858
+ editIndex,
859
+ loc: `${edit.pos.line}${edit.pos.hash}-${edit.end.line}${edit.end.hash}`,
860
+ current: origRange.join("\n"),
861
+ });
862
+ break;
863
+ }
743
864
  fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
744
865
  trackFirstChanged(edit.pos.line);
745
866
  break;
@@ -749,7 +870,7 @@ function applyHashlineEditToLines(
749
870
  if (inserted.length === 0) {
750
871
  noopEdits.push({
751
872
  editIndex,
752
- loc: `${edit.pos.line}#${edit.pos.hash}`,
873
+ loc: `${edit.pos.line}${edit.pos.hash}`,
753
874
  current: originalFileLines[edit.pos.line - 1],
754
875
  });
755
876
  break;
@@ -763,7 +884,7 @@ function applyHashlineEditToLines(
763
884
  if (inserted.length === 0) {
764
885
  noopEdits.push({
765
886
  editIndex,
766
- loc: `${edit.pos.line}#${edit.pos.hash}`,
887
+ loc: `${edit.pos.line}${edit.pos.hash}`,
767
888
  current: originalFileLines[edit.pos.line - 1],
768
889
  });
769
890
  break;
@@ -824,7 +945,7 @@ function buildHashlineEditResult(params: {
824
945
  };
825
946
  }
826
947
 
827
- function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
948
+ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
828
949
  const mismatches: HashMismatch[] = [];
829
950
  for (const edit of edits) {
830
951
  switch (edit.op) {
@@ -859,6 +980,15 @@ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): H
859
980
  if (actualHash === ref.hash) {
860
981
  return;
861
982
  }
983
+ const rebased = tryRebaseAnchor(ref, fileLines);
984
+ if (rebased !== null) {
985
+ const original = `${ref.line}${ref.hash}`;
986
+ ref.line = rebased;
987
+ warnings.push(
988
+ `Auto-rebased anchor ${original} → ${rebased}${ref.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
989
+ );
990
+ return;
991
+ }
862
992
  mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
863
993
  }
864
994
  }
@@ -897,7 +1027,7 @@ export function applyHashlineEdits(
897
1027
  const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
898
1028
  const warnings: string[] = [];
899
1029
 
900
- const mismatches = validateHashlineEditRefs(edits, fileLines);
1030
+ const mismatches = validateHashlineEditRefs(edits, fileLines, warnings);
901
1031
  if (mismatches.length > 0) {
902
1032
  throw new HashlineMismatchError(mismatches, fileLines);
903
1033
  }
@@ -997,14 +1127,12 @@ function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: numbe
997
1127
  counters.newLine = lineNumber;
998
1128
  }
999
1129
 
1000
- function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, width: number, content: string): string {
1001
- const padded = String(lineNumber).padStart(width, " ");
1002
- return `${kind}${padded}#${computeLineHash(lineNumber, content)}|${content}`;
1130
+ function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, content: string): string {
1131
+ return `${kind}${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1003
1132
  }
1004
1133
 
1005
- function formatCompactRemovedLine(lineNumber: number, width: number, content: string): string {
1006
- const padded = String(lineNumber).padStart(width, " ");
1007
- return `-${padded}${HASHLINE_PREVIEW_PLACEHOLDER}|${content}`;
1134
+ function formatCompactRemovedLine(lineNumber: number, content: string): string {
1135
+ return `-${lineNumber}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1008
1136
  }
1009
1137
 
1010
1138
  function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } {
@@ -1025,13 +1153,13 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
1025
1153
  syncNewLineCounters(counters, parsed.lineNumber);
1026
1154
  const newLine = counters.newLine;
1027
1155
  if (newLine === undefined) return { kind: "+", text: parsed.raw };
1028
- const text = formatCompactHashlineLine("+", newLine, parsed.lineWidth, parsed.content);
1156
+ const text = formatCompactHashlineLine("+", newLine, parsed.content);
1029
1157
  counters.newLine = newLine + 1;
1030
1158
  return { kind: "+", text };
1031
1159
  }
1032
1160
  case "-": {
1033
1161
  syncOldLineCounters(counters, parsed.lineNumber);
1034
- const text = formatCompactRemovedLine(parsed.lineNumber, parsed.lineWidth, parsed.content);
1162
+ const text = formatCompactRemovedLine(parsed.lineNumber, parsed.content);
1035
1163
  counters.oldLine = parsed.lineNumber + 1;
1036
1164
  return { kind: "-", text };
1037
1165
  }
@@ -1039,7 +1167,7 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
1039
1167
  syncOldLineCounters(counters, parsed.lineNumber);
1040
1168
  const newLine = counters.newLine;
1041
1169
  if (newLine === undefined) return { kind: " ", text: parsed.raw };
1042
- const text = formatCompactHashlineLine(" ", newLine, parsed.lineWidth, parsed.content);
1170
+ const text = formatCompactHashlineLine(" ", newLine, parsed.content);
1043
1171
  counters.oldLine = parsed.lineNumber + 1;
1044
1172
  counters.newLine = newLine + 1;
1045
1173
  return { kind: " ", text };
@@ -1145,7 +1273,7 @@ export function buildCompactHashlineDiffPreview(
1145
1273
  }
1146
1274
 
1147
1275
  export async function computeHashlineDiff(
1148
- input: { path: string; edits: HashlineEditInput[]; move?: string },
1276
+ input: { path: string; edits: HashlineEditInput[] },
1149
1277
  cwd: string,
1150
1278
  ): Promise<
1151
1279
  | {
@@ -1156,28 +1284,19 @@ export async function computeHashlineDiff(
1156
1284
  error: string;
1157
1285
  }
1158
1286
  > {
1159
- const { path, edits, move } = input;
1287
+ const { path, edits } = input;
1160
1288
 
1161
1289
  try {
1162
1290
  const absolutePath = resolveToCwd(path, cwd);
1163
- const movePath = move ? resolveToCwd(move, cwd) : undefined;
1164
- const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1165
1291
  const resolvedEdits = resolveHashlineEditsForDiff(edits);
1166
1292
  const file = Bun.file(absolutePath);
1167
1293
 
1168
- if (movePath === absolutePath) {
1169
- return { error: "move path is the same as source path" };
1170
- }
1171
- if (isMoveOnly) {
1172
- return { diff: "", firstChangedLine: undefined };
1173
- }
1174
-
1175
1294
  const rawContent = await readHashlineFileText(file, path);
1176
1295
 
1177
1296
  const { text: content } = stripBom(rawContent);
1178
1297
  const normalizedContent = normalizeToLF(content);
1179
1298
  const result = applyHashlineEdits(normalizedContent, resolvedEdits);
1180
- if (normalizedContent === result.lines && !move) {
1299
+ if (normalizedContent === result.lines) {
1181
1300
  return { error: `No changes would be made to ${path}. The edits produce identical content.` };
1182
1301
  }
1183
1302
 
@@ -1204,63 +1323,18 @@ export async function executeHashlineSingle(
1204
1323
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1205
1324
  const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1206
1325
 
1207
- // Extract file-level ops from edits
1208
- const deleteFile = edits.some(e => e.delete);
1209
- const move = edits.find(e => e.move)?.move;
1210
- // Filter to content edits only (those with loc)
1211
1326
  const contentEdits = edits.filter(e => e.loc != null);
1212
1327
 
1213
- enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
1328
+ enforcePlanModeWrite(session, path, { op: "update" });
1214
1329
 
1215
1330
  if (path.endsWith(".ipynb") && contentEdits.length > 0) {
1216
1331
  throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1217
1332
  }
1218
1333
 
1219
1334
  const absolutePath = resolvePlanPath(session, path);
1220
- const resolvedMove = move ? resolvePlanPath(session, move) : undefined;
1221
- if (resolvedMove === absolutePath) {
1222
- throw new Error("move path is the same as source path");
1223
- }
1224
1335
 
1225
1336
  const sourceFile = Bun.file(absolutePath);
1226
1337
  const sourceExists = await sourceFile.exists();
1227
- const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0;
1228
-
1229
- if (deleteFile) {
1230
- if (sourceExists) {
1231
- await sourceFile.unlink();
1232
- }
1233
- invalidateFsScanAfterDelete(absolutePath);
1234
- return {
1235
- content: [{ type: "text", text: `Deleted ${path}` }],
1236
- details: {
1237
- diff: "",
1238
- op: "delete",
1239
- meta: outputMeta().get(),
1240
- },
1241
- };
1242
- }
1243
-
1244
- if (isMoveOnly && resolvedMove) {
1245
- if (!sourceExists) {
1246
- throw new Error(`File not found: ${path}`);
1247
- }
1248
- const parentDir = nodePath.dirname(resolvedMove);
1249
- if (parentDir && parentDir !== ".") {
1250
- await fs.mkdir(parentDir, { recursive: true });
1251
- }
1252
- await fs.rename(absolutePath, resolvedMove);
1253
- invalidateFsScanAfterRename(absolutePath, resolvedMove);
1254
- return {
1255
- content: [{ type: "text", text: `Moved ${path} to ${move}` }],
1256
- details: {
1257
- diff: "",
1258
- op: "update",
1259
- move,
1260
- meta: outputMeta().get(),
1261
- },
1262
- };
1263
- }
1264
1338
 
1265
1339
  if (!sourceExists) {
1266
1340
  const lines: string[] = [];
@@ -1304,7 +1378,7 @@ export async function executeHashlineSingle(
1304
1378
  warnings: anchorResult.warnings,
1305
1379
  noopEdits: anchorResult.noopEdits,
1306
1380
  };
1307
- if (originalNormalized === result.text && !move) {
1381
+ if (originalNormalized === result.text) {
1308
1382
  let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
1309
1383
  if (result.noopEdits && result.noopEdits.length > 0) {
1310
1384
  const details = result.noopEdits
@@ -1320,28 +1394,32 @@ export async function executeHashlineSingle(
1320
1394
  diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`;
1321
1395
  }
1322
1396
  }
1397
+ if (result.noopEdits.some(e => e.loc.includes("-"))) {
1398
+ diagnostic +=
1399
+ "\nHint: a `range` loc replaces the entire span inclusive of both endpoints. " +
1400
+ "If your replacement repeats the existing content, narrow the range or change the replacement.";
1401
+ }
1323
1402
  }
1324
1403
  throw new Error(diagnostic);
1325
1404
  }
1326
1405
 
1327
- const writePath = resolvedMove ?? absolutePath;
1328
1406
  const finalContent = bom + restoreLineEndings(result.text, originalEnding);
1329
- const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst =>
1330
- dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined,
1407
+ const diagnostics = await writethrough(
1408
+ absolutePath,
1409
+ finalContent,
1410
+ signal,
1411
+ Bun.file(absolutePath),
1412
+ batchRequest,
1413
+ dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1331
1414
  );
1332
- if (resolvedMove && resolvedMove !== absolutePath) {
1333
- await sourceFile.unlink();
1334
- invalidateFsScanAfterRename(absolutePath, resolvedMove);
1335
- } else {
1336
- invalidateFsScanAfterWrite(absolutePath);
1337
- }
1415
+ invalidateFsScanAfterWrite(absolutePath);
1338
1416
 
1339
1417
  const diffResult = generateDiffString(originalNormalized, result.text);
1340
1418
  const meta = outputMeta()
1341
1419
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1342
1420
  .get();
1343
1421
 
1344
- const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`;
1422
+ const resultText = `Updated ${path}`;
1345
1423
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1346
1424
  const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
1347
1425
  const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
@@ -1359,7 +1437,6 @@ export async function executeHashlineSingle(
1359
1437
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1360
1438
  diagnostics,
1361
1439
  op: "update",
1362
- move,
1363
1440
  meta,
1364
1441
  },
1365
1442
  };