@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.3

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 (85) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +423 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/export/html/template.css +43 -13
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.html +1 -0
  27. package/src/export/html/template.js +107 -0
  28. package/src/extensibility/extensions/types.ts +31 -8
  29. package/src/internal-urls/docs-index.generated.ts +1 -1
  30. package/src/lsp/index.ts +1 -1
  31. package/src/main.ts +44 -44
  32. package/src/mcp/oauth-discovery.ts +1 -1
  33. package/src/modes/acp/acp-agent.ts +957 -0
  34. package/src/modes/acp/acp-event-mapper.ts +531 -0
  35. package/src/modes/acp/acp-mode.ts +13 -0
  36. package/src/modes/acp/index.ts +2 -0
  37. package/src/modes/components/agent-dashboard.ts +5 -4
  38. package/src/modes/components/custom-editor.ts +53 -51
  39. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  40. package/src/modes/components/history-search.ts +2 -1
  41. package/src/modes/components/hook-editor.ts +2 -1
  42. package/src/modes/components/hook-input.ts +8 -7
  43. package/src/modes/components/hook-selector.ts +15 -10
  44. package/src/modes/components/keybinding-hints.ts +9 -9
  45. package/src/modes/components/login-dialog.ts +3 -3
  46. package/src/modes/components/mcp-add-wizard.ts +2 -1
  47. package/src/modes/components/model-selector.ts +14 -3
  48. package/src/modes/components/oauth-selector.ts +2 -1
  49. package/src/modes/components/session-selector.ts +2 -1
  50. package/src/modes/components/settings-selector.ts +2 -1
  51. package/src/modes/components/status-line-segment-editor.ts +2 -1
  52. package/src/modes/components/tree-selector.ts +3 -2
  53. package/src/modes/components/user-message-selector.ts +3 -8
  54. package/src/modes/components/user-message.ts +16 -0
  55. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  56. package/src/modes/controllers/input-controller.ts +48 -29
  57. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  58. package/src/modes/index.ts +1 -0
  59. package/src/modes/interactive-mode.ts +17 -5
  60. package/src/modes/print-mode.ts +1 -1
  61. package/src/modes/prompt-action-autocomplete.ts +7 -7
  62. package/src/modes/rpc/rpc-mode.ts +7 -2
  63. package/src/modes/rpc/rpc-types.ts +1 -0
  64. package/src/modes/theme/theme.ts +53 -44
  65. package/src/modes/types.ts +9 -2
  66. package/src/modes/utils/hotkeys-markdown.ts +20 -20
  67. package/src/modes/utils/keybinding-matchers.ts +21 -0
  68. package/src/modes/utils/ui-helpers.ts +1 -1
  69. package/src/patch/hashline.ts +139 -127
  70. package/src/patch/index.ts +77 -59
  71. package/src/patch/shared.ts +19 -11
  72. package/src/prompts/tools/hashline.md +43 -116
  73. package/src/sdk.ts +34 -17
  74. package/src/session/agent-session.ts +436 -86
  75. package/src/session/messages.ts +23 -0
  76. package/src/session/session-manager.ts +97 -31
  77. package/src/tools/ask.ts +56 -30
  78. package/src/tools/bash-interceptor.ts +1 -39
  79. package/src/tools/bash-skill-urls.ts +1 -1
  80. package/src/tools/browser.ts +1 -1
  81. package/src/tools/gemini-image.ts +1 -1
  82. package/src/tools/resolve.ts +1 -1
  83. package/src/utils/child-process.ts +88 -0
  84. package/src/utils/image-input.ts +11 -1
  85. package/src/web/search/providers/codex.ts +10 -3
@@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined {
1679
1679
  var currentSymbolPresetOverride: SymbolPreset | undefined;
1680
1680
  var currentColorBlindMode: boolean = false;
1681
1681
  var themeWatcher: fs.FSWatcher | undefined;
1682
+ var themeReloadTimer: NodeJS.Timeout | undefined;
1682
1683
  var sigwinchHandler: (() => void) | undefined;
1683
1684
  var autoDetectedTheme: boolean = false;
1684
1685
  var autoDarkTheme: string = "dark";
@@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset {
1888
1889
  }
1889
1890
 
1890
1891
  async function startThemeWatcher(): Promise<void> {
1891
- // Stop existing watcher if any
1892
- if (themeWatcher) {
1893
- themeWatcher.close();
1894
- themeWatcher = undefined;
1895
- }
1892
+ stopThemeWatcher();
1896
1893
 
1897
1894
  // Only watch if it's a custom theme (not built-in)
1898
1895
  if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
@@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise<void> {
1900
1897
  }
1901
1898
 
1902
1899
  const customThemesDir = getCustomThemesDir();
1903
- const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
1900
+ const watchedThemeName = currentThemeName;
1901
+ const watchedFileName = `${watchedThemeName}.json`;
1902
+ const themeFile = path.join(customThemesDir, watchedFileName);
1904
1903
 
1905
1904
  // Only watch if the file exists
1906
1905
  if (!fs.existsSync(themeFile)) {
1907
1906
  return;
1908
1907
  }
1909
1908
 
1910
- try {
1911
- themeWatcher = fs.watch(themeFile, eventType => {
1912
- if (eventType === "change") {
1913
- // Debounce rapid changes
1914
- setTimeout(() => {
1915
- loadTheme(currentThemeName!, getCurrentThemeOptions())
1916
- .then(loadedTheme => {
1917
- theme = loadedTheme;
1918
- if (onThemeChangeCallback) {
1919
- onThemeChangeCallback();
1920
- }
1921
- })
1922
- .catch(err => {
1923
- logger.debug("Theme reload error during file change", { error: String(err) });
1924
- });
1925
- }, 100);
1926
- } else if (eventType === "rename") {
1927
- // File was deleted or renamed - fall back to default theme
1928
- setTimeout(() => {
1929
- if (!fs.existsSync(themeFile)) {
1930
- currentThemeName = "dark";
1931
- loadTheme("dark", getCurrentThemeOptions())
1932
- .then(loadedTheme => {
1933
- theme = loadedTheme;
1934
- if (onThemeChangeCallback) {
1935
- onThemeChangeCallback();
1936
- }
1937
- })
1938
- .catch(err => {
1939
- logger.debug("Theme reload error during rename fallback", { error: String(err) });
1940
- });
1941
- if (themeWatcher) {
1942
- themeWatcher.close();
1943
- themeWatcher = undefined;
1944
- }
1909
+ const scheduleReload = () => {
1910
+ if (themeReloadTimer) {
1911
+ clearTimeout(themeReloadTimer);
1912
+ }
1913
+ themeReloadTimer = setTimeout(() => {
1914
+ themeReloadTimer = undefined;
1915
+
1916
+ // Ignore stale timers after switching themes or stopping the watcher
1917
+ if (currentThemeName !== watchedThemeName) {
1918
+ return;
1919
+ }
1920
+
1921
+ // Keep the last successfully loaded theme active if the file is temporarily missing
1922
+ if (!fs.existsSync(themeFile)) {
1923
+ return;
1924
+ }
1925
+
1926
+ loadTheme(watchedThemeName, getCurrentThemeOptions())
1927
+ .then(loadedTheme => {
1928
+ theme = loadedTheme;
1929
+ if (onThemeChangeCallback) {
1930
+ onThemeChangeCallback();
1945
1931
  }
1946
- }, 100);
1932
+ })
1933
+ .catch(() => {
1934
+ // Ignore errors (file might be in invalid state while being edited)
1935
+ });
1936
+ }, 100);
1937
+ };
1938
+
1939
+ try {
1940
+ themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
1941
+ if (currentThemeName !== watchedThemeName) {
1942
+ return;
1947
1943
  }
1944
+ if (!filename) {
1945
+ scheduleReload();
1946
+ return;
1947
+ }
1948
+ const changedFile = String(filename);
1949
+ if (changedFile !== watchedFileName) {
1950
+ return;
1951
+ }
1952
+ scheduleReload();
1948
1953
  });
1949
- } catch (err) {
1950
- logger.debug("Failed to start theme watcher", { error: String(err) });
1954
+ } catch {
1955
+ // Ignore errors starting watcher
1951
1956
  }
1952
1957
  }
1953
1958
 
@@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void {
2023
2028
  }
2024
2029
 
2025
2030
  export function stopThemeWatcher(): void {
2031
+ if (themeReloadTimer) {
2032
+ clearTimeout(themeReloadTimer);
2033
+ themeReloadTimer = undefined;
2034
+ }
2026
2035
  if (themeWatcher) {
2027
2036
  themeWatcher.close();
2028
2037
  themeWatcher = undefined;
@@ -3,7 +3,12 @@ import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-m
3
3
  import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
4
4
  import type { KeybindingsManager } from "../config/keybindings";
5
5
  import type { Settings } from "../config/settings";
6
- import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
6
+ import type {
7
+ ExtensionUIContext,
8
+ ExtensionUIDialogOptions,
9
+ ExtensionWidgetContent,
10
+ ExtensionWidgetOptions,
11
+ } from "../extensibility/extensions";
7
12
  import type { CompactOptions } from "../extensibility/extensions/types";
8
13
  import type { MCPManager } from "../mcp";
9
14
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -59,6 +64,8 @@ export interface InteractiveModeContext {
59
64
  btwContainer: Container;
60
65
  editor: CustomEditor;
61
66
  editorContainer: Container;
67
+ hookWidgetContainerAbove: Container;
68
+ hookWidgetContainerBelow: Container;
62
69
  statusLine: StatusLineComponent;
63
70
 
64
71
  // Session access
@@ -226,7 +233,7 @@ export interface InteractiveModeContext {
226
233
  reason: "start" | "switch" | "branch" | "tree" | "shutdown",
227
234
  previousSessionFile?: string,
228
235
  ): Promise<void>;
229
- setHookWidget(key: string, content: unknown): void;
236
+ setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
230
237
  setHookStatus(key: string, text: string | undefined): void;
231
238
  showHookSelector(
232
239
  title: string,
@@ -1,10 +1,10 @@
1
- import type { AppAction, KeybindingsManager } from "../../config/keybindings";
1
+ import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
2
2
 
3
3
  export interface HotkeysMarkdownBindings {
4
4
  keybindings: Pick<KeybindingsManager, "getDisplayString">;
5
5
  }
6
6
 
7
- function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
7
+ function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
8
8
  return bindings.keybindings.getDisplayString(action) || "Disabled";
9
9
  }
10
10
 
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
26
26
  "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
27
27
  "| `Ctrl+U` | Delete to start of line |",
28
28
  "| `Ctrl+K` | Delete to end of line |",
29
- `| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
30
- `| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
29
+ `| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
30
+ `| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
31
31
  "",
32
32
  "**Other**",
33
33
  "| Key | Action |",
34
34
  "|-----|--------|",
35
35
  "| `Tab` | Path completion / accept autocomplete |",
36
- `| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
37
- `| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
38
- `| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
39
- `| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
40
- `| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
41
- `| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
42
- `| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
43
- "| `Alt+P` | Select model (temporary) |",
44
- `| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
45
- `| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
46
- `| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
47
- `| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
48
- `| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
49
- `| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
50
- `| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
51
- `| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
36
+ `| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
37
+ `| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
38
+ `| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
39
+ `| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
40
+ `| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
41
+ `| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
42
+ `| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
43
+ `| \`${appKey(bindings, "app.model.selectTemporary")}\` | Select model (temporary) |`,
44
+ `| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
45
+ `| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
46
+ `| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
47
+ `| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
48
+ `| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
49
+ `| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
50
+ `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
51
+ `| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
52
52
  "| `#` | Open prompt actions |",
53
53
  "| `/` | Slash commands |",
54
54
  "| `!` | Run bash command |",
@@ -0,0 +1,21 @@
1
+ import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
2
+
3
+ /**
4
+ * Match the coding-agent interrupt key.
5
+ *
6
+ * Interactive mode installs a keybinding manager that exposes `app.interrupt`
7
+ * globally, but some isolated component tests still run with only TUI
8
+ * keybindings registered. In that case, fall back to raw Escape matching.
9
+ */
10
+ export function matchesAppInterrupt(data: string): boolean {
11
+ const keybindings = getKeybindings();
12
+ const interruptKeys = keybindings.getKeys("app.interrupt");
13
+ if (interruptKeys.length > 0) {
14
+ return keybindings.matches(data, "app.interrupt");
15
+ }
16
+ return matchesKey(data, "escape") || matchesKey(data, "esc");
17
+ }
18
+
19
+ export function matchesSelectCancel(data: string): boolean {
20
+ return getKeybindings().matches(data, "tui.select.cancel");
21
+ }
@@ -474,7 +474,7 @@ export class UiHelpers {
474
474
  const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
475
475
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
476
476
  }
477
- const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
477
+ const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
478
478
  const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
479
479
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
480
480
  }
@@ -16,9 +16,12 @@ import type { HashMismatch } from "./types";
16
16
 
17
17
  export type Anchor = { line: number; hash: string };
18
18
  export type HashlineEdit =
19
- | { op: "replace"; pos: Anchor; end?: Anchor; lines: string[] }
20
- | { op: "append"; pos?: Anchor; lines: string[] }
21
- | { op: "prepend"; pos?: Anchor; lines: string[] };
19
+ | { op: "replace_line"; pos: Anchor; lines: string[] }
20
+ | { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
21
+ | { op: "append_at"; pos: Anchor; lines: string[] }
22
+ | { op: "prepend_at"; pos: Anchor; lines: string[] }
23
+ | { op: "append_file"; lines: string[] }
24
+ | { op: "prepend_file"; lines: string[] };
22
25
 
23
26
  const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
24
27
 
@@ -455,18 +458,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
455
458
  // Edit Application
456
459
  // ═══════════════════════════════════════════════════════════════════════════
457
460
 
458
- const MIN_AUTOCORRECT_LENGTH = 2;
459
-
460
- function shouldAutocorrect(line: string, otherLine: string): boolean {
461
- if (!line || line !== otherLine) return false;
462
- line = line.trim();
463
- if (line.length < MIN_AUTOCORRECT_LENGTH) {
464
- // if brace, we allow
465
- return line.endsWith("}") || line.endsWith(")");
466
- }
467
- return true;
468
- }
469
-
470
461
  /**
471
462
  * Apply an array of hashline edits to file content.
472
463
  *
@@ -513,28 +504,29 @@ export function applyHashlineEdits(
513
504
  }
514
505
  for (const edit of edits) {
515
506
  switch (edit.op) {
516
- case "replace": {
517
- if (edit.end) {
518
- const startValid = validateRef(edit.pos);
519
- const endValid = validateRef(edit.end);
520
- if (!startValid || !endValid) continue;
521
- if (edit.pos.line > edit.end.line) {
522
- throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
523
- }
524
- } else {
525
- if (!validateRef(edit.pos)) continue;
507
+ case "replace_line": {
508
+ if (!validateRef(edit.pos)) continue;
509
+ break;
510
+ }
511
+ case "replace_range": {
512
+ const startValid = validateRef(edit.pos);
513
+ const endValid = validateRef(edit.end);
514
+ if (!startValid || !endValid) continue;
515
+ if (edit.pos.line > edit.end.line) {
516
+ throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
526
517
  }
527
518
  break;
528
519
  }
529
- case "append": {
530
- if (edit.pos && !validateRef(edit.pos)) continue;
520
+ case "append_at":
521
+ case "prepend_at": {
522
+ if (!validateRef(edit.pos)) continue;
531
523
  if (edit.lines.length === 0) {
532
524
  edit.lines = [""]; // insert an empty line
533
525
  }
534
526
  break;
535
527
  }
536
- case "prepend": {
537
- if (edit.pos && !validateRef(edit.pos)) continue;
528
+ case "append_file":
529
+ case "prepend_file": {
538
530
  if (edit.lines.length === 0) {
539
531
  edit.lines = [""]; // insert an empty line
540
532
  }
@@ -547,6 +539,38 @@ export function applyHashlineEdits(
547
539
  }
548
540
  maybeAutocorrectEscapedTabIndentation(edits, warnings);
549
541
  maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
542
+
543
+ // Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
544
+ // This catches the common boundary-overreach pattern where the agent includes a closing delimiter
545
+ // in the replacement but sets `end` to the line before the delimiter, causing duplication.
546
+ for (const edit of edits) {
547
+ let endLine: number;
548
+ switch (edit.op) {
549
+ case "replace_line":
550
+ endLine = edit.pos.line;
551
+ break;
552
+ case "replace_range":
553
+ endLine = edit.end.line;
554
+ break;
555
+ default:
556
+ continue;
557
+ }
558
+ if (edit.lines.length === 0) continue;
559
+ const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
560
+ if (nextSurvivingIdx >= originalFileLines.length) continue;
561
+ const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
562
+ const lastInsertedLine = edit.lines[edit.lines.length - 1];
563
+ const trimmedNext = nextSurvivingLine.trim();
564
+ const trimmedLast = lastInsertedLine.trim();
565
+ // Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
566
+ if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
567
+ const tag = formatLineTag(endLine + 1, nextSurvivingLine);
568
+ warnings.push(
569
+ `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
570
+ `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
571
+ );
572
+ }
573
+ }
550
574
  // Deduplicate identical edits targeting the same line(s)
551
575
  const seenEditKeys = new Map<string, number>();
552
576
  const dedupIndices = new Set<number>();
@@ -554,25 +578,22 @@ export function applyHashlineEdits(
554
578
  const edit = edits[i];
555
579
  let lineKey: string;
556
580
  switch (edit.op) {
557
- case "replace":
558
- if (!edit.end) {
559
- lineKey = `s:${edit.pos.line}`;
560
- } else {
561
- lineKey = `r:${edit.pos.line}:${edit.end.line}`;
562
- }
581
+ case "replace_line":
582
+ lineKey = `s:${edit.pos.line}`;
563
583
  break;
564
- case "append":
565
- if (edit.pos) {
566
- lineKey = `i:${edit.pos.line}`;
567
- break;
568
- }
584
+ case "replace_range":
585
+ lineKey = `r:${edit.pos.line}:${edit.end.line}`;
586
+ break;
587
+ case "append_at":
588
+ lineKey = `i:${edit.pos.line}`;
589
+ break;
590
+ case "prepend_at":
591
+ lineKey = `ib:${edit.pos.line}`;
592
+ break;
593
+ case "append_file":
569
594
  lineKey = "ieof";
570
595
  break;
571
- case "prepend":
572
- if (edit.pos) {
573
- lineKey = `ib:${edit.pos.line}`;
574
- break;
575
- }
596
+ case "prepend_file":
576
597
  lineKey = "ibef";
577
598
  break;
578
599
  }
@@ -594,20 +615,28 @@ export function applyHashlineEdits(
594
615
  let sortLine: number;
595
616
  let precedence: number;
596
617
  switch (edit.op) {
597
- case "replace":
598
- if (!edit.end) {
599
- sortLine = edit.pos.line;
600
- } else {
601
- sortLine = edit.end.line;
602
- }
618
+ case "replace_line":
619
+ sortLine = edit.pos.line;
620
+ precedence = 0;
621
+ break;
622
+ case "replace_range":
623
+ sortLine = edit.end.line;
603
624
  precedence = 0;
604
625
  break;
605
- case "append":
606
- sortLine = edit.pos ? edit.pos.line : fileLines.length + 1;
626
+ case "append_at":
627
+ sortLine = edit.pos.line;
607
628
  precedence = 1;
608
629
  break;
609
- case "prepend":
610
- sortLine = edit.pos ? edit.pos.line : 0;
630
+ case "prepend_at":
631
+ sortLine = edit.pos.line;
632
+ precedence = 2;
633
+ break;
634
+ case "append_file":
635
+ sortLine = fileLines.length + 1;
636
+ precedence = 1;
637
+ break;
638
+ case "prepend_file":
639
+ sortLine = 0;
611
640
  precedence = 2;
612
641
  break;
613
642
  }
@@ -619,99 +648,82 @@ export function applyHashlineEdits(
619
648
  // Apply edits bottom-up
620
649
  for (const { edit, idx } of annotated) {
621
650
  switch (edit.op) {
622
- case "replace": {
623
- if (!edit.end) {
624
- const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
625
- const newLines = edit.lines;
626
- if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
627
- noopEdits.push({
628
- editIndex: idx,
629
- loc: `${edit.pos.line}#${edit.pos.hash}`,
630
- current: origLines.join("\n"),
631
- });
632
- break;
633
- }
634
- fileLines.splice(edit.pos.line - 1, 1, ...newLines);
635
- trackFirstChanged(edit.pos.line);
636
- } else {
637
- const count = edit.end.line - edit.pos.line + 1;
638
- const newLines = [...edit.lines];
639
- const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
640
- const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
641
- if (
642
- shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) &&
643
- // Safety: only correct when end-line content differs from the duplicate.
644
- // If end already points to the boundary, matching next line is coincidence.
645
- fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
646
- ) {
647
- newLines.pop();
648
- warnings.push(
649
- `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
650
- );
651
- }
652
- const leadingReplacementLine = newLines[0]?.trimEnd();
653
- const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd();
654
- if (
655
- shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) &&
656
- // Safety: only correct when pos-line content differs from the duplicate.
657
- // If pos already points to the boundary, matching prev line is coincidence.
658
- fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine
659
- ) {
660
- newLines.shift();
661
- warnings.push(
662
- `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`,
663
- );
664
- }
665
- fileLines.splice(edit.pos.line - 1, count, ...newLines);
666
- trackFirstChanged(edit.pos.line);
651
+ case "replace_line": {
652
+ const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
653
+ const newLines = edit.lines;
654
+ if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
655
+ noopEdits.push({
656
+ editIndex: idx,
657
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
658
+ current: origLines.join("\n"),
659
+ });
660
+ break;
667
661
  }
662
+ fileLines.splice(edit.pos.line - 1, 1, ...newLines);
663
+ trackFirstChanged(edit.pos.line);
664
+ break;
665
+ }
666
+ case "replace_range": {
667
+ const count = edit.end.line - edit.pos.line + 1;
668
+ fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
669
+ trackFirstChanged(edit.pos.line);
668
670
  break;
669
671
  }
670
- case "append": {
672
+ case "append_at": {
671
673
  const inserted = edit.lines;
672
674
  if (inserted.length === 0) {
673
675
  noopEdits.push({
674
676
  editIndex: idx,
675
- loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "EOF",
676
- current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
677
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
678
+ current: originalFileLines[edit.pos.line - 1],
677
679
  });
678
680
  break;
679
681
  }
680
- if (edit.pos) {
681
- fileLines.splice(edit.pos.line, 0, ...inserted);
682
- trackFirstChanged(edit.pos.line + 1);
683
- } else {
684
- if (fileLines.length === 1 && fileLines[0] === "") {
685
- fileLines.splice(0, 1, ...inserted);
686
- trackFirstChanged(1);
687
- } else {
688
- fileLines.splice(fileLines.length, 0, ...inserted);
689
- trackFirstChanged(fileLines.length - inserted.length + 1);
690
- }
691
- }
682
+ fileLines.splice(edit.pos.line, 0, ...inserted);
683
+ trackFirstChanged(edit.pos.line + 1);
692
684
  break;
693
685
  }
694
- case "prepend": {
686
+ case "prepend_at": {
695
687
  const inserted = edit.lines;
696
688
  if (inserted.length === 0) {
697
689
  noopEdits.push({
698
690
  editIndex: idx,
699
- loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "BOF",
700
- current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
691
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
692
+ current: originalFileLines[edit.pos.line - 1],
701
693
  });
702
694
  break;
703
695
  }
704
- if (edit.pos) {
705
- fileLines.splice(edit.pos.line - 1, 0, ...inserted);
706
- trackFirstChanged(edit.pos.line);
707
- } else {
708
- if (fileLines.length === 1 && fileLines[0] === "") {
709
- fileLines.splice(0, 1, ...inserted);
710
- } else {
711
- fileLines.splice(0, 0, ...inserted);
712
- }
696
+ fileLines.splice(edit.pos.line - 1, 0, ...inserted);
697
+ trackFirstChanged(edit.pos.line);
698
+ break;
699
+ }
700
+ case "append_file": {
701
+ const inserted = edit.lines;
702
+ if (inserted.length === 0) {
703
+ noopEdits.push({ editIndex: idx, loc: "EOF", current: "" });
704
+ break;
705
+ }
706
+ if (fileLines.length === 1 && fileLines[0] === "") {
707
+ fileLines.splice(0, 1, ...inserted);
713
708
  trackFirstChanged(1);
709
+ } else {
710
+ fileLines.splice(fileLines.length, 0, ...inserted);
711
+ trackFirstChanged(fileLines.length - inserted.length + 1);
712
+ }
713
+ break;
714
+ }
715
+ case "prepend_file": {
716
+ const inserted = edit.lines;
717
+ if (inserted.length === 0) {
718
+ noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
719
+ break;
720
+ }
721
+ if (fileLines.length === 1 && fileLines[0] === "") {
722
+ fileLines.splice(0, 1, ...inserted);
723
+ } else {
724
+ fileLines.splice(0, 0, ...inserted);
714
725
  }
726
+ trackFirstChanged(1);
715
727
  break;
716
728
  }
717
729
  }