@nghyane/arcane 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.25] - 2026-03-08
6
+
7
+ ### Added
8
+
9
+ - Propagate OAuth flag from auth storage to Anthropic client, replacing token-format heuristic
10
+
11
+ ## [0.1.24] - 2026-03-08
12
+
13
+ ### Changed
14
+
15
+ - Simplify hashline edit API to two operations: replace and insert
16
+ - Remove redundant comments from patch module
17
+
5
18
  ## [0.1.23] - 2026-03-08
6
19
 
7
20
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane",
4
- "version": "0.1.23",
4
+ "version": "0.1.25",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/nghyane/arcane",
7
7
  "author": "Can Bölük",
@@ -44,9 +44,9 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@mozilla/readability": "0.6.0",
47
- "@nghyane/arcane-stats": "^0.1.12",
48
- "@nghyane/arcane-agent": "^0.1.16",
49
- "@nghyane/arcane-ai": "^0.1.12",
47
+ "@nghyane/arcane-stats": "^0.1.14",
48
+ "@nghyane/arcane-agent": "^0.1.18",
49
+ "@nghyane/arcane-ai": "^0.1.14",
50
50
  "@nghyane/arcane-natives": "^0.1.11",
51
51
  "@nghyane/arcane-tui": "^0.1.15",
52
52
  "@nghyane/arcane-utils": "^0.1.8",
@@ -979,6 +979,10 @@ export class ModelRegistry {
979
979
  return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
980
980
  }
981
981
 
982
+ isOAuthProvider(provider: string): boolean {
983
+ return this.authStorage.hasOAuth(provider);
984
+ }
985
+
982
986
  async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
983
987
  if (this.#keylessProviders.has(provider)) {
984
988
  return kNoAuth;
@@ -114,7 +114,6 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
114
114
  }
115
115
  }
116
116
 
117
- // Detect indent character from actual content
118
117
  let indentChar = " ";
119
118
  for (const line of actualLines) {
120
119
  const ws = getLeadingWhitespace(line);
@@ -220,7 +219,6 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
220
219
  const w = (s2 - s1) / (t2 - t1);
221
220
  if (w > 0 && Number.isInteger(w)) {
222
221
  const b = s1 - t1 * w;
223
- // Validate all samples against this model
224
222
  let valid = true;
225
223
  for (const [t, s] of samples) {
226
224
  if (t * w + b !== s) {
@@ -308,7 +306,6 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
308
306
  const trimmed = newLine.trim();
309
307
  const matchingActualLines = contentToActualLines.get(trimmed);
310
308
 
311
- // Check if this is a context line (same trimmed content exists in actual)
312
309
  if (matchingActualLines && matchingActualLines.length > 0) {
313
310
  if (matchingActualLines.length === 1) {
314
311
  return matchingActualLines[0];
@@ -319,12 +316,10 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
319
316
  const usedCount = usedActualLines.get(trimmed) ?? 0;
320
317
  if (usedCount < matchingActualLines.length) {
321
318
  usedActualLines.set(trimmed, usedCount + 1);
322
- // Use actual file content directly for context lines
323
319
  return matchingActualLines[usedCount];
324
320
  }
325
321
  }
326
322
 
327
- // This is a new/added line - apply consistent delta if safe
328
323
  if (delta && delta !== 0) {
329
324
  const newIndent = countLeadingWhitespace(newLine);
330
325
  if (newIndent === patternMin) {
@@ -573,7 +568,6 @@ function findHierarchicalContext(
573
568
  lineHint: number | undefined,
574
569
  allowFuzzy: boolean,
575
570
  ): ContextLineResult {
576
- // Check for newline-separated hierarchical contexts (from nested @@ anchors)
577
571
  if (context.includes("\n")) {
578
572
  const parts = context
579
573
  .split("\n")
@@ -627,7 +621,6 @@ function findHierarchicalContext(
627
621
  return { index: undefined, confidence: 0 };
628
622
  }
629
623
 
630
- // Try literal context first
631
624
  const spaceParts = context.split(/\s+/).filter(p => p.length > 0);
632
625
  const hasSignatureChars = /[(){}[\]]/.test(context);
633
626
  if (!hasSignatureChars && spaceParts.length > 2) {
@@ -662,7 +655,6 @@ function findHierarchicalContext(
662
655
 
663
656
  const result = findContextLine(lines, context, startFrom, { allowFuzzy });
664
657
 
665
- // If line hint exists and result is ambiguous or missing, try from hint
666
658
  if ((result.index === undefined || (result.matchCount ?? 0) > 1) && lineHint !== undefined) {
667
659
  const hintStart = Math.max(0, lineHint - 1);
668
660
  const hintedResult = findContextLine(lines, context, hintStart, { allowFuzzy });
@@ -671,7 +663,6 @@ function findHierarchicalContext(
671
663
  }
672
664
  }
673
665
 
674
- // If found uniquely, return it
675
666
  if (result.index !== undefined && (result.matchCount ?? 0) <= 1) {
676
667
  return result;
677
668
  }
@@ -679,7 +670,6 @@ function findHierarchicalContext(
679
670
  return result;
680
671
  }
681
672
 
682
- // Try from beginning if not found from current position
683
673
  if (result.index === undefined && startFrom !== 0) {
684
674
  const fromStartResult = findContextLine(lines, context, 0, { allowFuzzy });
685
675
  if (fromStartResult.index !== undefined && (fromStartResult.matchCount ?? 0) <= 1) {
@@ -738,7 +728,6 @@ function findSequenceWithHint(
738
728
  eof: boolean,
739
729
  allowFuzzy: boolean,
740
730
  ): import("./types").SequenceSearchResult {
741
- // Prefer content-based search starting from currentIndex
742
731
  const primaryResult = seekSequence(lines, pattern, currentIndex, eof, { allowFuzzy });
743
732
  if (
744
733
  primaryResult.matchCount &&
@@ -758,7 +747,6 @@ function findSequenceWithHint(
758
747
  return primaryResult;
759
748
  }
760
749
 
761
- // Use line hint as a secondary bias only if needed
762
750
  if (hintIndex !== undefined && hintIndex !== currentIndex) {
763
751
  const hintedResult = seekSequence(lines, pattern, hintIndex, eof, { allowFuzzy });
764
752
  if (hintedResult.index !== undefined || (hintedResult.matchCount && hintedResult.matchCount > 1)) {
@@ -857,7 +845,6 @@ function applyCharacterMatch(
857
845
  }
858
846
  }
859
847
 
860
- // Check for multiple exact occurrences
861
848
  if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
862
849
  const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
863
850
  const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
@@ -886,7 +873,6 @@ function applyCharacterMatch(
886
873
  throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${oldText}`);
887
874
  }
888
875
 
889
- // Adjust indentation to match what was actually found
890
876
  const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText);
891
877
 
892
878
  const warnings: string[] = [];
@@ -898,7 +884,6 @@ function applyCharacterMatch(
898
884
  );
899
885
  }
900
886
 
901
- // Apply the replacement
902
887
  const before = normalizedContent.substring(0, matchOutcome.match.startIndex);
903
888
  const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length);
904
889
  return { content: before + adjustedNewText + after, warnings };
@@ -942,9 +927,7 @@ function computeReplacements(
942
927
  lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1));
943
928
  }
944
929
 
945
- // If hunk has a changeContext, find it and adjust lineIndex
946
930
  if (hunk.changeContext !== undefined) {
947
- // Use hierarchical context matching for nested @@ anchors and space-separated contexts
948
931
  const result = findHierarchicalContext(originalLines, hunk.changeContext, lineIndex, lineHint, allowFuzzy);
949
932
  const idx = result.index;
950
933
  contextIndex = idx;
@@ -995,7 +978,6 @@ function computeReplacements(
995
978
  }
996
979
 
997
980
  if (hunk.oldLines.length === 0) {
998
- // Pure addition - prefer changeContext position, then line hint, then end of file
999
981
  let insertionIdx: number;
1000
982
  if (hunk.changeContext !== undefined) {
1001
983
  // changeContext was processed above; lineIndex is set to the context line or after it
@@ -1030,7 +1012,6 @@ function computeReplacements(
1030
1012
  continue;
1031
1013
  }
1032
1014
 
1033
- // Try to find the old lines in the file
1034
1015
  let pattern = [...hunk.oldLines];
1035
1016
  const matchHint = getHunkHintIndex(hunk, lineIndex);
1036
1017
  let searchResult = findSequenceWithHint(
@@ -1043,7 +1024,6 @@ function computeReplacements(
1043
1024
  );
1044
1025
  let newSlice = [...hunk.newLines];
1045
1026
 
1046
- // Retry without trailing empty line if present
1047
1027
  if (searchResult.index === undefined && pattern.length > 0 && pattern[pattern.length - 1] === "") {
1048
1028
  pattern = pattern.slice(0, -1);
1049
1029
  if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
@@ -1165,7 +1145,6 @@ function computeReplacements(
1165
1145
  );
1166
1146
  }
1167
1147
 
1168
- // Reject if match is ambiguous (prefix/substring matching found multiple matches)
1169
1148
  if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
1170
1149
  const previews = formatSequenceMatchPreviews(
1171
1150
  originalLines,
@@ -1186,7 +1165,6 @@ function computeReplacements(
1186
1165
  if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
1187
1166
  const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
1188
1167
  if (secondMatch.index !== undefined) {
1189
- // Extract 3-line previews for each match
1190
1168
  const formatPreview = (startIdx: number) => {
1191
1169
  const contextLines = 2;
1192
1170
  const maxLineLength = 80;
@@ -1210,7 +1188,6 @@ function computeReplacements(
1210
1188
  }
1211
1189
  }
1212
1190
 
1213
- // Adjust indentation if needed (handles fuzzy matches where indentation differs)
1214
1191
  const actualMatchedLines = originalLines.slice(found, found + pattern.length);
1215
1192
 
1216
1193
  // Skip pure-context hunks (no +/- lines — oldLines === newLines).
@@ -1235,7 +1212,6 @@ function computeReplacements(
1235
1212
  lineIndex = found + pattern.length;
1236
1213
  }
1237
1214
 
1238
- // Sort by start index
1239
1215
  replacements.sort((a, b) => a.startIndex - b.startIndex);
1240
1216
 
1241
1217
  for (let i = 1; i < replacements.length; i++) {
@@ -1319,14 +1295,12 @@ function applyHunksToContent(
1319
1295
  const { replacements, warnings } = computeReplacements(originalLines, path, hunks, allowFuzzy);
1320
1296
  const newLines = applyReplacements(originalLines, replacements);
1321
1297
 
1322
- // Restore the trailing empty element if we stripped it
1323
1298
  if (strippedTrailingEmpty) {
1324
1299
  newLines.push("");
1325
1300
  }
1326
1301
 
1327
1302
  const content = newLines.join("\n");
1328
1303
 
1329
- // Preserve original trailing newline behavior
1330
1304
  if (hadFinalNewline && !content.endsWith("\n")) {
1331
1305
  return { content: `${content}\n`, warnings };
1332
1306
  }
@@ -1374,7 +1348,6 @@ async function applyNormalizedPatch(
1374
1348
  }
1375
1349
  }
1376
1350
 
1377
- // Handle CREATE operation
1378
1351
  if (input.op === "create") {
1379
1352
  if (!input.diff) {
1380
1353
  throw new ApplyPatchError("Create operation requires diff (file content)");
@@ -1400,7 +1373,6 @@ async function applyNormalizedPatch(
1400
1373
  };
1401
1374
  }
1402
1375
 
1403
- // Handle DELETE operation
1404
1376
  if (input.op === "delete") {
1405
1377
  if (!(await fs.exists(absolutePath))) {
1406
1378
  throw new ApplyPatchError(`File not found: ${input.path}`);
@@ -1420,7 +1392,6 @@ async function applyNormalizedPatch(
1420
1392
  };
1421
1393
  }
1422
1394
 
1423
- // Handle UPDATE operation
1424
1395
  if (!input.diff) {
1425
1396
  throw new ApplyPatchError("Update operation requires diff (hunks)");
1426
1397
  }
package/src/patch/diff.ts CHANGED
@@ -55,12 +55,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte
55
55
  }
56
56
 
57
57
  if (part.added || part.removed) {
58
- // Capture the first changed line (in the new file)
59
58
  if (firstChangedLine === undefined) {
60
59
  firstChangedLine = newLineNum;
61
60
  }
62
61
 
63
- // Show the change
64
62
  for (const line of raw) {
65
63
  if (part.added) {
66
64
  output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
@@ -72,7 +70,6 @@ export function generateDiffString(oldContent: string, newContent: string, conte
72
70
  }
73
71
  lastWasChange = true;
74
72
  } else {
75
- // Context lines - only show a few before/after changes
76
73
  const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
77
74
 
78
75
  if (lastWasChange || nextPartIsChange) {
@@ -81,18 +78,15 @@ export function generateDiffString(oldContent: string, newContent: string, conte
81
78
  let skipEnd = 0;
82
79
 
83
80
  if (!lastWasChange) {
84
- // Show only last N lines as leading context
85
81
  skipStart = Math.max(0, raw.length - contextLines);
86
82
  linesToShow = raw.slice(skipStart);
87
83
  }
88
84
 
89
85
  if (!nextPartIsChange && linesToShow.length > contextLines) {
90
- // Show only first N lines as trailing context
91
86
  skipEnd = linesToShow.length - contextLines;
92
87
  linesToShow = linesToShow.slice(0, contextLines);
93
88
  }
94
89
 
95
- // Add ellipsis if we skipped lines at start
96
90
  if (skipStart > 0) {
97
91
  output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
98
92
  oldLineNum += skipStart;
@@ -105,14 +99,12 @@ export function generateDiffString(oldContent: string, newContent: string, conte
105
99
  newLineNum++;
106
100
  }
107
101
 
108
- // Add ellipsis if we skipped lines at end
109
102
  if (skipEnd > 0) {
110
103
  output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
111
104
  oldLineNum += skipEnd;
112
105
  newLineNum += skipEnd;
113
106
  }
114
107
  } else {
115
- // Skip these context lines entirely
116
108
  oldLineNum += raw.length;
117
109
  newLineNum += raw.length;
118
110
  }
@@ -198,7 +190,6 @@ export function replaceText(content: string, oldText: string, newText: string, o
198
190
  let count = 0;
199
191
 
200
192
  if (options.all) {
201
- // Check for exact matches first
202
193
  const exactCount = normalizedContent.split(normalizedOldText).length - 1;
203
194
  if (exactCount > 0) {
204
195
  return {
@@ -207,7 +198,6 @@ export function replaceText(content: string, oldText: string, newText: string, o
207
198
  };
208
199
  }
209
200
 
210
- // No exact matches - try fuzzy matching iteratively
211
201
  while (true) {
212
202
  const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
213
203
  allowFuzzy: options.fuzzy,
@@ -238,7 +228,6 @@ export function replaceText(content: string, oldText: string, newText: string, o
238
228
  return { content: normalizedContent, count };
239
229
  }
240
230
 
241
- // Single replacement mode
242
231
  const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
243
232
  allowFuzzy: options.fuzzy,
244
233
  threshold,
@@ -319,7 +308,6 @@ export async function computeEditDiff(
319
308
  });
320
309
 
321
310
  if (result.count === 0) {
322
- // Get closest match for error message
323
311
  const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
324
312
  allowFuzzy: fuzzy,
325
313
  threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
@@ -35,7 +35,6 @@ import {
35
35
  type HashlineEdit,
36
36
  type LineTag,
37
37
  parseTag,
38
- type ReplaceTextEdit,
39
38
  } from "./hashline";
40
39
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
41
40
  import {
@@ -44,7 +43,6 @@ import {
44
43
  type HashlineParams,
45
44
  hashlineEditSchema,
46
45
  hashlineParseContent,
47
- hashlineParseContentString,
48
46
  normalizeEditMode,
49
47
  type PatchParams,
50
48
  patchEditSchema,
@@ -268,100 +266,32 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
268
266
  }
269
267
 
270
268
  if (!(await file.exists())) {
271
- const content: string[] = [];
272
- for (const edit of edits) {
273
- switch (edit.op) {
274
- case "append": {
275
- if (edit.after) {
276
- throw new Error(`File not found: ${path}`);
277
- }
278
- content.push(...hashlineParseContent(edit.content));
279
- break;
280
- }
281
- case "prepend": {
282
- if (edit.before) {
283
- throw new Error(`File not found: ${path}`);
284
- }
285
- content.unshift(...hashlineParseContent(edit.content));
286
- break;
287
- }
288
- default: {
289
- throw new Error(`File not found: ${path}`);
290
- }
291
- }
292
- }
293
- await file.write(content.join("\n"));
294
- return {
295
- content: [{ type: "text", text: `Created ${path}` }],
296
- details: {
297
- diff: "",
298
- op: "create",
299
- meta: outputMeta().get(),
300
- },
301
- };
269
+ throw new Error(`File not found: ${path}`);
302
270
  }
303
271
 
304
272
  const anchorEdits: HashlineEdit[] = [];
305
- const replaceEdits: ReplaceTextEdit[] = [];
306
273
  for (const edit of edits) {
307
274
  switch (edit.op) {
308
- case "set": {
309
- const { tag, content } = edit;
310
- anchorEdits.push({
311
- op: "set",
312
- tag: parseTag(tag),
313
- content: hashlineParseContent(content),
314
- });
315
- break;
316
- }
317
- case "replace_range": {
318
- const { first, last, content } = edit;
275
+ case "replace": {
276
+ const { target, end, content } = edit;
319
277
  anchorEdits.push({
320
- op: "replace_range",
321
- first: parseTag(first),
322
- last: parseTag(last),
323
- content: hashlineParseContent(content),
324
- });
325
- break;
326
- }
327
- case "append": {
328
- const { after, content } = edit;
329
- anchorEdits.push({
330
- op: "append",
331
- ...(after ? { after: parseTag(after) } : {}),
332
- content: hashlineParseContent(content),
333
- });
334
- break;
335
- }
336
- case "prepend": {
337
- const { before, content } = edit;
338
- anchorEdits.push({
339
- op: "prepend",
340
- ...(before ? { before: parseTag(before) } : {}),
278
+ op: "replace",
279
+ target: parseTag(target),
280
+ ...(end ? { end: parseTag(end) } : {}),
341
281
  content: hashlineParseContent(content),
342
282
  });
343
283
  break;
344
284
  }
345
285
  case "insert": {
346
- const { before, after, content } = edit;
286
+ const { target, position, content } = edit;
347
287
  anchorEdits.push({
348
288
  op: "insert",
349
- before: parseTag(before),
350
- after: parseTag(after),
289
+ target: parseTag(target),
290
+ position,
351
291
  content: hashlineParseContent(content),
352
292
  });
353
293
  break;
354
294
  }
355
- case "replaceText": {
356
- const { old_text, new_text, all } = edit;
357
- replaceEdits.push({
358
- op: "replaceText",
359
- old_text: old_text,
360
- new_text: hashlineParseContentString(new_text),
361
- all: all ?? false,
362
- });
363
- break;
364
- }
365
295
  default:
366
296
  throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
367
297
  }
@@ -371,31 +301,8 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
371
301
  const { bom, text: content } = stripBom(rawContent);
372
302
  const originalEnding = detectLineEnding(content);
373
303
  const originalNormalized = normalizeToLF(content);
374
- let normalizedContent = originalNormalized;
375
304
 
376
- // Apply anchor-based edits first (set, set_range, insert)
377
- const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
378
- normalizedContent = anchorResult.content;
379
-
380
- // Apply content-replace edits (substr-style fuzzy replace)
381
- for (const r of replaceEdits) {
382
- if (r.old_text.length === 0) {
383
- throw new Error("old_text must not be empty.");
384
- }
385
- const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
386
- fuzzy: this.#allowFuzzy,
387
- all: r.all ?? false,
388
- threshold: this.#fuzzyThreshold,
389
- });
390
- normalizedContent = rep.content;
391
- }
392
-
393
- const result = {
394
- content: normalizedContent,
395
- firstChangedLine: anchorResult.firstChangedLine,
396
- warnings: anchorResult.warnings,
397
- noopEdits: anchorResult.noopEdits,
398
- };
305
+ const result = applyHashlineEdits(originalNormalized, anchorEdits);
399
306
  if (originalNormalized === result.content && !rename) {
400
307
  let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
401
308
  if (result.noopEdits && result.noopEdits.length > 0) {
@@ -416,22 +323,12 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
416
323
  for (const edit of anchorEdits) {
417
324
  refs.length = 0;
418
325
  switch (edit.op) {
419
- case "set":
420
- refs.push(edit.tag);
421
- break;
422
- case "replace_range":
423
- refs.push(edit.first, edit.last);
424
- break;
425
- case "append":
426
- if (edit.after) refs.push(edit.after);
427
- break;
428
- case "prepend":
429
- if (edit.before) refs.push(edit.before);
326
+ case "replace":
327
+ refs.push(edit.target);
328
+ if (edit.end) refs.push(edit.end);
430
329
  break;
431
330
  case "insert":
432
- refs.push(edit.after, edit.before);
433
- break;
434
- default:
331
+ refs.push(edit.target);
435
332
  break;
436
333
  }
437
334
 
@@ -544,7 +441,6 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
544
441
  }
545
442
  const effRename = result.change.newPath ? rename : undefined;
546
443
 
547
- // Generate diff for display
548
444
  let diffResult = {
549
445
  diff: "",
550
446
  firstChangedLine: undefined as number | undefined,
@@ -627,7 +523,6 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
627
523
  });
628
524
 
629
525
  if (result.count === 0) {
630
- // Get error details
631
526
  const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
632
527
  allowFuzzy: this.#allowFuzzy,
633
528
  threshold: this.#fuzzyThreshold,
@@ -220,7 +220,6 @@ export function findMatch(
220
220
  return {};
221
221
  }
222
222
 
223
- // Try exact match first
224
223
  const exactIndex = content.indexOf(target);
225
224
  if (exactIndex !== -1) {
226
225
  const occurrences = content.split(target).length - 1;
@@ -260,7 +259,6 @@ export function findMatch(
260
259
  };
261
260
  }
262
261
 
263
- // Try fuzzy match
264
262
  const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
265
263
  const { best, aboveThresholdCount, secondBestScore } = findBestFuzzyMatch(content, target, threshold);
266
264
 
@@ -384,7 +382,6 @@ export function seekSequence(
384
382
  return { index: undefined, confidence: 0 };
385
383
  }
386
384
 
387
- // Determine search start position
388
385
  const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
389
386
  const maxStart = lines.length - pattern.length;
390
387
 
@@ -547,7 +544,6 @@ export function seekSequence(
547
544
  });
548
545
 
549
546
  if (matchOutcome.match) {
550
- // Convert character index back to line index
551
547
  const matchedContent = contentText.substring(0, matchOutcome.match.startIndex);
552
548
  const lineIndex = start + matchedContent.split("\n").length - 1;
553
549
  const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1;
@@ -16,13 +16,8 @@ import type { HashMismatch } from "./types";
16
16
 
17
17
  export type LineTag = { line: number; hash: string };
18
18
  export type HashlineEdit =
19
- | { op: "set"; tag: LineTag; content: string[] }
20
- | { op: "replace_range"; first: LineTag; last: LineTag; content: string[] }
21
- | { op: "append"; after?: LineTag; content: string[] }
22
- | { op: "prepend"; before?: LineTag; content: string[] }
23
- | { op: "insert"; after: LineTag; before: LineTag; content: string[] };
24
- export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
25
- export type EditSpec = HashlineEdit | ReplaceTextEdit;
19
+ | { op: "replace"; target: LineTag; end?: LineTag; content: string[] }
20
+ | { op: "insert"; target: LineTag; position: "before" | "after"; content: string[] };
26
21
 
27
22
  /**
28
23
  * Compare two strings ignoring all whitespace differences.
@@ -31,9 +26,7 @@ export type EditSpec = HashlineEdit | ReplaceTextEdit;
31
26
  * the only differences are in spaces, tabs, or other whitespace.
32
27
  */
33
28
  function equalsIgnoringWhitespace(a: string, b: string): boolean {
34
- // Fast path: identical strings
35
29
  if (a === b) return true;
36
- // Compare with all whitespace removed
37
30
  return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
38
31
  }
39
32
 
@@ -140,17 +133,6 @@ function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): st
140
133
  return dstLines;
141
134
  }
142
135
 
143
- function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
144
- let out = dstLines;
145
- if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
146
- out = out.slice(1);
147
- }
148
- if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
149
- out = out.slice(0, -1);
150
- }
151
- return out;
152
- }
153
-
154
136
  function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
155
137
  // Only strip when the model replaced with multiple lines and grew the edit.
156
138
  // This avoids turning a single-line replacement into a deletion.
@@ -504,7 +486,6 @@ export class HashlineMismatchError extends Error {
504
486
  mismatchSet.set(m.line, m);
505
487
  }
506
488
 
507
- // Collect line ranges to display (mismatch lines + context)
508
489
  const displayLines = new Set<number>();
509
490
  for (const m of mismatches) {
510
491
  const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
@@ -630,7 +611,6 @@ export function applyHashlineEdits(
630
611
 
631
612
  const autocorrect = Bun.env.ARCANE_HL_AUTOCORRECT === "1";
632
613
 
633
- // Collect warnings and auto-correct edit content
634
614
  const warnings: string[] = [];
635
615
  for (const edit of edits) {
636
616
  const unicodeWarning = detectUnicodeEscapePlaceholders(edit.content);
@@ -644,25 +624,14 @@ export function applyHashlineEdits(
644
624
  const touched = new Set<number>();
645
625
  for (const edit of edits) {
646
626
  switch (edit.op) {
647
- case "set":
648
- touched.add(edit.tag.line);
649
- break;
650
- case "replace_range":
651
- for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
652
- break;
653
- case "append":
654
- if (edit.after) {
655
- touched.add(edit.after.line);
656
- }
657
- break;
658
- case "prepend":
659
- if (edit.before) {
660
- touched.add(edit.before.line);
627
+ case "replace":
628
+ touched.add(edit.target.line);
629
+ if (edit.end) {
630
+ for (let ln = edit.target.line; ln <= edit.end.line; ln++) touched.add(ln);
661
631
  }
662
632
  break;
663
633
  case "insert":
664
- touched.add(edit.after.line);
665
- touched.add(edit.before.line);
634
+ touched.add(edit.target.line);
666
635
  break;
667
636
  }
668
637
  }
@@ -685,44 +654,21 @@ export function applyHashlineEdits(
685
654
  }
686
655
  for (const edit of edits) {
687
656
  switch (edit.op) {
688
- case "set": {
689
- if (!validateRef(edit.tag)) continue;
690
- break;
691
- }
692
- case "append": {
693
- if (edit.content.length === 0) {
694
- throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
695
- }
696
- if (edit.after && !validateRef(edit.after)) continue;
697
- break;
698
- }
699
- case "prepend": {
700
- if (edit.content.length === 0) {
701
- throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
657
+ case "replace": {
658
+ if (!validateRef(edit.target)) continue;
659
+ if (edit.end) {
660
+ if (edit.target.line > edit.end.line) {
661
+ throw new Error(`Range start line ${edit.target.line} must be <= end line ${edit.end.line}`);
662
+ }
663
+ if (!validateRef(edit.end)) continue;
702
664
  }
703
- if (edit.before && !validateRef(edit.before)) continue;
704
665
  break;
705
666
  }
706
667
  case "insert": {
707
668
  if (edit.content.length === 0) {
708
- throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
709
- }
710
- if (edit.before.line <= edit.after.line) {
711
- throw new Error(`insert requires after (${edit.after.line}) < before (${edit.before.line})`);
669
+ throw new Error("Insert edit requires non-empty content");
712
670
  }
713
- const afterValid = validateRef(edit.after);
714
- const beforeValid = validateRef(edit.before);
715
- if (!afterValid || !beforeValid) continue;
716
- break;
717
- }
718
- case "replace_range": {
719
- if (edit.first.line > edit.last.line) {
720
- throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`);
721
- }
722
-
723
- const startValid = validateRef(edit.first);
724
- const endValid = validateRef(edit.last);
725
- if (!startValid || !endValid) continue;
671
+ if (!validateRef(edit.target)) continue;
726
672
  break;
727
673
  }
728
674
  }
@@ -730,35 +676,17 @@ export function applyHashlineEdits(
730
676
  if (mismatches.length > 0) {
731
677
  throw new HashlineMismatchError(mismatches, fileLines);
732
678
  }
733
- // Deduplicate identical edits targeting the same line(s)
734
679
  const seenEditKeys = new Map<string, number>();
735
680
  const dedupIndices = new Set<number>();
736
681
  for (let i = 0; i < edits.length; i++) {
737
682
  const edit = edits[i];
738
683
  let lineKey: string;
739
684
  switch (edit.op) {
740
- case "set":
741
- lineKey = `s:${edit.tag.line}`;
742
- break;
743
- case "replace_range":
744
- lineKey = `r:${edit.first.line}:${edit.last.line}`;
745
- break;
746
- case "append":
747
- if (edit.after) {
748
- lineKey = `i:${edit.after.line}`;
749
- break;
750
- }
751
- lineKey = "ieof";
752
- break;
753
- case "prepend":
754
- if (edit.before) {
755
- lineKey = `ib:${edit.before.line}`;
756
- break;
757
- }
758
- lineKey = "ibef";
685
+ case "replace":
686
+ lineKey = edit.end ? `r:${edit.target.line}:${edit.end.line}` : `s:${edit.target.line}`;
759
687
  break;
760
688
  case "insert":
761
- lineKey = `ix:${edit.after.line}:${edit.before.line}`;
689
+ lineKey = `i:${edit.target.line}:${edit.position}`;
762
690
  break;
763
691
  }
764
692
  const dstKey = `${lineKey}:${edit.content.join("\n")}`;
@@ -779,25 +707,13 @@ export function applyHashlineEdits(
779
707
  let sortLine: number;
780
708
  let precedence: number;
781
709
  switch (edit.op) {
782
- case "set":
783
- sortLine = edit.tag.line;
710
+ case "replace":
711
+ sortLine = edit.end ? edit.end.line : edit.target.line;
784
712
  precedence = 0;
785
713
  break;
786
- case "replace_range":
787
- sortLine = edit.last.line;
788
- precedence = 0;
789
- break;
790
- case "append":
791
- sortLine = edit.after ? edit.after.line : fileLines.length + 1;
792
- precedence = 1;
793
- break;
794
- case "prepend":
795
- sortLine = edit.before ? edit.before.line : 0;
796
- precedence = 2;
797
- break;
798
714
  case "insert":
799
- sortLine = edit.before.line;
800
- precedence = 3;
715
+ sortLine = edit.target.line;
716
+ precedence = edit.position === "before" ? 2 : 1;
801
717
  break;
802
718
  }
803
719
  return { edit, idx, sortLine, precedence };
@@ -805,147 +721,83 @@ export function applyHashlineEdits(
805
721
 
806
722
  annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
807
723
 
808
- // Apply edits bottom-up
809
724
  for (const { edit, idx } of annotated) {
810
725
  switch (edit.op) {
811
- case "set": {
812
- const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null;
813
- if (merged) {
814
- const origLines = originalFileLines.slice(
815
- merged.startLine - 1,
816
- merged.startLine - 1 + merged.deleteCount,
817
- );
818
- let nextLines = merged.newLines;
819
- nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
820
-
821
- if (origLines.length === nextLines.length && origLines.every((line, i) => line === nextLines[i])) {
822
- noopEdits.push({
823
- editIndex: idx,
824
- loc: `${edit.tag.line}#${edit.tag.hash}`,
825
- currentContent: origLines.join("\n"),
826
- });
726
+ case "replace": {
727
+ const startLine = edit.target.line;
728
+ const endLine = edit.end ? edit.end.line : edit.target.line;
729
+ const count = endLine - startLine + 1;
730
+
731
+ if (!edit.end) {
732
+ const merged = autocorrect ? maybeExpandSingleLineMerge(startLine, edit.content) : null;
733
+ if (merged) {
734
+ const origLines = originalFileLines.slice(
735
+ merged.startLine - 1,
736
+ merged.startLine - 1 + merged.deleteCount,
737
+ );
738
+ let nextLines = merged.newLines;
739
+ nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
740
+
741
+ if (origLines.length === nextLines.length && origLines.every((line, i) => line === nextLines[i])) {
742
+ noopEdits.push({
743
+ editIndex: idx,
744
+ loc: `${edit.target.line}#${edit.target.hash}`,
745
+ currentContent: origLines.join("\n"),
746
+ });
747
+ break;
748
+ }
749
+ fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
750
+ trackFirstChanged(merged.startLine);
827
751
  break;
828
752
  }
829
- fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
830
- trackFirstChanged(merged.startLine);
831
- break;
832
753
  }
833
754
 
834
- const count = 1;
835
- const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
755
+ const origLines = originalFileLines.slice(startLine - 1, startLine - 1 + count);
836
756
  let stripped = autocorrect
837
- ? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
757
+ ? stripRangeBoundaryEcho(originalFileLines, startLine, endLine, edit.content)
838
758
  : edit.content;
839
759
  stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
840
760
  const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
841
761
  if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
842
762
  noopEdits.push({
843
763
  editIndex: idx,
844
- loc: `${edit.tag.line}#${edit.tag.hash}`,
764
+ loc: `${edit.target.line}#${edit.target.hash}`,
845
765
  currentContent: origLines.join("\n"),
846
766
  });
847
767
  break;
848
768
  }
849
- fileLines.splice(edit.tag.line - 1, count, ...newLines);
850
- trackFirstChanged(edit.tag.line);
769
+ fileLines.splice(startLine - 1, count, ...newLines);
770
+ trackFirstChanged(startLine);
851
771
  break;
852
772
  }
853
- case "replace_range": {
854
- const count = edit.last.line - edit.first.line + 1;
855
- const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count);
856
- let stripped = autocorrect
857
- ? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
858
- : edit.content;
859
- stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
860
- const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
861
- if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
862
- noopEdits.push({
863
- editIndex: idx,
864
- loc: `${edit.first.line}#${edit.first.hash}`,
865
- currentContent: origLines.join("\n"),
866
- });
867
- break;
868
- }
869
- fileLines.splice(edit.first.line - 1, count, ...newLines);
870
- trackFirstChanged(edit.first.line);
871
- break;
872
- }
873
- case "append": {
874
- const inserted = edit.after
875
- ? autocorrect
876
- ? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
877
- : edit.content
878
- : edit.content;
879
- if (inserted.length === 0) {
880
- noopEdits.push({
881
- editIndex: idx,
882
- loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF",
883
- currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "",
884
- });
885
- break;
886
- }
887
- if (edit.after) {
888
- fileLines.splice(edit.after.line, 0, ...inserted);
889
- trackFirstChanged(edit.after.line + 1);
890
- } else {
891
- if (fileLines.length === 1 && fileLines[0] === "") {
892
- fileLines.splice(0, 1, ...inserted);
893
- trackFirstChanged(1);
894
- } else {
895
- fileLines.splice(fileLines.length, 0, ...inserted);
896
- trackFirstChanged(fileLines.length - inserted.length + 1);
897
- }
898
- }
899
- break;
900
- }
901
- case "prepend": {
902
- const inserted = edit.before
903
- ? autocorrect
904
- ? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
905
- : edit.content
773
+ case "insert": {
774
+ const anchorLine = originalFileLines[edit.target.line - 1];
775
+ const inserted = autocorrect
776
+ ? edit.position === "after"
777
+ ? stripInsertAnchorEchoAfter(anchorLine, edit.content)
778
+ : stripInsertAnchorEchoBefore(anchorLine, edit.content)
906
779
  : edit.content;
907
780
  if (inserted.length === 0) {
908
781
  noopEdits.push({
909
782
  editIndex: idx,
910
- loc: edit.before ? `${edit.before.line}#${edit.before.hash}` : "BOF",
911
- currentContent: edit.before ? originalFileLines[edit.before.line - 1] : "",
783
+ loc: `${edit.target.line}#${edit.target.hash}`,
784
+ currentContent: anchorLine,
912
785
  });
913
786
  break;
914
787
  }
915
- if (edit.before) {
916
- fileLines.splice(edit.before.line - 1, 0, ...inserted);
917
- trackFirstChanged(edit.before.line);
788
+ if (edit.position === "after") {
789
+ fileLines.splice(edit.target.line, 0, ...inserted);
790
+ trackFirstChanged(edit.target.line + 1);
918
791
  } else {
919
- if (fileLines.length === 1 && fileLines[0] === "") {
920
- fileLines.splice(0, 1, ...inserted);
921
- } else {
922
- fileLines.splice(0, 0, ...inserted);
923
- }
924
- trackFirstChanged(1);
925
- }
926
- break;
927
- }
928
- case "insert": {
929
- const afterLine = originalFileLines[edit.after.line - 1];
930
- const beforeLine = originalFileLines[edit.before.line - 1];
931
- const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
932
- if (inserted.length === 0) {
933
- noopEdits.push({
934
- editIndex: idx,
935
- loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.before.hash}`,
936
- currentContent: `${afterLine}\n${beforeLine}`,
937
- });
938
- break;
792
+ fileLines.splice(edit.target.line - 1, 0, ...inserted);
793
+ trackFirstChanged(edit.target.line);
939
794
  }
940
- fileLines.splice(edit.before.line - 1, 0, ...inserted);
941
- trackFirstChanged(edit.before.line);
942
795
  break;
943
796
  }
944
797
  }
945
798
  }
946
799
 
947
800
  let finalContent = fileLines.join("\n");
948
- // Preserve trailing newline behavior of original content
949
801
  if (hadFinalNewline && !finalContent.endsWith("\n")) {
950
802
  finalContent += "\n";
951
803
  } else if (!hadFinalNewline && finalContent.endsWith("\n")) {
@@ -1063,7 +915,6 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption
1063
915
 
1064
916
  const inputLines = diff.split("\n");
1065
917
 
1066
- // Single-pass: group consecutive lines by kind into run spans
1067
918
  type Kind = " " | "+" | "-" | "meta";
1068
919
  const runs: { kind: Kind; start: number; end: number }[] = [];
1069
920
  for (let i = 0; i < inputLines.length; i++) {
@@ -228,7 +228,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
228
228
  const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
229
229
  const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
230
230
 
231
- // Check for context marker
232
231
  if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
233
232
  startIndex = 1;
234
233
  } else if (unifiedHeader) {
@@ -280,7 +279,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
280
279
  throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
281
280
  }
282
281
 
283
- // Check for nested @@ anchors on subsequent lines
284
282
  // Format: @@ class Foo
285
283
  // @@ method
286
284
  while (startIndex < lines.length) {
@@ -290,7 +288,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
290
288
  }
291
289
  const trimmed = nextLine.trimEnd();
292
290
 
293
- // Check if it's another @@ line (nested anchor)
294
291
  if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
295
292
  const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
296
293
  if (nestedContext.trim().length > 0) {
@@ -301,7 +298,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
301
298
  // Empty @@ as separator - skip it
302
299
  startIndex++;
303
300
  } else {
304
- // Not an @@ line, stop accumulating
305
301
  break;
306
302
  }
307
303
  }
@@ -505,7 +501,6 @@ export function parseHunks(diff: string): DiffHunk[] {
505
501
  const line = lines[i];
506
502
  const trimmed = line.trim();
507
503
 
508
- // Skip blank lines between hunks
509
504
  if (trimmed === "") {
510
505
  i++;
511
506
  continue;
@@ -104,73 +104,36 @@ export function hashlineParseContentString(edit: string | string[] | null): stri
104
104
  return edit;
105
105
  }
106
106
 
107
- const hashlineTargetEditSchema = Type.Object(
107
+ const hashlineReplaceOpSchema = Type.Object(
108
108
  {
109
- op: Type.Literal("set"),
110
- tag: hashlineTagFormat("line being replaced"),
109
+ op: Type.Literal("replace"),
110
+ target: hashlineTagFormat("line to replace (or start of range)"),
111
+ end: Type.Optional(hashlineTagFormat("last line of range")),
111
112
  content: hashlineReplaceContentFormat("Replacement"),
112
113
  },
113
114
  { additionalProperties: false },
114
115
  );
115
116
 
116
- const hashlineAppendEditSchema = Type.Object(
117
- {
118
- op: Type.Literal("append"),
119
- after: Type.Optional(hashlineTagFormat("line after which to append")),
120
- content: hashlineInsertContentFormat("Appended"),
121
- },
122
- { additionalProperties: false },
123
- );
124
-
125
- const hashlinePrependEditSchema = Type.Object(
126
- {
127
- op: Type.Literal("prepend"),
128
- before: Type.Optional(hashlineTagFormat("line before which to prepend")),
129
- content: hashlineInsertContentFormat("Prepended"),
130
- },
131
- { additionalProperties: false },
132
- );
133
-
134
- const hashlineRangeEditSchema = Type.Object(
135
- {
136
- op: Type.Literal("replace_range"),
137
- first: hashlineTagFormat("first line"),
138
- last: hashlineTagFormat("last line"),
139
- content: hashlineReplaceContentFormat("Replacement"),
140
- },
141
- { additionalProperties: false },
142
- );
143
-
144
- const hashlineInsertEditSchema = Type.Object(
117
+ const hashlineInsertOpSchema = Type.Object(
145
118
  {
146
119
  op: Type.Literal("insert"),
147
- before: hashlineTagFormat("line before which to insert"),
148
- after: hashlineTagFormat("line after which to insert"),
120
+ target: hashlineTagFormat("anchor line"),
121
+ position: StringEnum(["before", "after"], { description: "Insert before or after the anchor" }),
149
122
  content: hashlineInsertContentFormat("Inserted"),
150
123
  },
151
124
  { additionalProperties: false },
152
125
  );
153
126
 
154
- const hashlineReplaceTextEditSchema = Type.Object(
155
- {
156
- op: Type.Literal("replaceText"),
157
- old_text: Type.String({ description: "Text to find", minLength: 1 }),
158
- new_text: hashlineReplaceContentFormat("Replacement"),
159
- all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
160
- },
161
- { additionalProperties: false },
162
- );
163
-
164
- const HL_REPLACE_ENABLED = Bun.env.ARCANE_HL_REPLACETXT === "1";
127
+ const hashlineEditSpecUnion = Type.Union([hashlineReplaceOpSchema, hashlineInsertOpSchema], {
128
+ discriminator: { propertyName: "op" },
129
+ });
165
130
 
166
- export const hashlineEditSpecSchema = Type.Union([
167
- hashlineTargetEditSchema,
168
- hashlineRangeEditSchema,
169
- hashlineAppendEditSchema,
170
- hashlinePrependEditSchema,
171
- hashlineInsertEditSchema,
172
- ...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
173
- ]);
131
+ // AJV discriminator requires `oneOf`, but TypeBox emits `anyOf`.
132
+ // Swap to `oneOf` so AJV validates only the matching sub-schema.
133
+ export const hashlineEditSpecSchema = (() => {
134
+ const { anyOf, ...rest } = hashlineEditSpecUnion;
135
+ return { ...rest, oneOf: anyOf } as unknown as typeof hashlineEditSpecUnion;
136
+ })();
174
137
 
175
138
  export const hashlineEditSchema = Type.Object(
176
139
  {
@@ -73,7 +73,6 @@ interface EditRenderArgs {
73
73
  newText?: string;
74
74
  patch?: string;
75
75
  all?: boolean;
76
- // Patch mode fields
77
76
  op?: Operation;
78
77
  rename?: string;
79
78
  diff?: string;
@@ -81,15 +80,12 @@ interface EditRenderArgs {
81
80
  * Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
82
81
  */
83
82
  previewDiff?: string;
84
- // Hashline mode fields
85
83
  edits?: HashlineEditPreview[];
86
84
  }
87
85
 
88
86
  type HashlineEditPreview =
89
- | { target: string; new_content: string[] }
90
- | { first: string; last: string; new_content: string[] }
91
- | { before?: string; after?: string; inserted_lines: string[] }
92
- | { old_text: string; new_text: string; all?: boolean };
87
+ | { op: "replace"; target: string; end?: string; content: string[] | string | null }
88
+ | { op: "insert"; target: string; position: "before" | "after"; content: string[] | string };
93
89
 
94
90
  /** Extended context for edit tool rendering */
95
91
  export interface EditRenderContext {
@@ -163,39 +159,29 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme): string
163
159
  dst: "",
164
160
  };
165
161
  }
166
- if ("target" in editRecord) {
167
- const target = typeof editRecord.target === "string" ? editRecord.target : "…";
168
- const newContent = editRecord.new_content;
162
+ const op = editRecord.op;
163
+ const target = typeof editRecord.target === "string" ? editRecord.target : "…";
164
+ const content = editRecord.content;
165
+ const contentStr =
166
+ content === null
167
+ ? ""
168
+ : Array.isArray(content)
169
+ ? (content as string[]).join("\n")
170
+ : typeof content === "string"
171
+ ? content
172
+ : "";
173
+ if (op === "replace") {
174
+ const end = typeof editRecord.end === "string" ? editRecord.end : undefined;
169
175
  return {
170
- srcLabel: `• line ${target}`,
171
- dst: Array.isArray(newContent) ? (newContent as string[]).join("\n") : "",
176
+ srcLabel: end ? `• range ${target}..${end}` : `• line ${target}`,
177
+ dst: contentStr,
172
178
  };
173
179
  }
174
- if ("first" in editRecord || "last" in editRecord) {
175
- const first = typeof editRecord.first === "string" ? editRecord.first : "";
176
- const last = typeof editRecord.last === "string" ? editRecord.last : "…";
177
- const newContent = editRecord.new_content;
180
+ if (op === "insert") {
181
+ const position = typeof editRecord.position === "string" ? editRecord.position : "after";
178
182
  return {
179
- srcLabel: `• range ${first}..${last}`,
180
- dst: Array.isArray(newContent) ? (newContent as string[]).join("\n") : "",
181
- };
182
- }
183
- if ("old_text" in editRecord || "new_text" in editRecord) {
184
- const all = typeof editRecord.all === "boolean" ? editRecord.all : false;
185
- return {
186
- srcLabel: `• replace old_text→new_text${all ? " (all)" : ""}`,
187
- dst: typeof editRecord.new_text === "string" ? editRecord.new_text : "",
188
- };
189
- }
190
- if ("inserted_lines" in editRecord || "before" in editRecord || "after" in editRecord) {
191
- const after = typeof editRecord.after === "string" ? editRecord.after : undefined;
192
- const before = typeof editRecord.before === "string" ? editRecord.before : undefined;
193
- const insertedLines = editRecord.inserted_lines;
194
- const text = Array.isArray(insertedLines) ? (insertedLines as string[]).join("\n") : "";
195
- const refs = [after, before].filter(Boolean).join("..") || "…";
196
- return {
197
- srcLabel: `• insert ${refs}`,
198
- dst: text,
183
+ srcLabel: `• insert ${position} ${target}`,
184
+ dst: contentStr,
199
185
  };
200
186
  }
201
187
  return {
@@ -217,19 +203,16 @@ export const editToolRenderer = {
217
203
  const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
218
204
  let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
219
205
 
220
- // Add arrow for move/rename operations
221
206
  if (args.rename) {
222
207
  pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(args.rename))}`;
223
208
  }
224
209
 
225
- // Show operation type for patch mode
226
210
  const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit";
227
211
  const spinner =
228
212
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
229
213
  const title = uiTheme.fg("toolTitle", uiTheme.bold(opTitle));
230
214
  let text = `${title} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`;
231
215
 
232
- // Show streaming preview of diff/content
233
216
  const previewDiffText =
234
217
  args.previewDiff ??
235
218
  (options.renderContext?.editDiffPreview && "diff" in options.renderContext.editDiffPreview
@@ -282,7 +265,6 @@ export const editToolRenderer = {
282
265
  return new Text(`${header}\n${uiTheme.fg("error", replaceTabs(errorText))}`, 0, 0);
283
266
  }
284
267
 
285
- // Get diff text from result or preview
286
268
  const { renderContext } = options;
287
269
  const editDiffPreview = renderContext?.editDiffPreview;
288
270
  const diffText =
@@ -292,7 +274,6 @@ export const editToolRenderer = {
292
274
 
293
275
  const diffStats = diffText ? getDiffStats(diffText) : { added: 0, removed: 0, hunks: 0, lines: 0 };
294
276
 
295
- // Build header with diff stats
296
277
  let description = filePath || "file";
297
278
  if (rename) description += ` ${uiTheme.fg("dim", "→")} ${shortenPath(rename)}`;
298
279
  const meta: string[] = [];
@@ -302,7 +283,6 @@ export const editToolRenderer = {
302
283
 
303
284
  const header = renderStatusLine({ icon: "success", title: opTitle, description, meta }, uiTheme);
304
285
 
305
- // Tree-style diff body
306
286
  const expanded = options.expanded;
307
287
  const diffLines = diffText ? diffText.split("\n") : [];
308
288
  const maxLines = expanded ? diffLines.length : Math.min(diffLines.length, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
@@ -318,7 +298,6 @@ export const editToolRenderer = {
318
298
  treeBody.push(`${uiTheme.fg("dim", `… ${remaining} more lines`)} ${formatClickHint(uiTheme)}`);
319
299
  }
320
300
 
321
- // Diagnostics
322
301
  if (result.details?.diagnostics) {
323
302
  const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp: string) =>
324
303
  uiTheme.getLangIcon(getLanguageFromPath(fp)),
package/src/sdk.ts CHANGED
@@ -1164,7 +1164,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1164
1164
  if (!key) {
1165
1165
  throw new Error(`No API key found for provider "${provider}"`);
1166
1166
  }
1167
- return key;
1167
+ return { key, isOAuth: modelRegistry.isOAuthProvider(provider) };
1168
1168
  },
1169
1169
  cursorExecHandlers,
1170
1170
  transformToolCallArguments: obfuscator?.hasSecrets() ? args => obfuscator!.deobfuscateObject(args) : undefined,