@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.
- package/CHANGELOG.md +143 -1
- package/package.json +19 -19
- package/src/autoresearch/prompt.md +1 -1
- package/src/cli/args.ts +10 -1
- package/src/cli/shell-cli.ts +15 -3
- package/src/commit/agentic/prompts/analyze-file.md +1 -1
- package/src/config/model-registry.ts +67 -15
- package/src/config/prompt-templates.ts +5 -5
- package/src/config/settings-schema.ts +63 -4
- package/src/cursor.ts +3 -8
- package/src/debug/system-info.ts +6 -2
- package/src/discovery/claude.ts +58 -36
- package/src/discovery/helpers.ts +3 -3
- package/src/discovery/opencode.ts +20 -2
- package/src/edit/diff.ts +50 -47
- package/src/edit/index.ts +87 -57
- package/src/edit/line-hash.ts +735 -19
- package/src/edit/modes/apply-patch.ts +0 -9
- package/src/edit/modes/atom.ts +658 -0
- package/src/edit/modes/chunk.ts +144 -78
- package/src/edit/modes/hashline.ts +223 -146
- package/src/edit/modes/patch.ts +5 -9
- package/src/edit/modes/replace.ts +6 -11
- package/src/edit/renderer.ts +112 -143
- package/src/edit/streaming.ts +385 -0
- package/src/exec/bash-executor.ts +58 -5
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +4 -12
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/pi-protocol.ts +0 -2
- package/src/lsp/client.ts +8 -1
- package/src/lsp/defaults.json +2 -1
- package/src/lsp/index.ts +1 -1
- package/src/mcp/render.ts +1 -8
- package/src/modes/acp/acp-agent.ts +76 -2
- package/src/modes/components/assistant-message.ts +5 -34
- package/src/modes/components/diff.ts +23 -14
- package/src/modes/components/footer.ts +21 -16
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/settings-defs.ts +6 -1
- package/src/modes/components/todo-reminder.ts +1 -8
- package/src/modes/components/tool-execution.ts +112 -105
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +0 -2
- package/src/modes/print-mode.ts +8 -0
- package/src/modes/theme/mermaid-cache.ts +13 -52
- package/src/modes/theme/theme.ts +2 -2
- package/src/prompts/agents/librarian.md +1 -1
- package/src/prompts/agents/reviewer.md +4 -4
- package/src/prompts/ci-green-request.md +1 -1
- package/src/prompts/review-request.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +3 -3
- package/src/prompts/system/subagent-yield-reminder.md +11 -0
- package/src/prompts/system/system-prompt.md +4 -1
- package/src/prompts/tools/ask.md +3 -2
- package/src/prompts/tools/ast-edit.md +15 -19
- package/src/prompts/tools/ast-grep.md +18 -24
- package/src/prompts/tools/atom.md +96 -0
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/chunk-edit.md +58 -179
- package/src/prompts/tools/debug.md +4 -5
- package/src/prompts/tools/exit-plan-mode.md +4 -5
- package/src/prompts/tools/find.md +4 -8
- package/src/prompts/tools/github.md +18 -0
- package/src/prompts/tools/grep.md +8 -8
- package/src/prompts/tools/hashline.md +22 -89
- package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
- package/src/prompts/tools/inspect-image.md +6 -6
- package/src/prompts/tools/lsp.md +6 -0
- package/src/prompts/tools/patch.md +12 -19
- package/src/prompts/tools/python.md +3 -2
- package/src/prompts/tools/read-chunk.md +46 -8
- package/src/prompts/tools/read.md +9 -6
- package/src/prompts/tools/ssh.md +8 -17
- package/src/prompts/tools/todo-write.md +54 -41
- package/src/sdk.ts +22 -14
- package/src/session/agent-session.ts +61 -22
- package/src/session/session-manager.ts +228 -57
- package/src/session/streaming-output.ts +11 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +44 -48
- package/src/task/render.ts +11 -13
- package/src/tools/ask.ts +7 -7
- package/src/tools/ast-edit.ts +45 -41
- package/src/tools/ast-grep.ts +77 -85
- package/src/tools/bash.ts +21 -9
- package/src/tools/browser.ts +32 -30
- package/src/tools/calculator.ts +4 -4
- package/src/tools/cancel-job.ts +1 -1
- package/src/tools/checkpoint.ts +2 -2
- package/src/tools/debug.ts +41 -37
- package/src/tools/exit-plan-mode.ts +1 -1
- package/src/tools/find.ts +4 -4
- package/src/tools/gh-renderer.ts +12 -4
- package/src/tools/gh.ts +514 -712
- package/src/tools/grep.ts +115 -130
- package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
- package/src/tools/index.ts +14 -32
- package/src/tools/inspect-image.ts +3 -3
- package/src/tools/json-tree.ts +114 -114
- package/src/tools/match-line-format.ts +9 -8
- package/src/tools/notebook.ts +8 -7
- package/src/tools/poll-tool.ts +2 -1
- package/src/tools/python.ts +9 -23
- package/src/tools/read.ts +32 -21
- package/src/tools/render-mermaid.ts +1 -1
- package/src/tools/render-utils.ts +18 -0
- package/src/tools/renderers.ts +2 -2
- package/src/tools/report-tool-issue.ts +3 -2
- package/src/tools/resolve.ts +1 -1
- package/src/tools/review.ts +12 -10
- package/src/tools/search-tool-bm25.ts +2 -4
- package/src/tools/sqlite-reader.ts +116 -3
- package/src/tools/ssh.ts +4 -4
- package/src/tools/todo-write.ts +172 -147
- package/src/tools/vim.ts +14 -15
- package/src/tools/write.ts +4 -4
- package/src/tools/{submit-result.ts → yield.ts} +11 -13
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/file-display-mode.ts +10 -5
- package/src/utils/git.ts +9 -5
- package/src/utils/shell-snapshot.ts +2 -3
- package/src/vim/render.ts +4 -4
- package/src/web/search/providers/codex.ts +129 -6
- package/src/prompts/system/subagent-submit-reminder.md +0 -11
- package/src/prompts/tools/gh-issue-view.md +0 -11
- package/src/prompts/tools/gh-pr-checkout.md +0 -12
- package/src/prompts/tools/gh-pr-diff.md +0 -12
- package/src/prompts/tools/gh-pr-push.md +0 -11
- package/src/prompts/tools/gh-pr-view.md +0 -11
- package/src/prompts/tools/gh-repo-view.md +0 -11
- package/src/prompts/tools/gh-run-watch.md +0 -12
- package/src/prompts/tools/gh-search-issues.md +0 -11
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The combined `LINE
|
|
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: `
|
|
12
|
-
* Reference format: `"
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return line.replace(
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
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)
|
|
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)}
|
|
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 `"
|
|
503
|
+
* Parse a line reference string like `"5th"` into structured form.
|
|
463
504
|
*
|
|
464
|
-
* @throws Error if the format is invalid (not `
|
|
505
|
+
* @throws Error if the format is invalid (not `NUMBERBIGRAM`)
|
|
465
506
|
*/
|
|
466
507
|
export function parseTag(ref: string): { line: number; hash: string } {
|
|
467
|
-
//
|
|
468
|
-
// 1. optional leading "
|
|
508
|
+
// Captures:
|
|
509
|
+
// 1. optional leading ">+-" markers and whitespace
|
|
469
510
|
// 2. line number (1+ digits)
|
|
470
|
-
// 3.
|
|
471
|
-
|
|
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
|
|
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
|
|
495
|
-
* showing the correct `LINE
|
|
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}
|
|
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
|
-
|
|
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}
|
|
626
|
+
const prefix = `${lineNum}${hash}`;
|
|
548
627
|
|
|
549
628
|
if (mismatchSet.has(lineNum)) {
|
|
550
|
-
lines.push(
|
|
629
|
+
lines.push(`${prefix}:${text}`);
|
|
551
630
|
} else {
|
|
552
|
-
lines.push(
|
|
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}
|
|
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}
|
|
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}
|
|
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,
|
|
1001
|
-
|
|
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,
|
|
1006
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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[]
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
1330
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
};
|