diff --git a/packages/coding-agent/src/prompts/tools/vim.md b/packages/coding-agent/src/prompts/tools/vim.md index 5bf8c0f51..b2fc8eaae 100644 --- a/packages/coding-agent/src/prompts/tools/vim.md +++ b/packages/coding-agent/src/prompts/tools/vim.md @@ -1,30 +1,64 @@ -Stateful Vim-style editor with multi-buffer support. +Stateful Vim editor. Every call requires `file` — the buffer loads automatically on first use. +- `{"file": "path"}` — view file +- `{"file": "path", "kbd": ["…"], "insert": "…"}` — edit file -Every call requires `file` — the path to edit. The buffer is loaded automatically on first use. +## kbd vs insert — get this right -- `{"file": "path/to/file.py"}` — view file (loads buffer if needed) -- `{"file": "path/to/file.py", "kbd": ["…"], "insert": "…"}` — edit file +`kbd` = Vim commands only (`dd`, `G`, `o`, `cc`, `gg`, etc.). +`insert` = text content to type into the buffer. -How `kbd` + `insert` work together: -- `kbd` runs Vim key sequences (motions, commands, operators) -- `insert` is **raw text** (with real `\n` newlines in JSON) that gets typed into the buffer -- For `insert` to work, the last `kbd` entry **MUST** leave the buffer in INSERT mode (via `i`, `o`, `O`, `a`, `A`, `cc`, `C`, `s`, `S`, etc.) -- After the call, the tool auto-exits INSERT mode and auto-saves to disk -- Set `pause: true` to skip auto-save and stay in the current mode +**Never put text content in kbd.** Only Vim keystrokes go there. +- BAD: `{"kbd": ["1Gohello world"]}` — text mixed into kbd +- GOOD: `{"kbd": ["1Go"], "insert": "hello world"}` — command in kbd, text in insert -Rules: -- Each non-final `kbd` entry must end in NORMAL mode — use `` or merge into one string -- To recover from mistakes: `{"file": "f.py", "kbd": ["u"]}` to undo, or `{"file": "f.py", "kbd": [":e!"]}` to reload from disk +For `insert` to work, the last `kbd` entry must leave INSERT mode active (`o`, `O`, `i`, `a`, `A`, `cc`, `C`, `s`, `S`). The tool auto-exits INSERT and auto-saves after each call. -Special keys: ``, ``, ``, ``, ``, ``, ``, ``, ``. +Each non-final `kbd` entry must end in NORMAL mode (add ``). -Supported: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert (`i/a/o/O/I/A/cc/C/s/S`), visual (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/``), search (`/pattern`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`). +## Best pattern: replace entire file -Examples: -- `{"file": "src/app.ts"}` — view file -- `{"file": "src/app.ts", "kbd": ["3G", "ciwnewName"]}` — rename word on line 3 -- `{"file": "src/app.ts", "kbd": ["5G", "cc"], "insert": " if b == 0:\n return None"}` — replace line 5 -- `{"file": "src/app.ts", "kbd": ["3G", "o"], "insert": "def multiply(a, b):\n return a * b"}` — insert after line 3 -- `{"file": "src/app.ts", "kbd": [":%s/oldName/newName/g"]}` — find and replace -- `{"file": "src/app.ts", "kbd": ["/TODO", "dd"]}` — search and delete -- `{"file": "src/app.ts", "kbd": [":3,5d"]}` — delete line range +For any edit touching multiple locations, replace the whole file — it is the most reliable approach: + +```json +{"file": "f.py", "kbd": ["ggdGi"], "insert": "entire new file content"} +``` + +`ggdGi` = go to top, delete all, enter INSERT. Then `insert` provides the complete new content. + +## Surgical edits + +**Insert after line N** (include indentation in insert — `o` does NOT auto-indent): +```json +{"file": "f.py", "kbd": ["3Go"], "insert": " new line here"} +``` + +**Replace line N** (`cc` clears the line and enters INSERT): +```json +{"file": "f.py", "kbd": ["5Gcc"], "insert": " replacement content"} +``` + +**Find and replace**: +```json +{"file": "f.py", "kbd": [":%s/old/new/g"]} +``` + +**Delete line range**: +```json +{"file": "f.py", "kbd": [":3,5d"]} +``` + +## Undo mistakes +- `{"file": "f.py", "kbd": ["u"]}` — undo last change +- `{"file": "f.py", "kbd": ["3u"]}` — undo last 3 changes + +`:e!` reloads from disk, but since auto-save commits after each call, it will reload your last (possibly wrong) result. Use `u` to revert instead. + +## Supported + +Keys: `` `` `` `` `` `` `` `` `` +Motions: `h j k l w b e 0 $ gg G { } f t` with counts +Operators: `d c y p` with motions and text objects (`iw aw i" a" i( a(`) +Insert: `i a o O I A cc C s S` — these all enter INSERT mode; do NOT add another `i` after them +Visual: `v V` +Other: `.` repeat, `u`/`` undo/redo, `/pattern` search, `n N` +Ex: `:w` `:q` `:wq` `:e` `:e!` `:N` `:s///` `:%s///` `:N,Md` `:%d` diff --git a/packages/coding-agent/src/tools/vim.ts b/packages/coding-agent/src/tools/vim.ts index 435f298e1..f900d2bea 100644 --- a/packages/coding-agent/src/tools/vim.ts +++ b/packages/coding-agent/src/tools/vim.ts @@ -352,7 +352,11 @@ export class VimTool implements AgentTool { if (fp) engine.buffer.baseFingerprint = fp; } - const sequences = Array.isArray(params.kbd) ? params.kbd : typeof params.kbd === "string" ? [params.kbd] : undefined; + const sequences = Array.isArray(params.kbd) + ? params.kbd + : typeof params.kbd === "string" + ? [params.kbd] + : undefined; if (!sequences) { // No kbd — just show the file viewport if (isNewBuffer) { diff --git a/packages/coding-agent/src/vim/engine.ts b/packages/coding-agent/src/vim/engine.ts index 65c5a79b5..6218eab40 100644 --- a/packages/coding-agent/src/vim/engine.ts +++ b/packages/coding-agent/src/vim/engine.ts @@ -1284,7 +1284,11 @@ export class VimEngine { if (!next || next.value !== "g") { throw new VimError("Unsupported g motion", token); } - return { nextIndex: index + 2, target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }, linewise: true }; + return { + nextIndex: index + 2, + target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }, + linewise: true, + }; } case "G": return { @@ -1708,6 +1712,10 @@ export class VimEngine { digits += value; cursor += 1; } - return { count: digits.length > 0 ? Number.parseInt(digits, 10) : 1, hasCount: digits.length > 0, nextIndex: cursor }; + return { + count: digits.length > 0 ? Number.parseInt(digits, 10) : 1, + hasCount: digits.length > 0, + nextIndex: cursor, + }; } } diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts index 233044f51..a8aea64a2 100644 --- a/packages/coding-agent/test/tools/vim.test.ts +++ b/packages/coding-agent/test/tools/vim.test.ts @@ -240,7 +240,9 @@ describe("vim tool", () => { const tool = new VimTool(createSession(tmpDir)); await tool.execute("open", { file: "ambiguous.ts" }); - await expect(tool.execute("bad", { file: "ambiguous.ts", kbd: ["o", "o"] })).rejects.toThrow(/left Vim in INSERT mode/i); + await expect(tool.execute("bad", { file: "ambiguous.ts", kbd: ["o", "o"] })).rejects.toThrow( + /left Vim in INSERT mode/i, + ); }); it("rejects additional kbd entries after entering insert mode", async () => { @@ -249,7 +251,9 @@ describe("vim tool", () => { const tool = new VimTool(createSession(tmpDir)); await tool.execute("open", { file: "insert-boundary.ts" }); - await expect(tool.execute("edit", { file: "insert-boundary.ts", kbd: ["2G", "o", "o"] })).rejects.toThrow(/insert field|/i); + await expect(tool.execute("edit", { file: "insert-boundary.ts", kbd: ["2G", "o", "o"] })).rejects.toThrow( + /insert field|/i, + ); const saved = await Bun.file(filePath).text(); expect(saved).toBe("alpha\nbeta\n"); }); @@ -311,12 +315,17 @@ describe("vim tool", () => { const pendingInputs: string[] = []; await tool.execute("open", { file: "command.ts" }); - const result = await tool.execute("command", { file: "command.ts", kbd: [":%s/foo/bar/g"] }, undefined, update => { - const pending = update.details?.pendingInput; - if (pending?.kind === "command") { - pendingInputs.push(pending.text); - } - }); + const result = await tool.execute( + "command", + { file: "command.ts", kbd: [":%s/foo/bar/g"] }, + undefined, + update => { + const pending = update.details?.pendingInput; + if (pending?.kind === "command") { + pendingInputs.push(pending.text); + } + }, + ); expect(pendingInputs).toContain(""); expect(pendingInputs).toContain("%"); @@ -340,7 +349,9 @@ describe("vim tool", () => { const moved = await tool.execute("move", { file: "plan.ts", kbd: ["2G"] }); expect(textResult(moved)).toContain("L2:1"); await expect(tool.execute("edit", { file: "plan.ts", kbd: ["dd"] })).rejects.toThrow(/Plan mode/i); - await expect(tool.execute("insert", { file: "plan.ts", kbd: ["cc"], insert: "blocked" })).rejects.toThrow(/Plan mode/i); + await expect(tool.execute("insert", { file: "plan.ts", kbd: ["cc"], insert: "blocked" })).rejects.toThrow( + /Plan mode/i, + ); }); });