@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.
- package/CHANGELOG.md +150 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +423 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/custom-editor.ts +53 -51
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +48 -29
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +20 -20
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +436 -86
- package/src/session/messages.ts +23 -0
- package/src/session/session-manager.ts +97 -31
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
- package/src/utils/image-input.ts +11 -1
- package/src/web/search/providers/codex.ts +10 -3
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
}
|
|
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
|
|
1950
|
-
|
|
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;
|
package/src/modes/types.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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, "
|
|
41
|
-
`| \`${appKey(bindings, "
|
|
42
|
-
`| \`${appKey(bindings, "
|
|
43
|
-
"
|
|
44
|
-
`| \`${appKey(bindings, "
|
|
45
|
-
`| \`${appKey(bindings, "
|
|
46
|
-
`| \`${appKey(bindings, "
|
|
47
|
-
`| \`${appKey(bindings, "
|
|
48
|
-
`| \`${appKey(bindings, "
|
|
49
|
-
`| \`${appKey(bindings, "
|
|
50
|
-
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
|
|
51
|
-
`| \`${appKey(bindings, "
|
|
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
|
}
|
package/src/patch/hashline.ts
CHANGED
|
@@ -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: "
|
|
20
|
-
| { op: "
|
|
21
|
-
| { op: "
|
|
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 "
|
|
517
|
-
if (edit.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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 "
|
|
530
|
-
|
|
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 "
|
|
537
|
-
|
|
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 "
|
|
558
|
-
|
|
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 "
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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 "
|
|
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 "
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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 "
|
|
606
|
-
sortLine = edit.pos
|
|
626
|
+
case "append_at":
|
|
627
|
+
sortLine = edit.pos.line;
|
|
607
628
|
precedence = 1;
|
|
608
629
|
break;
|
|
609
|
-
case "
|
|
610
|
-
sortLine = edit.pos
|
|
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 "
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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 "
|
|
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:
|
|
676
|
-
current:
|
|
677
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
678
|
+
current: originalFileLines[edit.pos.line - 1],
|
|
677
679
|
});
|
|
678
680
|
break;
|
|
679
681
|
}
|
|
680
|
-
|
|
681
|
-
|
|
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 "
|
|
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:
|
|
700
|
-
current:
|
|
691
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
692
|
+
current: originalFileLines[edit.pos.line - 1],
|
|
701
693
|
});
|
|
702
694
|
break;
|
|
703
695
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
}
|