@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.1

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 (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. package/src/vim/types.ts +197 -0
@@ -8,6 +8,7 @@ import {
8
8
  type ChunkInfo,
9
9
  ChunkReadStatus,
10
10
  type ChunkReadTarget,
11
+ ChunkRegion,
11
12
  ChunkState,
12
13
  type EditOperation as NativeEditOperation,
13
14
  } from "@oh-my-pi/pi-natives";
@@ -30,7 +31,9 @@ import type { EditToolDetails, LspBatchRequest } from "../renderer";
30
31
  export type { ChunkReadTarget };
31
32
 
32
33
  export type ChunkEditOperation =
33
- | { op: "replace"; sel?: string; content: string }
34
+ | { op: "put"; sel?: string; content: string }
35
+ | { op: "replace"; sel?: string; content: string; find: string }
36
+ | { op: "delete"; sel?: string }
34
37
  | { op: "before"; sel?: string; content: string }
35
38
  | { op: "after"; sel?: string; content: string }
36
39
  | { op: "prepend"; sel?: string; content: string }
@@ -158,6 +161,10 @@ async function resolveChunkSourceContext(session: ToolSession, path: string): Pr
158
161
  };
159
162
  }
160
163
 
164
+ function normalizeChunkRegionSyntax(text: string): string {
165
+ return text.replaceAll("@body", "~").replaceAll("@head", "^");
166
+ }
167
+
161
168
  function buildChunkEditResult(result: {
162
169
  diffBefore: string;
163
170
  diffAfter: string;
@@ -174,7 +181,7 @@ function buildChunkEditResult(result: {
174
181
  changed: result.changed,
175
182
  parseValid: result.parseValid,
176
183
  touchedPaths: result.touchedPaths,
177
- warnings: result.warnings,
184
+ warnings: result.warnings.map(normalizeChunkRegionSyntax),
178
185
  };
179
186
  }
180
187
 
@@ -192,6 +199,17 @@ export function parseChunkSelector(selector: string | undefined): { selector?: s
192
199
  return { selector };
193
200
  }
194
201
 
202
+ /** Split a combined `file:selector` path into file path and chunk selector. */
203
+ export function parseChunkEditPath(editPath: string | undefined): { filePath: string; selector?: string } {
204
+ if (!editPath) return { filePath: "" };
205
+ const colonIndex = chunkReadPathSeparatorIndex(editPath);
206
+ if (colonIndex === -1) {
207
+ return { filePath: editPath };
208
+ }
209
+ const sel = editPath.slice(colonIndex + 1) || undefined;
210
+ return { filePath: editPath.slice(0, colonIndex), selector: sel };
211
+ }
212
+
195
213
  export function parseChunkReadPath(readPath: string): ParsedChunkReadPath {
196
214
  const colonIndex = chunkReadPathSeparatorIndex(readPath);
197
215
  if (colonIndex === -1) {
@@ -252,34 +270,131 @@ export async function formatChunkedRead(params: {
252
270
  return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
253
271
  }
254
272
 
255
- export async function formatChunkedGrepLine(params: {
273
+ export type ChunkedGrepMatch = {
274
+ displayPath: string;
275
+ fileLineCount: number;
276
+ chunkPath?: string;
277
+ chunkChecksum?: string;
278
+ lineNumber: number;
279
+ line: string;
280
+ };
281
+
282
+ export async function describeChunkedGrepMatch(params: {
256
283
  filePath: string;
257
284
  lineNumber: number;
258
285
  line: string;
259
286
  cwd: string;
260
287
  language?: string;
261
- }): Promise<string> {
288
+ }): Promise<ChunkedGrepMatch> {
262
289
  const { filePath, lineNumber, line, cwd, language } = params;
263
290
  const { state } = await loadChunkStateForFile(filePath, language);
264
- return state.formatGrepLine(displayPathForFile(filePath, cwd), lineNumber, line);
291
+ const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined;
292
+ const chunkInfo = chunkPath ? state.chunk(chunkPath) : null;
293
+ return {
294
+ displayPath: displayPathForFile(filePath, cwd),
295
+ fileLineCount: state.lineCount,
296
+ chunkPath,
297
+ chunkChecksum: chunkInfo?.checksum,
298
+ lineNumber,
299
+ line,
300
+ };
301
+ }
302
+
303
+ const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH";
304
+ type NativeChunkRegion = "head" | "body";
305
+
306
+ function isChunkChecksumToken(value: string): boolean {
307
+ return value.length === 4 && Array.from(value).every(ch => CHUNK_CHECKSUM_ALPHABET.includes(ch.toUpperCase()));
308
+ }
309
+
310
+ function parseChunkEditSelector(selector: string | undefined): {
311
+ selector?: string;
312
+ crc?: string;
313
+ region?: NativeChunkRegion;
314
+ } {
315
+ if (!selector) {
316
+ return {};
317
+ }
318
+
319
+ let trimmed = selector.trim();
320
+ if (trimmed.length === 0) {
321
+ return {};
322
+ }
323
+
324
+ let region: NativeChunkRegion | undefined;
325
+ const suffix = trimmed.at(-1);
326
+ if (suffix === "~" || suffix === "^") {
327
+ region = suffix === "~" ? "body" : "head";
328
+ trimmed = trimmed.slice(0, -1).trimEnd();
329
+ }
330
+
331
+ let selectorPart = trimmed;
332
+ let crc: string | undefined;
333
+ const hashIndex = selectorPart.lastIndexOf("#");
334
+ if (hashIndex >= 0) {
335
+ const suffix = selectorPart.slice(hashIndex + 1).trim();
336
+ if (isChunkChecksumToken(suffix)) {
337
+ crc = suffix.toUpperCase();
338
+ selectorPart = selectorPart.slice(0, hashIndex).trimEnd();
339
+ }
340
+ } else if (isChunkChecksumToken(selectorPart)) {
341
+ crc = selectorPart.toUpperCase();
342
+ selectorPart = "";
343
+ }
344
+
345
+ return { selector: selectorPart || undefined, crc, region };
265
346
  }
266
347
 
267
- function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperation {
348
+ type NativeChunkRegionEncoding = "named" | "symbolic";
349
+
350
+ function toNativeEditRegion(
351
+ region: NativeChunkRegion | undefined,
352
+ encoding: NativeChunkRegionEncoding,
353
+ ): NativeEditOperation["region"] | undefined {
354
+ if (!region) {
355
+ return undefined;
356
+ }
357
+ if (encoding === "symbolic") {
358
+ return region === "body" ? ChunkRegion.Body : ChunkRegion.Head;
359
+ }
360
+ return region as unknown as NativeEditOperation["region"] | undefined;
361
+ }
362
+
363
+ function toNativeEditOperation(
364
+ operation: ChunkEditOperation,
365
+ defaultRegion: NativeChunkRegion | undefined,
366
+ encoding: NativeChunkRegionEncoding,
367
+ ): NativeEditOperation {
368
+ const { selector, crc, region } = parseChunkEditSelector(operation.sel);
369
+ const nativeRegion = toNativeEditRegion(operation.sel === undefined ? (region ?? defaultRegion) : region, encoding);
268
370
  switch (operation.op) {
371
+ case "put":
372
+ return {
373
+ op: ChunkEditOp.Put,
374
+ sel: selector,
375
+ crc,
376
+ region: nativeRegion,
377
+ content: operation.content,
378
+ };
269
379
  case "replace":
270
380
  return {
271
381
  op: ChunkEditOp.Replace,
272
- sel: operation.sel,
382
+ sel: selector,
383
+ crc,
384
+ region: nativeRegion,
385
+ find: operation.find,
273
386
  content: operation.content,
274
387
  };
275
388
  case "before":
276
- return { op: ChunkEditOp.Before, sel: operation.sel, content: operation.content };
389
+ return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content };
277
390
  case "after":
278
- return { op: ChunkEditOp.After, sel: operation.sel, content: operation.content };
391
+ return { op: ChunkEditOp.After, sel: selector, crc, region: nativeRegion, content: operation.content };
279
392
  case "prepend":
280
- return { op: ChunkEditOp.Prepend, sel: operation.sel, content: operation.content };
393
+ return { op: ChunkEditOp.Prepend, sel: selector, crc, region: nativeRegion, content: operation.content };
281
394
  case "append":
282
- return { op: ChunkEditOp.Append, sel: operation.sel, content: operation.content };
395
+ return { op: ChunkEditOp.Append, sel: selector, crc, region: nativeRegion, content: operation.content };
396
+ case "delete":
397
+ return { op: ChunkEditOp.Delete, sel: selector, crc, region: nativeRegion };
283
398
  default: {
284
399
  const exhaustive: never = operation;
285
400
  return exhaustive;
@@ -287,6 +402,28 @@ function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperati
287
402
  }
288
403
  }
289
404
 
405
+ function buildNativeChunkEditRequest(
406
+ params: { defaultSelector?: string; defaultCrc?: string; operations: ChunkEditOperation[] },
407
+ encoding: NativeChunkRegionEncoding,
408
+ ): Pick<Parameters<ChunkState["applyEdits"]>[0], "operations" | "defaultSelector" | "defaultCrc"> {
409
+ const parsedDefaultSelector = parseChunkEditSelector(params.defaultSelector);
410
+ const operations = params.operations.map(operation =>
411
+ toNativeEditOperation(operation, parsedDefaultSelector.region, encoding),
412
+ );
413
+ return {
414
+ operations,
415
+ defaultSelector: parsedDefaultSelector.selector,
416
+ defaultCrc: params.defaultCrc ?? parsedDefaultSelector.crc,
417
+ };
418
+ }
419
+
420
+ function isChunkRegionEncodingError(error: unknown): error is Error {
421
+ return (
422
+ error instanceof Error &&
423
+ /value `"(body|head|~|\^)"` does not match any variant of enum `ChunkRegion`/.test(error.message)
424
+ );
425
+ }
426
+
290
427
  export function applyChunkEdits(params: {
291
428
  source: string;
292
429
  language?: string;
@@ -298,19 +435,40 @@ export function applyChunkEdits(params: {
298
435
  anchorStyle?: ChunkAnchorStyle;
299
436
  }): ChunkEditResult {
300
437
  const normalizedSource = normalizeChunkSource(params.source);
301
- const nativeOperations = params.operations.map(toNativeEditOperation);
302
- const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language));
303
- const result = state.applyEdits({
304
- operations: nativeOperations,
305
- normalizeIndent: resolveChunkAutoIndent(),
306
- defaultSelector: params.defaultSelector,
307
- defaultCrc: params.defaultCrc,
308
- anchorStyle: params.anchorStyle,
309
- cwd: params.cwd,
310
- filePath: params.filePath,
311
- });
438
+ const applyNativeEdits = (encoding: NativeChunkRegionEncoding): ChunkEditResult => {
439
+ const request = buildNativeChunkEditRequest(params, encoding);
440
+ const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language));
441
+ return buildChunkEditResult(
442
+ state.applyEdits({
443
+ operations: request.operations,
444
+ normalizeIndent: resolveChunkAutoIndent(),
445
+ defaultSelector: request.defaultSelector,
446
+ defaultCrc: request.defaultCrc,
447
+ anchorStyle: params.anchorStyle,
448
+ cwd: params.cwd,
449
+ filePath: params.filePath,
450
+ }),
451
+ );
452
+ };
312
453
 
313
- return buildChunkEditResult(result);
454
+ try {
455
+ return applyNativeEdits("named");
456
+ } catch (error) {
457
+ if (isChunkRegionEncodingError(error)) {
458
+ try {
459
+ return applyNativeEdits("symbolic");
460
+ } catch (fallbackError) {
461
+ if (fallbackError instanceof Error) {
462
+ throw new Error(normalizeChunkRegionSyntax(fallbackError.message));
463
+ }
464
+ throw fallbackError;
465
+ }
466
+ }
467
+ if (error instanceof Error) {
468
+ throw new Error(normalizeChunkRegionSyntax(error.message));
469
+ }
470
+ throw error;
471
+ }
314
472
  }
315
473
 
316
474
  export async function getChunkInfoForFile(
@@ -326,22 +484,39 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget {
326
484
  return { status: ChunkReadStatus.NotFound, selector };
327
485
  }
328
486
 
329
- const CHUNK_OP_VALUES = ["replace", "after", "before", "prepend", "append"] as const;
330
-
331
- export const chunkToolEditSchema = Type.Object({
332
- op: StringEnum(CHUNK_OP_VALUES),
333
- sel: Type.String({
334
- description:
335
- "Chunk selector. Use 'path~' or 'path^' for insertions, 'path#CRC~' or 'path#CRC^' for replace, or omit the suffix to target the full chunk.",
336
- }),
337
- content: Type.String({
338
- description:
339
- "New content. Write indentation relative to the targeted region as described in the tool prompt. Do NOT include the chunk's base padding.",
340
- }),
341
- });
487
+ export const chunkToolEditSchema = Type.Object(
488
+ {
489
+ path: Type.String({
490
+ description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#ABCD~', 'src/app.ts:class_Bar'.",
491
+ }),
492
+ write: Type.Optional(
493
+ Type.Union([Type.String(), Type.Null()], {
494
+ description: "Write complete new content to the targeted region. Use null to delete the chunk.",
495
+ }),
496
+ ),
497
+ replace: Type.Optional(
498
+ Type.Object(
499
+ {
500
+ old: Type.String({ description: "Literal substring to find. Must match exactly once." }),
501
+ new: Type.String({ description: "Replacement text." }),
502
+ },
503
+ { description: "Find and replace a substring within the chunk." },
504
+ ),
505
+ ),
506
+ insert: Type.Optional(
507
+ Type.Object(
508
+ {
509
+ loc: StringEnum(["append", "prepend"] as const),
510
+ body: Type.String({ description: "Content to insert." }),
511
+ },
512
+ { description: "Insert content relative to the chunk." },
513
+ ),
514
+ ),
515
+ },
516
+ { additionalProperties: false },
517
+ );
342
518
  export const chunkEditParamsSchema = Type.Object(
343
519
  {
344
- path: Type.String({ description: "File path" }),
345
520
  edits: Type.Array(chunkToolEditSchema, {
346
521
  description: "Chunk edits",
347
522
  minItems: 1,
@@ -353,9 +528,10 @@ export const chunkEditParamsSchema = Type.Object(
353
528
  export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
354
529
  export type ChunkParams = Static<typeof chunkEditParamsSchema>;
355
530
 
356
- interface ExecuteChunkModeOptions {
531
+ export interface ExecuteChunkSingleOptions {
357
532
  session: ToolSession;
358
- params: ChunkParams;
533
+ path: string;
534
+ edits: ChunkToolEdit[];
359
535
  signal?: AbortSignal;
360
536
  batchRequest?: LspBatchRequest;
361
537
  writethrough: WritethroughCallback;
@@ -363,20 +539,122 @@ interface ExecuteChunkModeOptions {
363
539
  }
364
540
 
365
541
  export function isChunkParams(params: unknown): params is ChunkParams {
366
- return (
367
- typeof params === "object" &&
368
- params !== null &&
369
- "edits" in params &&
370
- Array.isArray(params.edits) &&
371
- params.edits.length > 0 &&
372
- typeof params.edits[0] === "object" &&
373
- params.edits[0] !== null &&
374
- "sel" in params.edits[0]
375
- );
542
+ if (
543
+ typeof params !== "object" ||
544
+ params === null ||
545
+ !("edits" in params) ||
546
+ !Array.isArray(params.edits) ||
547
+ params.edits.length === 0
548
+ ) {
549
+ return false;
550
+ }
551
+ const first = params.edits[0];
552
+ if (typeof first !== "object" || first === null || !("path" in first)) return false;
553
+ return "write" in first || "replace" in first || "insert" in first;
554
+ }
555
+
556
+ /** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on.
557
+ * Handles two patterns:
558
+ * 1. Tab-based over-indentation: models include the function's base \t indent.
559
+ * 2. Space-based indentation: models use literal spaces instead of \t.
560
+ * Returns the corrected content and any warnings. */
561
+ function autoCorrectBodyIndent(content: string, index: number): { content: string; warnings: string[] } {
562
+ const warnings: string[] = [];
563
+ if (!content || !resolveChunkAutoIndent()) return { content, warnings };
564
+ const lines = content.split("\n");
565
+ const nonEmpty = lines.filter(l => l.length > 0);
566
+ if (nonEmpty.length <= 1) return { content, warnings };
567
+
568
+ // 1. Tab-based over-indentation: strip common leading tabs.
569
+ const minTabs = Math.min(...nonEmpty.map(l => l.match(/^\t*/)?.[0].length ?? 0));
570
+ if (minTabs >= 1) {
571
+ const fixed = lines.map(l => (l.length === 0 ? l : l.slice(minTabs))).join("\n");
572
+ warnings.push(
573
+ `Edit ${index + 1}: auto-corrected body indentation \u2014 stripped ${minTabs} leading tab(s). When writing to \`~\`, write at column 0; the tool adds the function's base indent.`,
574
+ );
575
+ return { content: fixed, warnings };
576
+ }
577
+
578
+ // 2. Space-based indentation: strip common leading spaces and convert to tabs.
579
+ const spaceIndents = nonEmpty.map(l => l.match(/^ */)?.[0].length ?? 0);
580
+ const minSpaces = Math.min(...spaceIndents);
581
+ if (minSpaces >= 2) {
582
+ const indentDiffs = spaceIndents.map(s => s - minSpaces).filter(d => d > 0);
583
+ const indentUnit = indentDiffs.length > 0 ? Math.min(...indentDiffs) : 4;
584
+ const unit = indentUnit >= 2 && indentUnit <= 8 ? indentUnit : 4;
585
+ const fixed = lines
586
+ .map(line => {
587
+ if (line.length === 0) return line;
588
+ const stripped = line.slice(minSpaces);
589
+ const leadingSpaces = stripped.match(/^ */)?.[0].length ?? 0;
590
+ const tabs = Math.floor(leadingSpaces / unit);
591
+ const rem = leadingSpaces % unit;
592
+ return "\t".repeat(tabs) + " ".repeat(rem) + stripped.slice(leadingSpaces);
593
+ })
594
+ .join("\n");
595
+ warnings.push(
596
+ `Edit ${index + 1}: auto-converted space indentation to tabs \u2014 stripped ${minSpaces} common leading spaces and converted ${unit}-space indent to tabs. When auto-indent is on, use \\t for indentation.`,
597
+ );
598
+ return { content: fixed, warnings };
599
+ }
600
+
601
+ return { content, warnings };
376
602
  }
377
603
 
378
- function normalizeChunkEditOperations(edits: ChunkToolEdit[]): ChunkEditOperation[] {
379
- return edits as ChunkEditOperation[];
604
+ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
605
+ operations: ChunkEditOperation[];
606
+ warnings: string[];
607
+ } {
608
+ const warnings: string[] = [];
609
+ const operations = edits.map((edit, index): ChunkEditOperation => {
610
+ const { selector } = parseChunkEditPath(edit.path);
611
+ // When multiple ops are present (model confusion), prefer write (total replacement) as the
612
+ // safest default, then replace (surgical), then insert (additive), then delete.
613
+ const hasInsert = edit.insert != null && typeof edit.insert.body === "string" && edit.insert.body.length > 0;
614
+ const hasReplace =
615
+ edit.replace != null &&
616
+ ((typeof edit.replace.old === "string" && edit.replace.old.length > 0) ||
617
+ (typeof edit.replace.new === "string" && edit.replace.new.length > 0));
618
+ const hasWrite = typeof edit.write === "string" && edit.write.length > 0;
619
+ const opCount = [hasInsert, hasReplace, hasWrite].filter(Boolean).length;
620
+ if (opCount > 1) {
621
+ const chosen = hasWrite ? "write" : hasReplace ? "replace" : "insert";
622
+ const present = [hasWrite && "write", hasReplace && "replace", hasInsert && "insert"]
623
+ .filter(Boolean)
624
+ .join(", ");
625
+ warnings.push(
626
+ `Edit ${index + 1}: multiple operation fields set (${present}). Each edit entry must have exactly ONE of write/replace/insert — not multiple. Used "${chosen}", ignored the rest.`,
627
+ );
628
+ }
629
+ if (hasWrite) {
630
+ let writeContent = edit.write!;
631
+ if (selector?.endsWith("~")) {
632
+ const corrected = autoCorrectBodyIndent(writeContent, index);
633
+ writeContent = corrected.content;
634
+ warnings.push(...corrected.warnings);
635
+ }
636
+ return { op: "put", sel: selector, content: writeContent };
637
+ }
638
+ if (typeof edit.write === "string" && !hasInsert && !hasReplace) {
639
+ return { op: "put", sel: selector, content: edit.write };
640
+ }
641
+ if (hasReplace) {
642
+ return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old };
643
+ }
644
+ if (hasInsert) {
645
+ const op = edit.insert!.loc === "prepend" ? "before" : "after";
646
+ let insertContent = edit.insert!.body;
647
+ if (selector?.endsWith("~")) {
648
+ const corrected = autoCorrectBodyIndent(insertContent, index);
649
+ insertContent = corrected.content;
650
+ warnings.push(...corrected.warnings);
651
+ }
652
+ return { op, sel: selector, content: insertContent };
653
+ }
654
+ // write: null or no op specified → delete
655
+ return { op: "delete", sel: selector };
656
+ });
657
+ return { operations, warnings };
380
658
  }
381
659
 
382
660
  async function writeChunkResult(params: {
@@ -428,11 +706,10 @@ async function writeChunkResult(params: {
428
706
  };
429
707
  }
430
708
 
431
- export async function executeChunkMode(
432
- options: ExecuteChunkModeOptions,
709
+ export async function executeChunkSingle(
710
+ options: ExecuteChunkSingleOptions,
433
711
  ): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
434
- const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
435
- const { path, edits } = params;
712
+ const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
436
713
  const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
437
714
  session,
438
715
  path,
@@ -441,7 +718,7 @@ export async function executeChunkMode(
441
718
  if (parentDir && parentDir !== ".") {
442
719
  await fs.mkdir(parentDir, { recursive: true });
443
720
  }
444
- const normalizedOperations = normalizeChunkEditOperations(edits);
721
+ const { operations: normalizedOperations, warnings: normWarnings } = normalizeChunkEditOperations(edits);
445
722
 
446
723
  if (!sourceExists && normalizedOperations.some(op => op.sel)) {
447
724
  throw new Error(
@@ -457,10 +734,12 @@ export async function executeChunkMode(
457
734
  operations: normalizedOperations,
458
735
  anchorStyle: resolveAnchorStyle(session.settings),
459
736
  });
737
+ chunkResult.warnings.push(...normWarnings);
460
738
 
461
739
  if (!chunkResult.changed) {
740
+ const warningsBlock = chunkResult.warnings.length > 0 ? `\n\nWarnings:\n${chunkResult.warnings.join("\n")}` : "";
462
741
  return {
463
- content: [{ type: "text", text: "[No changes needed \u2014 content already matches.]" }],
742
+ content: [{ type: "text", text: `[No changes needed content already matches.]${warningsBlock}` }],
464
743
  details: {
465
744
  diff: "",
466
745
  op: sourceExists ? "update" : "create",
@@ -128,18 +128,18 @@ const locSchema = Type.Union(
128
128
 
129
129
  export const hashlineEditSchema = Type.Object(
130
130
  {
131
- loc: locSchema,
132
- content: linesSchema,
131
+ path: Type.String({ description: "File path" }),
132
+ loc: Type.Optional(locSchema),
133
+ 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" })),
133
136
  },
134
137
  { additionalProperties: false },
135
138
  );
136
139
 
137
140
  export const hashlineEditParamsSchema = Type.Object(
138
141
  {
139
- path: Type.String({ description: "path" }),
140
- edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }),
141
- delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })),
142
- move: Type.Optional(Type.String({ description: "If set, move $path to $move" })),
142
+ edits: Type.Array(hashlineEditSchema, { description: "edits" }),
143
143
  },
144
144
  { additionalProperties: false },
145
145
  );
@@ -147,9 +147,10 @@ export const hashlineEditParamsSchema = Type.Object(
147
147
  export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
148
148
  export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
149
149
 
150
- interface ExecuteHashlineModeOptions {
150
+ export interface ExecuteHashlineSingleOptions {
151
151
  session: ToolSession;
152
- params: HashlineParams;
152
+ path: string;
153
+ edits: HashlineToolEdit[];
153
154
  signal?: AbortSignal;
154
155
  batchRequest?: LspBatchRequest;
155
156
  writethrough: WritethroughCallback;
@@ -166,20 +167,38 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
166
167
  }
167
168
 
168
169
  export function isHashlineParams(params: unknown): params is HashlineParams {
169
- return (
170
- typeof params === "object" &&
171
- params !== null &&
172
- "edits" in params &&
173
- Array.isArray(params.edits) &&
174
- (params.edits.length === 0 ||
175
- (typeof params.edits[0] === "object" && params.edits[0] !== null && "loc" in params.edits[0]))
176
- );
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;
177
176
  }
178
177
 
179
178
  function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
180
179
  return edits.map(resolveEditAnchor);
181
180
  }
182
181
 
182
+ type HashlineEditInput = HashlineToolEdit | HashlineEdit;
183
+
184
+ function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[] {
185
+ return edits.map((edit, editIndex) => {
186
+ if (!edit || typeof edit !== "object") {
187
+ throw new Error(`Invalid hashline edit at index ${editIndex}: expected object.`);
188
+ }
189
+
190
+ if ("op" in edit) {
191
+ return edit;
192
+ }
193
+
194
+ if ("loc" in edit) {
195
+ return resolveEditAnchor(edit);
196
+ }
197
+
198
+ throw new Error(`Invalid hashline edit at index ${editIndex}: expected op/loc payload.`);
199
+ });
200
+ }
201
+
183
202
  function tryParseTag(raw: string): Anchor | undefined {
184
203
  try {
185
204
  return parseTag(raw);
@@ -1126,7 +1145,7 @@ export function buildCompactHashlineDiffPreview(
1126
1145
  }
1127
1146
 
1128
1147
  export async function computeHashlineDiff(
1129
- input: { path: string; edits: HashlineEdit[]; move?: string },
1148
+ input: { path: string; edits: HashlineEditInput[]; move?: string },
1130
1149
  cwd: string,
1131
1150
  ): Promise<
1132
1151
  | {
@@ -1143,6 +1162,7 @@ export async function computeHashlineDiff(
1143
1162
  const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1144
1163
 
1145
1164
  try {
1165
+ const resolvedEdits = resolveHashlineEditsForDiff(edits);
1146
1166
  const file = Bun.file(absolutePath);
1147
1167
 
1148
1168
  if (movePath === absolutePath) {
@@ -1156,7 +1176,7 @@ export async function computeHashlineDiff(
1156
1176
 
1157
1177
  const { text: content } = stripBom(rawContent);
1158
1178
  const normalizedContent = normalizeToLF(content);
1159
- const result = applyHashlineEdits(normalizedContent, edits);
1179
+ const result = applyHashlineEdits(normalizedContent, resolvedEdits);
1160
1180
  if (normalizedContent === result.lines && !move) {
1161
1181
  return { error: `No changes would be made to ${path}. The edits produce identical content.` };
1162
1182
  }
@@ -1179,15 +1199,20 @@ async function readHashlineFileText(file: BunFile, path: string): Promise<string
1179
1199
  }
1180
1200
  }
1181
1201
 
1182
- export async function executeHashlineMode(
1183
- options: ExecuteHashlineModeOptions,
1202
+ export async function executeHashlineSingle(
1203
+ options: ExecuteHashlineSingleOptions,
1184
1204
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1185
- const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1186
- const { path, edits, delete: deleteFile, move } = params;
1205
+ const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1206
+
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
+ const contentEdits = edits.filter(e => e.loc != null);
1187
1212
 
1188
1213
  enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
1189
1214
 
1190
- if (path.endsWith(".ipynb") && edits?.length > 0) {
1215
+ if (path.endsWith(".ipynb") && contentEdits.length > 0) {
1191
1216
  throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1192
1217
  }
1193
1218
 
@@ -1199,7 +1224,7 @@ export async function executeHashlineMode(
1199
1224
 
1200
1225
  const sourceFile = Bun.file(absolutePath);
1201
1226
  const sourceExists = await sourceFile.exists();
1202
- const isMoveOnly = Boolean(resolvedMove) && edits.length === 0;
1227
+ const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0;
1203
1228
 
1204
1229
  if (deleteFile) {
1205
1230
  if (sourceExists) {
@@ -1239,7 +1264,7 @@ export async function executeHashlineMode(
1239
1264
 
1240
1265
  if (!sourceExists) {
1241
1266
  const lines: string[] = [];
1242
- for (const edit of edits) {
1267
+ for (const edit of contentEdits) {
1243
1268
  if (edit.loc === "append") {
1244
1269
  lines.push(...hashlineParseText(edit.content));
1245
1270
  } else if (edit.loc === "prepend") {
@@ -1261,7 +1286,7 @@ export async function executeHashlineMode(
1261
1286
  };
1262
1287
  }
1263
1288
 
1264
- const anchorEdits = resolveEditAnchors(edits);
1289
+ const anchorEdits = resolveEditAnchors(contentEdits);
1265
1290
  const rawContent = await sourceFile.text();
1266
1291
  assertEditableFileContent(rawContent, path);
1267
1292