@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
package/src/cursor.ts CHANGED
@@ -177,16 +177,11 @@ export class CursorExecHandlers implements ICursorExecHandlers {
177
177
 
178
178
  async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
179
179
  const toolCallId = decodeToolCallId(args.toolCallId);
180
+ const grepPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
180
181
  const toolResultMessage = await executeTool(this.options, "grep", toolCallId, {
181
182
  pattern: args.pattern,
182
- path: args.path || undefined,
183
- glob: args.glob || undefined,
184
- mode: args.outputMode || undefined,
185
- context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
186
- ignore_case: args.caseInsensitive || undefined,
187
- type: args.type || undefined,
188
- limit: args.headLimit ?? undefined,
189
- multiline: args.multiline || undefined,
183
+ path: grepPath,
184
+ i: args.caseInsensitive || undefined,
190
185
  });
191
186
  return toolResultMessage;
192
187
  }
@@ -40,8 +40,12 @@ function macosMarketingName(release: string): string | undefined {
40
40
 
41
41
  /** Collect system information */
42
42
  export async function collectSystemInfo(): Promise<SystemInfo> {
43
- const cpus = os.cpus();
44
- const cpuModel = cpus[0]?.model ?? "Unknown CPU";
43
+ let cpuModel = "Unknown CPU";
44
+ try {
45
+ cpuModel = os.cpus()[0]?.model ?? cpuModel;
46
+ } catch {
47
+ // Keep debug report collection best-effort when CPU probing fails.
48
+ }
45
49
 
46
50
  // Try to get shell from environment
47
51
  const shell = Bun.env.SHELL ?? Bun.env.ComSpec ?? "unknown";
@@ -18,6 +18,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
18
18
  import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
19
19
  import { type CustomTool, toolCapability } from "../capability/tool";
20
20
  import type { LoadContext, LoadResult } from "../capability/types";
21
+ import { settings } from "../config/settings";
21
22
  import {
22
23
  calculateDepth,
23
24
  createSourceMeta,
@@ -252,48 +253,69 @@ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<Extens
252
253
  // Slash Commands
253
254
  // =============================================================================
254
255
 
256
+ /**
257
+ * Read the Claude command-loading toggles from settings.
258
+ * Falls back to true (current behavior) when settings are not initialized,
259
+ * e.g. inside discovery unit tests that run without Settings.init().
260
+ */
261
+ function readClaudeCommandToggles(): { enableUser: boolean; enableProject: boolean } {
262
+ try {
263
+ return {
264
+ enableUser: settings.get("commands.enableClaudeUser") ?? true,
265
+ enableProject: settings.get("commands.enableClaudeProject") ?? true,
266
+ };
267
+ } catch {
268
+ return { enableUser: true, enableProject: true };
269
+ }
270
+ }
271
+
255
272
  async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
256
273
  const items: SlashCommand[] = [];
257
274
  const warnings: string[] = [];
275
+ const { enableUser, enableProject } = readClaudeCommandToggles();
276
+
277
+ if (enableUser) {
278
+ const userBase = getUserClaude(ctx);
279
+ const userCommandsDir = path.join(userBase, "commands");
280
+
281
+ const userResult = await loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
282
+ extensions: ["md"],
283
+ transform: (name, content, path, source) => {
284
+ const cmdName = name.replace(/\.md$/, "");
285
+ return {
286
+ name: cmdName,
287
+ path,
288
+ content,
289
+ level: "user",
290
+ _source: source,
291
+ };
292
+ },
293
+ });
258
294
 
259
- const userBase = getUserClaude(ctx);
260
- const userCommandsDir = path.join(userBase, "commands");
261
-
262
- const userResult = await loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
263
- extensions: ["md"],
264
- transform: (name, content, path, source) => {
265
- const cmdName = name.replace(/\.md$/, "");
266
- return {
267
- name: cmdName,
268
- path,
269
- content,
270
- level: "user",
271
- _source: source,
272
- };
273
- },
274
- });
275
-
276
- items.push(...userResult.items);
277
- if (userResult.warnings) warnings.push(...userResult.warnings);
278
-
279
- const projectCommandsDir = path.join(ctx.cwd, CONFIG_DIR, "commands");
295
+ items.push(...userResult.items);
296
+ if (userResult.warnings) warnings.push(...userResult.warnings);
297
+ }
280
298
 
281
- const projectResult = await loadFilesFromDir<SlashCommand>(ctx, projectCommandsDir, PROVIDER_ID, "project", {
282
- extensions: ["md"],
283
- transform: (name, content, path, source) => {
284
- const cmdName = name.replace(/\.md$/, "");
285
- return {
286
- name: cmdName,
287
- path,
288
- content,
289
- level: "project",
290
- _source: source,
291
- };
292
- },
293
- });
299
+ if (enableProject) {
300
+ const projectCommandsDir = path.join(ctx.cwd, CONFIG_DIR, "commands");
301
+
302
+ const projectResult = await loadFilesFromDir<SlashCommand>(ctx, projectCommandsDir, PROVIDER_ID, "project", {
303
+ extensions: ["md"],
304
+ transform: (name, content, path, source) => {
305
+ const cmdName = name.replace(/\.md$/, "");
306
+ return {
307
+ name: cmdName,
308
+ path,
309
+ content,
310
+ level: "project",
311
+ _source: source,
312
+ };
313
+ },
314
+ });
294
315
 
295
- items.push(...projectResult.items);
296
- if (projectResult.warnings) warnings.push(...projectResult.warnings);
316
+ items.push(...projectResult.items);
317
+ if (projectResult.warnings) warnings.push(...projectResult.warnings);
318
+ }
297
319
 
298
320
  return { items, warnings };
299
321
  }
@@ -221,9 +221,9 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
221
221
 
222
222
  let tools = parseArrayOrCSV(frontmatter.tools)?.map(tool => tool.toLowerCase());
223
223
 
224
- // Subagents with explicit tool lists always need submit_result
225
- if (tools && !tools.includes("submit_result")) {
226
- tools = [...tools, "submit_result"];
224
+ // Subagents with explicit tool lists always need yield
225
+ if (tools && !tools.includes("yield")) {
226
+ tools = [...tools, "yield"];
227
227
  }
228
228
 
229
229
  // Parse spawns field (array, "*", or CSV)
@@ -26,6 +26,7 @@ import { type Settings, settingsCapability } from "../capability/settings";
26
26
  import { type Skill, skillCapability } from "../capability/skill";
27
27
  import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
28
28
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
29
+ import { settings } from "../config/settings";
29
30
 
30
31
  import {
31
32
  buildExtensionModuleItems,
@@ -236,9 +237,26 @@ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<Extens
236
237
  // Slash Commands (commands/)
237
238
  // =============================================================================
238
239
 
240
+ /**
241
+ * Read the OpenCode command-loading toggles from settings.
242
+ * Falls back to true (current behavior) when settings are not initialized,
243
+ * e.g. inside discovery unit tests that run without Settings.init().
244
+ */
245
+ function readOpencodeCommandToggles(): { enableUser: boolean; enableProject: boolean } {
246
+ try {
247
+ return {
248
+ enableUser: settings.get("commands.enableOpencodeUser") ?? true,
249
+ enableProject: settings.get("commands.enableOpencodeProject") ?? true,
250
+ };
251
+ } catch {
252
+ return { enableUser: true, enableProject: true };
253
+ }
254
+ }
255
+
239
256
  async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
240
- const userCommandsDir = getUserPath(ctx, "opencode", "commands");
241
- const projectCommandsDir = getProjectPath(ctx, "opencode", "commands");
257
+ const { enableUser, enableProject } = readOpencodeCommandToggles();
258
+ const userCommandsDir = enableUser ? getUserPath(ctx, "opencode", "commands") : null;
259
+ const projectCommandsDir = enableProject ? getProjectPath(ctx, "opencode", "commands") : null;
242
260
 
243
261
  const transformCommand =
244
262
  (level: "user" | "project") => (name: string, content: string, filePath: string, source: SourceMeta) => {
package/src/edit/diff.ts CHANGED
@@ -50,17 +50,8 @@ export class ApplyPatchError extends Error {
50
50
  // Diff String Generation
51
51
  // ═══════════════════════════════════════════════════════════════════════════
52
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}`;
53
+ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, content: string): string {
54
+ return `${prefix}${lineNum}|${content}`;
64
55
  }
65
56
 
66
57
  /**
@@ -71,9 +62,6 @@ export function generateDiffString(oldContent: string, newContent: string, conte
71
62
  const parts = Diff.diffLines(oldContent, newContent);
72
63
  const output: string[] = [];
73
64
 
74
- const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
75
- const lineNumWidth = String(maxLineNum).length;
76
-
77
65
  let oldLineNum = 1;
78
66
  let newLineNum = 1;
79
67
  let lastWasChange = false;
@@ -95,10 +83,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte
95
83
  // Show the change
96
84
  for (const line of raw) {
97
85
  if (part.added) {
98
- output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
86
+ output.push(formatNumberedDiffLine("+", newLineNum, line));
99
87
  newLineNum++;
100
88
  } else {
101
- output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line));
89
+ output.push(formatNumberedDiffLine("-", oldLineNum, line));
102
90
  oldLineNum++;
103
91
  }
104
92
  }
@@ -108,40 +96,57 @@ export function generateDiffString(oldContent: string, newContent: string, conte
108
96
  const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
109
97
 
110
98
  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);
99
+ const contextLimit = Math.max(0, contextLines);
100
+ let leadingSkip = 0;
101
+ let middleSkip = 0;
102
+ let trailingSkip = 0;
103
+ let linesToShow: string[];
104
+
105
+ if (lastWasChange && nextPartIsChange) {
106
+ if (raw.length > contextLimit * 2) {
107
+ const leadingContext = raw.slice(0, contextLimit);
108
+ const trailingContext = raw.slice(raw.length - contextLimit);
109
+ middleSkip = raw.length - leadingContext.length - trailingContext.length;
110
+ linesToShow = [...leadingContext, ...trailingContext];
111
+ } else {
112
+ linesToShow = raw;
113
+ }
114
+ } else if (nextPartIsChange) {
115
+ leadingSkip = Math.max(0, raw.length - contextLimit);
116
+ linesToShow = raw.slice(leadingSkip);
117
+ } else {
118
+ trailingSkip = Math.max(0, raw.length - contextLimit);
119
+ linesToShow = raw.slice(0, contextLimit);
125
120
  }
126
121
 
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;
122
+ if (leadingSkip > 0) {
123
+ output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
124
+ oldLineNum += leadingSkip;
125
+ newLineNum += leadingSkip;
132
126
  }
133
127
 
134
- for (const line of linesToShow) {
135
- output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line));
128
+ const firstChunkLength = middleSkip > 0 ? contextLimit : linesToShow.length;
129
+ for (const line of linesToShow.slice(0, firstChunkLength)) {
130
+ output.push(formatNumberedDiffLine(" ", oldLineNum, line));
136
131
  oldLineNum++;
137
132
  newLineNum++;
138
133
  }
139
134
 
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;
135
+ if (middleSkip > 0) {
136
+ output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
137
+ oldLineNum += middleSkip;
138
+ newLineNum += middleSkip;
139
+ for (const line of linesToShow.slice(firstChunkLength)) {
140
+ output.push(formatNumberedDiffLine(" ", oldLineNum, line));
141
+ oldLineNum++;
142
+ newLineNum++;
143
+ }
144
+ }
145
+
146
+ if (trailingSkip > 0) {
147
+ output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
148
+ oldLineNum += trailingSkip;
149
+ newLineNum += trailingSkip;
145
150
  }
146
151
  } else {
147
152
  // Skip these context lines entirely
@@ -184,8 +189,6 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string
184
189
  const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
185
190
  const output: string[] = [];
186
191
  let firstChangedLine: number | undefined;
187
- const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
188
- const lineNumWidth = String(maxLineNum).length;
189
192
  for (const hunk of patch.hunks) {
190
193
  output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
191
194
  let oldLine = hunk.oldStart;
@@ -193,18 +196,18 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string
193
196
  for (const line of hunk.lines) {
194
197
  if (line.startsWith("-")) {
195
198
  if (firstChangedLine === undefined) firstChangedLine = newLine;
196
- output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1)));
199
+ output.push(formatNumberedDiffLine("-", oldLine, line.slice(1)));
197
200
  oldLine++;
198
201
  continue;
199
202
  }
200
203
  if (line.startsWith("+")) {
201
204
  if (firstChangedLine === undefined) firstChangedLine = newLine;
202
- output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1)));
205
+ output.push(formatNumberedDiffLine("+", newLine, line.slice(1)));
203
206
  newLine++;
204
207
  continue;
205
208
  }
206
209
  if (line.startsWith(" ")) {
207
- output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1)));
210
+ output.push(formatNumberedDiffLine(" ", oldLine, line.slice(1)));
208
211
  oldLine++;
209
212
  newLine++;
210
213
  continue;
package/src/edit/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  writethroughNoop,
10
10
  } from "../lsp";
11
11
  import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
12
+ import atomDescription from "../prompts/tools/atom.md" with { type: "text" };
12
13
  import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" };
13
14
  import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
14
15
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
@@ -17,44 +18,33 @@ import type { ToolSession } from "../tools";
17
18
  import { VimTool, vimSchema } from "../tools/vim";
18
19
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
19
20
  import type { VimToolDetails } from "../vim/types";
20
- import {
21
- type ApplyPatchParams,
22
- applyPatchSchema,
23
- expandApplyPatchToEntries,
24
- isApplyPatchParams,
25
- } from "./modes/apply-patch";
21
+ import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
26
22
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
23
+ import {
24
+ type AtomParams,
25
+ type AtomToolEdit,
26
+ atomEditParamsSchema,
27
+ executeAtomSingle,
28
+ resolveAtomEntryPaths,
29
+ } from "./modes/atom";
27
30
  import {
28
31
  type ChunkParams,
29
32
  type ChunkToolEdit,
30
33
  chunkEditParamsSchema,
31
34
  executeChunkSingle,
32
- isChunkParams,
33
35
  parseChunkEditPath,
34
36
  resolveAnchorStyle,
35
37
  resolveChunkAutoIndent,
36
38
  } from "./modes/chunk";
37
39
  import {
38
40
  executeHashlineSingle,
41
+ HashlineMismatchError,
39
42
  type HashlineParams,
40
43
  type HashlineToolEdit,
41
44
  hashlineEditParamsSchema,
42
- isHashlineParams,
43
45
  } from "./modes/hashline";
44
- import {
45
- executePatchSingle,
46
- isPatchParams,
47
- type PatchEditEntry,
48
- type PatchParams,
49
- patchEditSchema,
50
- } from "./modes/patch";
51
- import {
52
- executeReplaceSingle,
53
- isReplaceParams,
54
- type ReplaceEditEntry,
55
- type ReplaceParams,
56
- replaceEditSchema,
57
- } from "./modes/replace";
46
+ import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
47
+ import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
58
48
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
59
49
 
60
50
  export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
@@ -62,30 +52,38 @@ export * from "./apply-patch";
62
52
  export * from "./diff";
63
53
  export * from "./line-hash";
64
54
  export * from "./modes/apply-patch";
55
+ export * from "./modes/atom";
65
56
  export * from "./modes/chunk";
66
57
  export * from "./modes/hashline";
67
58
  export * from "./modes/patch";
68
59
  export * from "./modes/replace";
69
60
  export * from "./normalize";
70
61
  export * from "./renderer";
62
+ export * from "./streaming";
71
63
 
72
64
  type TInput =
73
65
  | typeof replaceEditSchema
74
66
  | typeof patchEditSchema
75
67
  | typeof hashlineEditParamsSchema
68
+ | typeof atomEditParamsSchema
76
69
  | typeof chunkEditParamsSchema
77
70
  | typeof vimSchema
78
71
  | typeof applyPatchSchema;
79
72
 
80
73
  type VimParams = Static<typeof vimSchema>;
81
- type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams | ApplyPatchParams;
74
+ type EditParams =
75
+ | ReplaceParams
76
+ | PatchParams
77
+ | HashlineParams
78
+ | AtomParams
79
+ | ChunkParams
80
+ | VimParams
81
+ | ApplyPatchParams;
82
82
  type EditToolResultDetails = EditToolDetails | VimToolDetails;
83
83
 
84
84
  type EditModeDefinition = {
85
85
  description: (session: ToolSession) => string;
86
86
  parameters: TInput;
87
- invalidParamsMessage: string;
88
- validate: (params: EditParams) => boolean;
89
87
  execute: (
90
88
  tool: EditTool,
91
89
  params: EditParams,
@@ -108,10 +106,6 @@ function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
108
106
  return editMode;
109
107
  }
110
108
 
111
- function isVimParams(params: EditParams): params is VimParams {
112
- return typeof params === "object" && params !== null && "file" in params && typeof params.file === "string";
113
- }
114
-
115
109
  function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
116
110
  switch (rawValue) {
117
111
  case "true":
@@ -147,6 +141,25 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
147
141
  return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
148
142
  }
149
143
 
144
+ /**
145
+ * Resolve per-entry `path` against an optional top-level `path` default.
146
+ * If both are absent on an entry, throws a descriptive error.
147
+ */
148
+ function resolveEntryPaths<T extends { path?: string }>(
149
+ edits: readonly T[],
150
+ topLevelPath: string | undefined,
151
+ ): (T & { path: string })[] {
152
+ return edits.map((edit, i) => {
153
+ const path = (edit && typeof edit.path === "string" && edit.path) || topLevelPath;
154
+ if (!path) {
155
+ throw new Error(
156
+ `Edit ${i}: missing \`path\`. Provide \`path\` on this edit or supply a top-level \`path\` for the request.`,
157
+ );
158
+ }
159
+ return { ...edit, path };
160
+ });
161
+ }
162
+
150
163
  /** Group items by a key, preserving insertion order. */
151
164
  function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
152
165
  const map = new Map<K, T[]>();
@@ -202,7 +215,8 @@ async function executePerFile(
202
215
  if (text) contentTexts.push(text);
203
216
  } catch (err) {
204
217
  const errorText = err instanceof Error ? err.message : String(err);
205
- perFileResults.push({ path, diff: "", isError: true, errorText });
218
+ const displayErrorText = err instanceof HashlineMismatchError ? err.displayMessage : undefined;
219
+ perFileResults.push({ path, diff: "", isError: true, errorText, displayErrorText });
206
220
  contentTexts.push(`Error editing ${path}: ${errorText}`);
207
221
  }
208
222
 
@@ -306,10 +320,6 @@ export class EditTool implements AgentTool<TInput> {
306
320
  context?: AgentToolContext,
307
321
  ): Promise<AgentToolResult<EditToolResultDetails, TInput>> {
308
322
  const modeDefinition = this.#getModeDefinition();
309
- if (!modeDefinition.validate(params)) {
310
- throw new Error(modeDefinition.invalidParamsMessage);
311
- }
312
-
313
323
  return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall), onUpdate);
314
324
  }
315
325
 
@@ -322,9 +332,6 @@ export class EditTool implements AgentTool<TInput> {
322
332
  chunkAutoIndent: resolveChunkAutoIndent(),
323
333
  }),
324
334
  parameters: chunkEditParamsSchema,
325
- invalidParamsMessage:
326
- "Invalid edit parameters for chunk mode. Expected `{ edits: [{ path: 'file:selector', ...op }, ...] }` with at least one edit. Each edit needs a `path`; supply one of `write` (string content; pass an empty string or omit it together with `replace`/`insert` to delete the chunk), `replace: { old, new }`, or `insert: { loc, body }`.",
327
- validate: isChunkParams,
328
335
  execute: (
329
336
  tool: EditTool,
330
337
  params: EditParams,
@@ -332,8 +339,9 @@ export class EditTool implements AgentTool<TInput> {
332
339
  batchRequest: LspBatchRequest | undefined,
333
340
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
334
341
  ) => {
335
- const { edits } = params as ChunkParams;
336
- const byFile = groupBy(edits, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
342
+ const { edits, path: topPath } = params as ChunkParams & { path?: string };
343
+ const resolved = resolveEntryPaths(edits as ChunkToolEdit[], topPath);
344
+ const byFile = groupBy(resolved, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
337
345
  const entries = [...byFile.entries()].map(([filePath, fileEdits]) => ({
338
346
  path: filePath,
339
347
  run: (br: LspBatchRequest | undefined) =>
@@ -353,8 +361,6 @@ export class EditTool implements AgentTool<TInput> {
353
361
  patch: {
354
362
  description: () => prompt.render(patchDescription),
355
363
  parameters: patchEditSchema,
356
- invalidParamsMessage: "Invalid edit parameters for patch mode.",
357
- validate: isPatchParams,
358
364
  execute: (
359
365
  tool: EditTool,
360
366
  params: EditParams,
@@ -362,8 +368,9 @@ export class EditTool implements AgentTool<TInput> {
362
368
  batchRequest: LspBatchRequest | undefined,
363
369
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
364
370
  ) => {
365
- const { edits } = params as PatchParams;
366
- const entries = edits.map((entry: PatchEditEntry) => ({
371
+ const { edits, path: topPath } = params as PatchParams & { path?: string };
372
+ const resolved = resolveEntryPaths(edits as PatchEditEntry[], topPath);
373
+ const entries = resolved.map(entry => ({
367
374
  path: entry.path,
368
375
  run: (br: LspBatchRequest | undefined) =>
369
376
  executePatchSingle({
@@ -383,8 +390,6 @@ export class EditTool implements AgentTool<TInput> {
383
390
  apply_patch: {
384
391
  description: () => prompt.render(applyPatchDescription),
385
392
  parameters: applyPatchSchema,
386
- invalidParamsMessage: "Invalid edit parameters for apply_patch mode.",
387
- validate: isApplyPatchParams,
388
393
  execute: (
389
394
  tool: EditTool,
390
395
  params: EditParams,
@@ -393,8 +398,8 @@ export class EditTool implements AgentTool<TInput> {
393
398
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
394
399
  ) => {
395
400
  const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
396
- const perFile = entries.map((entry: PatchEditEntry) => ({
397
- path: entry.path,
401
+ const perFile = entries.map(entry => ({
402
+ path: entry.path!,
398
403
  run: (br: LspBatchRequest | undefined) =>
399
404
  executePatchSingle({
400
405
  session: tool.session,
@@ -413,8 +418,6 @@ export class EditTool implements AgentTool<TInput> {
413
418
  hashline: {
414
419
  description: () => prompt.render(hashlineDescription),
415
420
  parameters: hashlineEditParamsSchema,
416
- invalidParamsMessage: "Invalid edit parameters for hashline mode.",
417
- validate: isHashlineParams,
418
421
  execute: (
419
422
  tool: EditTool,
420
423
  params: EditParams,
@@ -422,8 +425,9 @@ export class EditTool implements AgentTool<TInput> {
422
425
  batchRequest: LspBatchRequest | undefined,
423
426
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
424
427
  ) => {
425
- const { edits } = params as HashlineParams;
426
- const byFile = groupBy(edits, (e: HashlineToolEdit) => e.path);
428
+ const { edits, path: topPath } = params as HashlineParams & { path?: string };
429
+ const resolved = resolveEntryPaths(edits as HashlineToolEdit[], topPath);
430
+ const byFile = groupBy(resolved, e => e.path);
427
431
  const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
428
432
  path,
429
433
  run: (br: LspBatchRequest | undefined) =>
@@ -440,11 +444,38 @@ export class EditTool implements AgentTool<TInput> {
440
444
  return executePerFile(entries, batchRequest, onUpdate);
441
445
  },
442
446
  },
447
+ atom: {
448
+ description: () => prompt.render(atomDescription),
449
+ parameters: atomEditParamsSchema,
450
+ execute: (
451
+ tool: EditTool,
452
+ params: EditParams,
453
+ signal: AbortSignal | undefined,
454
+ batchRequest: LspBatchRequest | undefined,
455
+ onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
456
+ ) => {
457
+ const { edits, path: topPath } = params as AtomParams & { path?: string };
458
+ const resolved = resolveAtomEntryPaths(edits as AtomToolEdit[], topPath);
459
+ const byFile = groupBy(resolved, e => e.path);
460
+ const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
461
+ path,
462
+ run: (br: LspBatchRequest | undefined) =>
463
+ executeAtomSingle({
464
+ session: tool.session,
465
+ path,
466
+ edits: fileEdits,
467
+ signal,
468
+ batchRequest: br,
469
+ writethrough: tool.#writethrough,
470
+ beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
471
+ }),
472
+ }));
473
+ return executePerFile(entries, batchRequest, onUpdate);
474
+ },
475
+ },
443
476
  replace: {
444
477
  description: () => prompt.render(replaceDescription),
445
478
  parameters: replaceEditSchema,
446
- invalidParamsMessage: "Invalid edit parameters for replace mode.",
447
- validate: isReplaceParams,
448
479
  execute: (
449
480
  tool: EditTool,
450
481
  params: EditParams,
@@ -452,8 +483,9 @@ export class EditTool implements AgentTool<TInput> {
452
483
  batchRequest: LspBatchRequest | undefined,
453
484
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
454
485
  ) => {
455
- const { edits } = params as ReplaceParams;
456
- const entries = edits.map((entry: ReplaceEditEntry) => ({
486
+ const { edits, path: topPath } = params as ReplaceParams & { path?: string };
487
+ const resolved = resolveEntryPaths(edits as ReplaceEditEntry[], topPath);
488
+ const entries = resolved.map(entry => ({
457
489
  path: entry.path,
458
490
  run: (br: LspBatchRequest | undefined) =>
459
491
  executeReplaceSingle({
@@ -473,8 +505,6 @@ export class EditTool implements AgentTool<TInput> {
473
505
  vim: {
474
506
  description: () => this.#vimTool.description,
475
507
  parameters: vimSchema,
476
- invalidParamsMessage: "Invalid edit parameters for vim mode.",
477
- validate: isVimParams,
478
508
  execute: async (
479
509
  tool: EditTool,
480
510
  params: EditParams,