@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.2

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 (235) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/package.json +86 -24
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +116 -30
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +123 -178
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -8
  28. package/src/commit/agentic/index.ts +22 -26
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  31. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  32. package/src/commit/agentic/tools/git-overview.ts +6 -9
  33. package/src/commit/agentic/tools/index.ts +6 -8
  34. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  35. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  36. package/src/commit/agentic/tools/split-commit.ts +4 -4
  37. package/src/commit/agentic/validation.ts +1 -1
  38. package/src/commit/analysis/conventional.ts +4 -4
  39. package/src/commit/analysis/summary.ts +3 -3
  40. package/src/commit/changelog/generate.ts +4 -4
  41. package/src/commit/changelog/index.ts +5 -9
  42. package/src/commit/map-reduce/map-phase.ts +4 -4
  43. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  44. package/src/commit/pipeline.ts +13 -16
  45. package/src/config/keybindings.ts +7 -6
  46. package/src/config/prompt-templates.ts +44 -226
  47. package/src/config/resolve-config-value.ts +4 -2
  48. package/src/config/settings-schema.ts +98 -2
  49. package/src/config/settings.ts +25 -26
  50. package/src/dap/client.ts +674 -0
  51. package/src/dap/config.ts +150 -0
  52. package/src/dap/defaults.json +211 -0
  53. package/src/dap/index.ts +4 -0
  54. package/src/dap/session.ts +1255 -0
  55. package/src/dap/types.ts +600 -0
  56. package/src/debug/log-viewer.ts +3 -2
  57. package/src/discovery/builtin.ts +1 -2
  58. package/src/discovery/codex.ts +2 -2
  59. package/src/discovery/github.ts +2 -1
  60. package/src/discovery/helpers.ts +2 -2
  61. package/src/discovery/opencode.ts +2 -2
  62. package/src/edit/diff.ts +818 -0
  63. package/src/edit/index.ts +309 -0
  64. package/src/edit/line-hash.ts +67 -0
  65. package/src/edit/modes/chunk.ts +454 -0
  66. package/src/{patch → edit/modes}/hashline.ts +741 -361
  67. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  68. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  69. package/src/{patch → edit}/normalize.ts +97 -76
  70. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  71. package/src/exec/bash-executor.ts +4 -2
  72. package/src/exec/idle-timeout-watchdog.ts +126 -0
  73. package/src/exec/non-interactive-env.ts +5 -0
  74. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
  75. package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
  76. package/src/extensibility/custom-commands/loader.ts +1 -2
  77. package/src/extensibility/custom-tools/loader.ts +34 -11
  78. package/src/extensibility/custom-tools/types.ts +1 -1
  79. package/src/extensibility/extensions/loader.ts +9 -4
  80. package/src/extensibility/extensions/runner.ts +24 -1
  81. package/src/extensibility/extensions/types.ts +4 -2
  82. package/src/extensibility/hooks/loader.ts +5 -6
  83. package/src/extensibility/hooks/types.ts +2 -2
  84. package/src/extensibility/plugins/doctor.ts +2 -1
  85. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  86. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  87. package/src/extensibility/slash-commands.ts +3 -7
  88. package/src/index.ts +3 -1
  89. package/src/internal-urls/docs-index.generated.ts +11 -11
  90. package/src/ipy/executor.ts +58 -17
  91. package/src/ipy/gateway-coordinator.ts +6 -4
  92. package/src/ipy/kernel.ts +45 -22
  93. package/src/ipy/runtime.ts +2 -2
  94. package/src/lsp/client.ts +7 -4
  95. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  96. package/src/lsp/config.ts +2 -2
  97. package/src/lsp/defaults.json +688 -154
  98. package/src/lsp/index.ts +234 -45
  99. package/src/lsp/lspmux.ts +2 -2
  100. package/src/lsp/startup-events.ts +13 -0
  101. package/src/lsp/types.ts +12 -1
  102. package/src/lsp/utils.ts +8 -1
  103. package/src/main.ts +125 -47
  104. package/src/memories/index.ts +4 -5
  105. package/src/modes/acp/acp-agent.ts +563 -163
  106. package/src/modes/acp/acp-event-mapper.ts +9 -1
  107. package/src/modes/acp/acp-mode.ts +4 -2
  108. package/src/modes/components/agent-dashboard.ts +3 -4
  109. package/src/modes/components/diff.ts +6 -7
  110. package/src/modes/components/footer.ts +9 -29
  111. package/src/modes/components/hook-editor.ts +3 -3
  112. package/src/modes/components/hook-selector.ts +6 -1
  113. package/src/modes/components/read-tool-group.ts +6 -12
  114. package/src/modes/components/session-observer-overlay.ts +472 -0
  115. package/src/modes/components/settings-defs.ts +24 -0
  116. package/src/modes/components/status-line.ts +15 -61
  117. package/src/modes/components/tool-execution.ts +1 -1
  118. package/src/modes/components/welcome.ts +1 -1
  119. package/src/modes/controllers/btw-controller.ts +2 -2
  120. package/src/modes/controllers/command-controller.ts +4 -2
  121. package/src/modes/controllers/event-controller.ts +59 -2
  122. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  123. package/src/modes/controllers/input-controller.ts +15 -8
  124. package/src/modes/controllers/selector-controller.ts +26 -0
  125. package/src/modes/index.ts +20 -2
  126. package/src/modes/interactive-mode.ts +278 -69
  127. package/src/modes/rpc/host-tools.ts +186 -0
  128. package/src/modes/rpc/rpc-client.ts +178 -13
  129. package/src/modes/rpc/rpc-mode.ts +73 -3
  130. package/src/modes/rpc/rpc-types.ts +53 -1
  131. package/src/modes/session-observer-registry.ts +146 -0
  132. package/src/modes/shared.ts +0 -42
  133. package/src/modes/theme/theme.ts +80 -8
  134. package/src/modes/types.ts +4 -2
  135. package/src/modes/utils/keybinding-matchers.ts +9 -0
  136. package/src/prompts/system/custom-system-prompt.md +5 -0
  137. package/src/prompts/system/system-prompt.md +8 -1
  138. package/src/prompts/tools/chunk-edit.md +219 -0
  139. package/src/prompts/tools/debug.md +43 -0
  140. package/src/prompts/tools/grep.md +3 -0
  141. package/src/prompts/tools/lsp.md +5 -5
  142. package/src/prompts/tools/read-chunk.md +17 -0
  143. package/src/prompts/tools/read.md +19 -5
  144. package/src/sdk.ts +216 -165
  145. package/src/secrets/index.ts +1 -1
  146. package/src/secrets/obfuscator.ts +25 -17
  147. package/src/session/agent-session.ts +381 -286
  148. package/src/session/agent-storage.ts +12 -12
  149. package/src/session/compaction/branch-summarization.ts +3 -3
  150. package/src/session/compaction/compaction.ts +5 -6
  151. package/src/session/compaction/utils.ts +3 -3
  152. package/src/session/history-storage.ts +62 -19
  153. package/src/session/messages.ts +3 -3
  154. package/src/session/session-dump-format.ts +203 -0
  155. package/src/session/session-manager.ts +15 -5
  156. package/src/session/session-storage.ts +4 -2
  157. package/src/session/streaming-output.ts +1 -1
  158. package/src/session/tool-choice-queue.ts +213 -0
  159. package/src/slash-commands/builtin-registry.ts +56 -8
  160. package/src/ssh/connection-manager.ts +2 -2
  161. package/src/ssh/sshfs-mount.ts +5 -5
  162. package/src/stt/downloader.ts +4 -4
  163. package/src/stt/recorder.ts +4 -4
  164. package/src/stt/transcriber.ts +2 -2
  165. package/src/system-prompt.ts +25 -13
  166. package/src/task/agents.ts +5 -6
  167. package/src/task/commands.ts +2 -5
  168. package/src/task/executor.ts +32 -4
  169. package/src/task/index.ts +91 -82
  170. package/src/task/template.ts +2 -2
  171. package/src/task/types.ts +25 -0
  172. package/src/task/worktree.ts +131 -149
  173. package/src/tools/ask.ts +2 -3
  174. package/src/tools/ast-edit.ts +7 -7
  175. package/src/tools/ast-grep.ts +7 -7
  176. package/src/tools/auto-generated-guard.ts +36 -41
  177. package/src/tools/await-tool.ts +2 -2
  178. package/src/tools/bash.ts +5 -23
  179. package/src/tools/browser.ts +4 -5
  180. package/src/tools/calculator.ts +2 -3
  181. package/src/tools/cancel-job.ts +2 -2
  182. package/src/tools/checkpoint.ts +3 -3
  183. package/src/tools/debug.ts +1007 -0
  184. package/src/tools/exit-plan-mode.ts +3 -3
  185. package/src/tools/fetch.ts +67 -3
  186. package/src/tools/find.ts +4 -5
  187. package/src/tools/fs-cache-invalidation.ts +5 -0
  188. package/src/tools/gemini-image.ts +13 -5
  189. package/src/tools/gh.ts +130 -308
  190. package/src/tools/grep.ts +57 -9
  191. package/src/tools/index.ts +44 -22
  192. package/src/tools/inspect-image.ts +4 -4
  193. package/src/tools/output-meta.ts +1 -1
  194. package/src/tools/python.ts +19 -6
  195. package/src/tools/read.ts +211 -146
  196. package/src/tools/render-mermaid.ts +2 -3
  197. package/src/tools/render-utils.ts +20 -6
  198. package/src/tools/renderers.ts +3 -1
  199. package/src/tools/report-tool-issue.ts +80 -0
  200. package/src/tools/resolve.ts +70 -39
  201. package/src/tools/search-tool-bm25.ts +2 -2
  202. package/src/tools/ssh.ts +2 -2
  203. package/src/tools/todo-write.ts +2 -2
  204. package/src/tools/tool-timeouts.ts +1 -0
  205. package/src/tools/write.ts +5 -6
  206. package/src/tui/tree-list.ts +3 -1
  207. package/src/utils/clipboard.ts +80 -0
  208. package/src/utils/commit-message-generator.ts +2 -3
  209. package/src/utils/edit-mode.ts +49 -0
  210. package/src/utils/external-editor.ts +11 -5
  211. package/src/utils/file-display-mode.ts +6 -5
  212. package/src/utils/file-mentions.ts +8 -7
  213. package/src/utils/git.ts +1400 -0
  214. package/src/utils/image-loading.ts +98 -0
  215. package/src/utils/title-generator.ts +2 -3
  216. package/src/utils/tools-manager.ts +6 -6
  217. package/src/web/scrapers/choosealicense.ts +1 -1
  218. package/src/web/search/index.ts +3 -3
  219. package/src/web/search/render.ts +6 -4
  220. package/src/autoresearch/command-initialize.md +0 -34
  221. package/src/commit/git/errors.ts +0 -9
  222. package/src/commit/git/index.ts +0 -210
  223. package/src/commit/git/operations.ts +0 -54
  224. package/src/patch/diff.ts +0 -433
  225. package/src/patch/index.ts +0 -888
  226. package/src/patch/parser.ts +0 -532
  227. package/src/patch/types.ts +0 -292
  228. package/src/prompts/agents/oracle.md +0 -77
  229. package/src/tools/gh-cli.ts +0 -125
  230. package/src/tools/pending-action.ts +0 -49
  231. package/src/utils/child-process.ts +0 -88
  232. package/src/utils/frontmatter.ts +0 -117
  233. package/src/utils/image-input.ts +0 -274
  234. package/src/utils/mime.ts +0 -53
  235. package/src/utils/prompt-format.ts +0 -170
@@ -0,0 +1,818 @@
1
+ /**
2
+ * Diff generation and replace-mode utilities for the edit tool.
3
+ *
4
+ * Provides diff string generation and the replace-mode edit logic
5
+ * used when not in patch mode.
6
+ */
7
+ import { isEnoent } from "@oh-my-pi/pi-utils";
8
+ import * as Diff from "diff";
9
+ import { resolveToCwd } from "../tools/path-utils";
10
+ import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
11
+ import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
12
+
13
+ export interface DiffResult {
14
+ diff: string;
15
+ firstChangedLine: number | undefined;
16
+ }
17
+
18
+ export interface DiffError {
19
+ error: string;
20
+ }
21
+
22
+ export interface DiffHunk {
23
+ changeContext?: string;
24
+ oldStartLine?: number;
25
+ newStartLine?: number;
26
+ hasContextLines: boolean;
27
+ oldLines: string[];
28
+ newLines: string[];
29
+ isEndOfFile: boolean;
30
+ }
31
+
32
+ export class ParseError extends Error {
33
+ constructor(
34
+ message: string,
35
+ readonly lineNumber?: number,
36
+ ) {
37
+ super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message);
38
+ this.name = "ParseError";
39
+ }
40
+ }
41
+
42
+ export class ApplyPatchError extends Error {
43
+ constructor(message: string) {
44
+ super(message);
45
+ this.name = "ApplyPatchError";
46
+ }
47
+ }
48
+
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+ // Diff String Generation
51
+ // ═══════════════════════════════════════════════════════════════════════════
52
+
53
+ function countContentLines(content: string): number {
54
+ const lines = content.split("\n");
55
+ if (lines.length > 1 && lines[lines.length - 1] === "") {
56
+ lines.pop();
57
+ }
58
+ return Math.max(1, lines.length);
59
+ }
60
+
61
+ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string {
62
+ const padded = String(lineNum).padStart(width, " ");
63
+ return `${prefix}${padded}|${content}`;
64
+ }
65
+
66
+ /**
67
+ * Generate a unified diff string with line numbers and context.
68
+ * Returns both the diff string and the first changed line number (in the new file).
69
+ */
70
+ export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
71
+ const parts = Diff.diffLines(oldContent, newContent);
72
+ const output: string[] = [];
73
+
74
+ const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
75
+ const lineNumWidth = String(maxLineNum).length;
76
+
77
+ let oldLineNum = 1;
78
+ let newLineNum = 1;
79
+ let lastWasChange = false;
80
+ let firstChangedLine: number | undefined;
81
+
82
+ for (let i = 0; i < parts.length; i++) {
83
+ const part = parts[i];
84
+ const raw = part.value.split("\n");
85
+ if (raw[raw.length - 1] === "") {
86
+ raw.pop();
87
+ }
88
+
89
+ if (part.added || part.removed) {
90
+ // Capture the first changed line (in the new file)
91
+ if (firstChangedLine === undefined) {
92
+ firstChangedLine = newLineNum;
93
+ }
94
+
95
+ // Show the change
96
+ for (const line of raw) {
97
+ if (part.added) {
98
+ output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
99
+ newLineNum++;
100
+ } else {
101
+ output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line));
102
+ oldLineNum++;
103
+ }
104
+ }
105
+ lastWasChange = true;
106
+ } else {
107
+ // Context lines - only show a few before/after changes
108
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
109
+
110
+ if (lastWasChange || nextPartIsChange) {
111
+ let linesToShow = raw;
112
+ let skipStart = 0;
113
+ let skipEnd = 0;
114
+
115
+ if (!lastWasChange) {
116
+ // Show only last N lines as leading context
117
+ skipStart = Math.max(0, raw.length - contextLines);
118
+ linesToShow = raw.slice(skipStart);
119
+ }
120
+
121
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
122
+ // Show only first N lines as trailing context
123
+ skipEnd = linesToShow.length - contextLines;
124
+ linesToShow = linesToShow.slice(0, contextLines);
125
+ }
126
+
127
+ // Add ellipsis if we skipped lines at start
128
+ if (skipStart > 0) {
129
+ output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
130
+ oldLineNum += skipStart;
131
+ newLineNum += skipStart;
132
+ }
133
+
134
+ for (const line of linesToShow) {
135
+ output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line));
136
+ oldLineNum++;
137
+ newLineNum++;
138
+ }
139
+
140
+ // Add ellipsis if we skipped lines at end
141
+ if (skipEnd > 0) {
142
+ output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
143
+ oldLineNum += skipEnd;
144
+ newLineNum += skipEnd;
145
+ }
146
+ } else {
147
+ // Skip these context lines entirely
148
+ oldLineNum += raw.length;
149
+ newLineNum += raw.length;
150
+ }
151
+
152
+ lastWasChange = false;
153
+ }
154
+ }
155
+
156
+ return { diff: output.join("\n"), firstChangedLine };
157
+ }
158
+
159
+ // ═══════════════════════════════════════════════════════════════════════════
160
+ // Replace Mode Logic
161
+ // ═══════════════════════════════════════════════════════════════════════════
162
+
163
+ export interface ReplaceOptions {
164
+ /** Allow fuzzy matching */
165
+ fuzzy: boolean;
166
+ /** Replace all occurrences */
167
+ all: boolean;
168
+ /** Similarity threshold for fuzzy matching */
169
+ threshold?: number;
170
+ }
171
+
172
+ export interface ReplaceResult {
173
+ /** The new content after replacements */
174
+ content: string;
175
+ /** Number of replacements made */
176
+ count: number;
177
+ }
178
+
179
+ /**
180
+ * Generate a unified diff string without file headers.
181
+ * Returns both the diff string and the first changed line number (in the new file).
182
+ */
183
+ export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult {
184
+ const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
185
+ const output: string[] = [];
186
+ let firstChangedLine: number | undefined;
187
+ const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
188
+ const lineNumWidth = String(maxLineNum).length;
189
+ for (const hunk of patch.hunks) {
190
+ output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
191
+ let oldLine = hunk.oldStart;
192
+ let newLine = hunk.newStart;
193
+ for (const line of hunk.lines) {
194
+ if (line.startsWith("-")) {
195
+ if (firstChangedLine === undefined) firstChangedLine = newLine;
196
+ output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1)));
197
+ oldLine++;
198
+ continue;
199
+ }
200
+ if (line.startsWith("+")) {
201
+ if (firstChangedLine === undefined) firstChangedLine = newLine;
202
+ output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1)));
203
+ newLine++;
204
+ continue;
205
+ }
206
+ if (line.startsWith(" ")) {
207
+ output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1)));
208
+ oldLine++;
209
+ newLine++;
210
+ continue;
211
+ }
212
+ output.push(line);
213
+ }
214
+ }
215
+
216
+ return { diff: output.join("\n"), firstChangedLine };
217
+ }
218
+
219
+ const EOF_MARKER = "*** End of File";
220
+ const CHANGE_CONTEXT_MARKER = "@@ ";
221
+ const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
222
+ const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/;
223
+ const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i;
224
+ const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i;
225
+ const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "];
226
+ const DIFF_METADATA_PREFIXES = [
227
+ "*** Update File:",
228
+ "*** Add File:",
229
+ "*** Delete File:",
230
+ "diff --git ",
231
+ "index ",
232
+ "--- ",
233
+ "+++ ",
234
+ "new file mode ",
235
+ "deleted file mode ",
236
+ "rename from ",
237
+ "rename to ",
238
+ "similarity index ",
239
+ "dissimilarity index ",
240
+ "old mode ",
241
+ "new mode ",
242
+ ];
243
+ const PATCH_WRAPPER_PREFIXES = ["*** Begin Patch", "*** End Patch"];
244
+ const MAX_OCCURRENCE_PREVIEWS = 5;
245
+
246
+ function isDiffContentLine(line: string): boolean {
247
+ const firstChar = line[0];
248
+ if (firstChar === " ") return true;
249
+ if (firstChar === "+") {
250
+ return !line.startsWith("+++ ");
251
+ }
252
+ if (firstChar === "-") {
253
+ return !line.startsWith("--- ");
254
+ }
255
+ return false;
256
+ }
257
+
258
+ function matchesTrimmedPrefix(line: string, prefixes: string[]): boolean {
259
+ return prefixes.some(prefix => line.startsWith(prefix));
260
+ }
261
+
262
+ function isPatchWrapperLine(line: string): boolean {
263
+ return line === "***" || matchesTrimmedPrefix(line, PATCH_WRAPPER_PREFIXES);
264
+ }
265
+
266
+ function formatOccurrenceMatchError(
267
+ occurrences: number,
268
+ occurrencePreviews: string[] | undefined,
269
+ path?: string,
270
+ ): string {
271
+ const previews = occurrencePreviews?.join("\n\n") ?? "";
272
+ const moreMsg =
273
+ occurrences > MAX_OCCURRENCE_PREVIEWS ? ` (showing first ${MAX_OCCURRENCE_PREVIEWS} of ${occurrences})` : "";
274
+ const pathSuffix = path ? ` in ${path}` : "";
275
+ return `Found ${occurrences} occurrences${pathSuffix}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`;
276
+ }
277
+
278
+ async function readFileTextForDiff(path: string, absolutePath: string): Promise<string> {
279
+ try {
280
+ return await Bun.file(absolutePath).text();
281
+ } catch (error) {
282
+ if (isEnoent(error)) {
283
+ throw new Error(`File not found: ${path}`);
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ export function normalizeDiff(diff: string): string {
290
+ let lines = diff.split("\n");
291
+
292
+ while (lines.length > 0) {
293
+ const lastLine = lines[lines.length - 1];
294
+ if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) {
295
+ lines = lines.slice(0, -1);
296
+ } else {
297
+ break;
298
+ }
299
+ }
300
+
301
+ if (lines[0] && isPatchWrapperLine(lines[0].trim())) {
302
+ lines = lines.slice(1);
303
+ }
304
+ if (lines.length > 0 && isPatchWrapperLine(lines[lines.length - 1]?.trim() ?? "")) {
305
+ lines = lines.slice(0, -1);
306
+ }
307
+
308
+ lines = lines.filter(line => {
309
+ if (isDiffContentLine(line)) {
310
+ return true;
311
+ }
312
+
313
+ return !matchesTrimmedPrefix(line.trim(), DIFF_METADATA_PREFIXES);
314
+ });
315
+
316
+ return lines.join("\n");
317
+ }
318
+
319
+ export function normalizeCreateContent(content: string): string {
320
+ const lines = content.split("\n");
321
+ const nonEmptyLines = lines.filter(line => line.length > 0);
322
+
323
+ if (nonEmptyLines.length > 0 && nonEmptyLines.every(line => line.startsWith("+ ") || line.startsWith("+"))) {
324
+ return lines
325
+ .map(line => {
326
+ if (line.startsWith("+ ")) return line.slice(2);
327
+ if (line.startsWith("+")) return line.slice(1);
328
+ return line;
329
+ })
330
+ .join("\n");
331
+ }
332
+
333
+ return content;
334
+ }
335
+
336
+ interface UnifiedHunkHeader {
337
+ oldStartLine: number;
338
+ oldLineCount: number;
339
+ newStartLine: number;
340
+ newLineCount: number;
341
+ changeContext?: string;
342
+ }
343
+
344
+ function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined {
345
+ const match = line.match(UNIFIED_HUNK_HEADER_REGEX);
346
+ if (!match) return undefined;
347
+
348
+ const oldStartLine = Number(match[1]);
349
+ const oldLineCount = match[2] ? Number(match[2]) : 1;
350
+ const newStartLine = Number(match[3]);
351
+ const newLineCount = match[4] ? Number(match[4]) : 1;
352
+ const changeContext = match[5]?.trim();
353
+
354
+ return {
355
+ oldStartLine,
356
+ oldLineCount,
357
+ newStartLine,
358
+ newLineCount,
359
+ changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined,
360
+ };
361
+ }
362
+
363
+ function isUnifiedDiffMetadataLine(line: string): boolean {
364
+ return matchesTrimmedPrefix(
365
+ line,
366
+ DIFF_METADATA_PREFIXES.filter(prefix => !prefix.startsWith("*** ")),
367
+ );
368
+ }
369
+
370
+ interface ParseHunkResult {
371
+ hunk: DiffHunk;
372
+ linesConsumed: number;
373
+ }
374
+
375
+ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult {
376
+ if (lines.length === 0) {
377
+ throw new ParseError("Diff does not contain any lines", lineNumber);
378
+ }
379
+
380
+ const changeContexts: string[] = [];
381
+ let oldStartLine: number | undefined;
382
+ let newStartLine: number | undefined;
383
+ let startIndex: number;
384
+
385
+ const headerLine = lines[0];
386
+ const headerTrimmed = headerLine.trimEnd();
387
+ const isHeaderLine = headerLine.startsWith("@@");
388
+ const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
389
+ const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
390
+
391
+ if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
392
+ startIndex = 1;
393
+ } else if (unifiedHeader) {
394
+ if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) {
395
+ throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber);
396
+ }
397
+ if (unifiedHeader.changeContext) {
398
+ changeContexts.push(unifiedHeader.changeContext);
399
+ }
400
+ oldStartLine = unifiedHeader.oldStartLine;
401
+ newStartLine = unifiedHeader.newStartLine;
402
+ startIndex = 1;
403
+ } else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
404
+ const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length);
405
+ const trimmedContextValue = contextValue.trim();
406
+ const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, "");
407
+
408
+ const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX);
409
+ if (lineHintMatch) {
410
+ oldStartLine = Number(lineHintMatch[1]);
411
+ newStartLine = oldStartLine;
412
+ if (oldStartLine < 1) {
413
+ throw new ParseError("Line hint must be >= 1", lineNumber);
414
+ }
415
+ } else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) {
416
+ oldStartLine = 1;
417
+ newStartLine = 1;
418
+ } else if (trimmedContextValue.length > 0) {
419
+ changeContexts.push(contextValue);
420
+ }
421
+ startIndex = 1;
422
+ } else if (isHeaderLine) {
423
+ const contextValue = headerTrimmed.slice(2).trim();
424
+ if (contextValue.length > 0) {
425
+ changeContexts.push(contextValue);
426
+ }
427
+ startIndex = 1;
428
+ } else {
429
+ if (!allowMissingContext) {
430
+ throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber);
431
+ }
432
+ startIndex = 0;
433
+ }
434
+
435
+ if (oldStartLine !== undefined && oldStartLine < 1) {
436
+ throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber);
437
+ }
438
+ if (newStartLine !== undefined && newStartLine < 1) {
439
+ throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
440
+ }
441
+
442
+ while (startIndex < lines.length) {
443
+ const nextLine = lines[startIndex];
444
+ if (!nextLine.startsWith("@@")) {
445
+ break;
446
+ }
447
+ const trimmed = nextLine.trimEnd();
448
+ if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
449
+ const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
450
+ if (nestedContext.trim().length > 0) {
451
+ changeContexts.push(nestedContext);
452
+ }
453
+ startIndex++;
454
+ } else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) {
455
+ startIndex++;
456
+ } else {
457
+ break;
458
+ }
459
+ }
460
+
461
+ if (startIndex >= lines.length) {
462
+ throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
463
+ }
464
+
465
+ const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined;
466
+
467
+ const hunk: DiffHunk = {
468
+ changeContext,
469
+ oldStartLine,
470
+ newStartLine,
471
+ hasContextLines: false,
472
+ oldLines: [],
473
+ newLines: [],
474
+ isEndOfFile: false,
475
+ };
476
+
477
+ let parsedLines = 0;
478
+
479
+ for (let i = startIndex; i < lines.length; i++) {
480
+ const line = lines[i];
481
+ const trimmed = line.trim();
482
+ const nextLine = lines[i + 1];
483
+
484
+ if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) {
485
+ break;
486
+ }
487
+
488
+ if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
489
+ if (parsedLines === 0) {
490
+ throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
491
+ }
492
+ hunk.isEndOfFile = true;
493
+ parsedLines++;
494
+ break;
495
+ }
496
+
497
+ if (trimmed === "..." || trimmed === "…") {
498
+ hunk.hasContextLines = true;
499
+ parsedLines++;
500
+ continue;
501
+ }
502
+
503
+ const firstChar = line[0];
504
+
505
+ if (firstChar === undefined || firstChar === "") {
506
+ hunk.hasContextLines = true;
507
+ hunk.oldLines.push("");
508
+ hunk.newLines.push("");
509
+ } else if (firstChar === " ") {
510
+ hunk.hasContextLines = true;
511
+ hunk.oldLines.push(line.slice(1));
512
+ hunk.newLines.push(line.slice(1));
513
+ } else if (firstChar === "+") {
514
+ hunk.newLines.push(line.slice(1));
515
+ } else if (firstChar === "-") {
516
+ hunk.oldLines.push(line.slice(1));
517
+ } else if (!line.startsWith("@@")) {
518
+ hunk.hasContextLines = true;
519
+ hunk.oldLines.push(line);
520
+ hunk.newLines.push(line);
521
+ } else {
522
+ if (parsedLines === 0) {
523
+ throw new ParseError(
524
+ `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`,
525
+ lineNumber + 1,
526
+ );
527
+ }
528
+ break;
529
+ }
530
+ parsedLines++;
531
+ }
532
+
533
+ if (parsedLines === 0) {
534
+ throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex);
535
+ }
536
+
537
+ stripLineNumberPrefixes(hunk);
538
+ return { hunk, linesConsumed: parsedLines + startIndex };
539
+ }
540
+
541
+ function stripLineNumberPrefixes(hunk: DiffHunk): void {
542
+ const allLines = [...hunk.oldLines, ...hunk.newLines].filter(line => line.trim().length > 0);
543
+ if (allLines.length < 2) return;
544
+
545
+ const numberMatches = allLines
546
+ .map(line => line.match(/^\s*(\d{1,6})\s+(.+)$/u))
547
+ .filter((match): match is RegExpMatchArray => match !== null);
548
+
549
+ if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) {
550
+ return;
551
+ }
552
+
553
+ const numbers = numberMatches.map(match => Number(match[1]));
554
+ let sequential = 0;
555
+ for (let i = 1; i < numbers.length; i++) {
556
+ if (numbers[i] === numbers[i - 1] + 1) {
557
+ sequential++;
558
+ }
559
+ }
560
+
561
+ if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) {
562
+ return;
563
+ }
564
+
565
+ const strip = (line: string): string => {
566
+ const match = line.match(/^\s*\d{1,6}\s+(.+)$/u);
567
+ return match ? match[1] : line;
568
+ };
569
+
570
+ hunk.oldLines = hunk.oldLines.map(strip);
571
+ hunk.newLines = hunk.newLines.map(strip);
572
+ }
573
+
574
+ function countMultiFileMarkers(diff: string): number {
575
+ const counts = new Map<string, number>();
576
+ const paths = new Set<string>();
577
+ const lines = diff.split("\n");
578
+ for (const line of lines) {
579
+ if (isDiffContentLine(line)) {
580
+ continue;
581
+ }
582
+ const trimmed = line.trim();
583
+ for (const marker of MULTI_FILE_MARKERS) {
584
+ if (trimmed.startsWith(marker)) {
585
+ const filePath = extractMarkerPath(trimmed);
586
+ if (filePath) {
587
+ paths.add(filePath);
588
+ }
589
+ counts.set(marker, (counts.get(marker) ?? 0) + 1);
590
+ break;
591
+ }
592
+ }
593
+ }
594
+ if (paths.size > 0) {
595
+ return paths.size;
596
+ }
597
+ let maxCount = 0;
598
+ for (const count of counts.values()) {
599
+ if (count > maxCount) {
600
+ maxCount = count;
601
+ }
602
+ }
603
+ return maxCount;
604
+ }
605
+
606
+ function extractMarkerPath(line: string): string | undefined {
607
+ if (line.startsWith("diff --git ")) {
608
+ const parts = line.split(/\s+/);
609
+ const candidate = parts[3] ?? parts[2];
610
+ if (!candidate) return undefined;
611
+ return candidate.replace(/^(a|b)\//, "");
612
+ }
613
+ if (line.startsWith("*** Update File:")) {
614
+ return line.slice("*** Update File:".length).trim();
615
+ }
616
+ if (line.startsWith("*** Add File:")) {
617
+ return line.slice("*** Add File:".length).trim();
618
+ }
619
+ if (line.startsWith("*** Delete File:")) {
620
+ return line.slice("*** Delete File:".length).trim();
621
+ }
622
+ return undefined;
623
+ }
624
+
625
+ export function parseDiffHunks(diff: string): DiffHunk[] {
626
+ const multiFileCount = countMultiFileMarkers(diff);
627
+ if (multiFileCount > 1) {
628
+ throw new ApplyPatchError(
629
+ `Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`,
630
+ );
631
+ }
632
+
633
+ const normalizedDiff = normalizeDiff(diff);
634
+ const lines = normalizedDiff.split("\n");
635
+ const hunks: DiffHunk[] = [];
636
+ let i = 0;
637
+
638
+ while (i < lines.length) {
639
+ const line = lines[i];
640
+ const trimmed = line.trim();
641
+
642
+ if (trimmed === "") {
643
+ i++;
644
+ continue;
645
+ }
646
+
647
+ const firstChar = line[0];
648
+ const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-";
649
+ if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) {
650
+ i++;
651
+ continue;
652
+ }
653
+
654
+ if (trimmed.startsWith("@@") && lines.slice(i + 1).every(next => next.trim() === "")) {
655
+ break;
656
+ }
657
+
658
+ const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true);
659
+ hunks.push(hunk);
660
+ i += linesConsumed;
661
+ }
662
+
663
+ return hunks;
664
+ }
665
+
666
+ /**
667
+ * Find and replace text in content using fuzzy matching.
668
+ */
669
+ export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult {
670
+ if (oldText.length === 0) {
671
+ throw new Error("oldText must not be empty.");
672
+ }
673
+ const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
674
+ let normalizedContent = normalizeToLF(content);
675
+ const normalizedOldText = normalizeToLF(oldText);
676
+ const normalizedNewText = normalizeToLF(newText);
677
+ let count = 0;
678
+
679
+ if (options.all) {
680
+ // Check for exact matches first
681
+ const exactCount = normalizedContent.split(normalizedOldText).length - 1;
682
+ if (exactCount > 0) {
683
+ return {
684
+ content: normalizedContent.split(normalizedOldText).join(normalizedNewText),
685
+ count: exactCount,
686
+ };
687
+ }
688
+
689
+ // No exact matches - try fuzzy matching iteratively
690
+ while (true) {
691
+ const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
692
+ allowFuzzy: options.fuzzy,
693
+ threshold,
694
+ });
695
+
696
+ const shouldUseClosest =
697
+ options.fuzzy &&
698
+ matchOutcome.closest &&
699
+ matchOutcome.closest.confidence >= threshold &&
700
+ (matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1);
701
+ const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined);
702
+ if (!match) {
703
+ break;
704
+ }
705
+
706
+ const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
707
+ if (adjustedNewText === match.actualText) {
708
+ break;
709
+ }
710
+ normalizedContent =
711
+ normalizedContent.substring(0, match.startIndex) +
712
+ adjustedNewText +
713
+ normalizedContent.substring(match.startIndex + match.actualText.length);
714
+ count++;
715
+ }
716
+
717
+ return { content: normalizedContent, count };
718
+ }
719
+
720
+ // Single replacement mode
721
+ const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
722
+ allowFuzzy: options.fuzzy,
723
+ threshold,
724
+ });
725
+
726
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
727
+ throw new Error(formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews));
728
+ }
729
+
730
+ if (!matchOutcome.match) {
731
+ return { content: normalizedContent, count: 0 };
732
+ }
733
+
734
+ const match = matchOutcome.match;
735
+ const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
736
+ normalizedContent =
737
+ normalizedContent.substring(0, match.startIndex) +
738
+ adjustedNewText +
739
+ normalizedContent.substring(match.startIndex + match.actualText.length);
740
+
741
+ return { content: normalizedContent, count: 1 };
742
+ }
743
+
744
+ // ═══════════════════════════════════════════════════════════════════════════
745
+ // Preview/Diff Computation
746
+ // ═══════════════════════════════════════════════════════════════════════════
747
+
748
+ /**
749
+ * Compute the diff for an edit operation without applying it.
750
+ * Used for preview rendering in the TUI before the tool executes.
751
+ */
752
+ export async function computeEditDiff(
753
+ path: string,
754
+ oldText: string,
755
+ newText: string,
756
+ cwd: string,
757
+ fuzzy = true,
758
+ all = false,
759
+ threshold?: number,
760
+ ): Promise<DiffResult | DiffError> {
761
+ if (oldText.length === 0) {
762
+ return { error: "oldText must not be empty." };
763
+ }
764
+ const absolutePath = resolveToCwd(path, cwd);
765
+
766
+ try {
767
+ let rawContent: string;
768
+ try {
769
+ rawContent = await readFileTextForDiff(path, absolutePath);
770
+ } catch (error) {
771
+ const message = error instanceof Error ? error.message : String(error);
772
+ return { error: message || `Unable to read ${path}` };
773
+ }
774
+
775
+ const { text: content } = stripBom(rawContent);
776
+ const normalizedContent = normalizeToLF(content);
777
+ const normalizedOldText = normalizeToLF(oldText);
778
+ const normalizedNewText = normalizeToLF(newText);
779
+
780
+ const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
781
+ fuzzy,
782
+ all,
783
+ threshold,
784
+ });
785
+
786
+ if (result.count === 0) {
787
+ // Get closest match for error message
788
+ const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
789
+ allowFuzzy: fuzzy,
790
+ threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
791
+ });
792
+
793
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
794
+ return {
795
+ error: formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews, path),
796
+ };
797
+ }
798
+
799
+ return {
800
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
801
+ allowFuzzy: fuzzy,
802
+ threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
803
+ fuzzyMatches: matchOutcome.fuzzyMatches,
804
+ }),
805
+ };
806
+ }
807
+
808
+ if (normalizedContent === result.content) {
809
+ return {
810
+ error: `No changes would be made to ${path}. The replacement produces identical content.`,
811
+ };
812
+ }
813
+
814
+ return generateDiffString(normalizedContent, result.content);
815
+ } catch (err) {
816
+ return { error: err instanceof Error ? err.message : String(err) };
817
+ }
818
+ }