diff --git a/AGENTS.md b/AGENTS.md index dcb1cdf..8feebb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,13 +57,13 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (342 lines) Plugin entry: intercept registration, action application - vim.ts (630 lines) Pure vim engine: state, handlers, command tables, types + index.ts (345 lines) Plugin entry: intercept registration, action application + vim.ts (638 lines) Pure vim engine: state, handlers, command tables, types leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe) version.ts (46 lines) Version constant, GitHub update check (cached daily) test/ - vim.test.ts (1311 lines) Characterization tests for all key handling branches + vim.test.ts (1341 lines) Characterization tests for all key handling branches leader.test.ts (125 lines) Unit tests for leader key matching functions ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2bfc7..7c86535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Fixed +- Escape from insert mode now moves cursor left, matching vim. - Clipboard now uses `wl-copy` on Wayland instead of `xclip`, which doesn't work there. ## [0.14.0] — 2026-06-13 diff --git a/src/index.ts b/src/index.ts index ddbd5f8..b5c6806 100644 --- a/src/index.ts +++ b/src/index.ts @@ -306,7 +306,7 @@ const plugin: TuiPluginModule = { const handlerMode = state.mode; const result = state.mode === "insert" - ? handleInsertKey(state, key, ctx.event) + ? handleInsertKey(state, key, ctx.event, prompt) : state.mode === "visual" ? handleVisualKey(state, key, ctx.event) : handleNormalKey(state, key, ctx.event, prompt); diff --git a/src/vim.ts b/src/vim.ts index 125bab4..297b2b9 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -160,10 +160,18 @@ export function translateKey(ev: KeyEvent): string { return key; } -export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent): HandlerResult { +export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { if (ev.name === "escape") { state.mode = "normal"; - return { consume: true, actions: [{ type: "mode", mode: "normal" }] }; + const actions: Action[] = []; + // Vim moves cursor one left when leaving insert mode, + // unless at position 0 or start of line. + const offset = prompt.getCursorOffset(); + if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { + actions.push({ type: "cursorTo", offset: offset - 1 }); + } + actions.push({ type: "mode", mode: "normal" }); + return { consume: true, actions }; } if (ev.name === "return" && ev.ctrl) { return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; diff --git a/test/vim.test.ts b/test/vim.test.ts index c85d93d..f672840 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -222,37 +222,67 @@ describe("handleInsertKey", () => { }); it("escape → consume, mode normal", () => { - const r = handleInsertKey(state, "escape", ev("escape")); + const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); expect(r.consume).toBe(true); expect(state.mode).toBe("normal"); expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); }); it("enter → consume, input.newline", () => { - const r = handleInsertKey(state, "return", ev("return")); + const r = handleInsertKey(state, "return", ev("return"), mockPrompt); expect(r.consume).toBe(true); expect(cmds(r.actions)).toContain("input.newline"); }); it("ctrl+enter → consume, input.submit", () => { - const r = handleInsertKey(state, "return", ev("return", { ctrl: true })); + const r = handleInsertKey(state, "return", ev("return", { ctrl: true }), mockPrompt); expect(r.consume).toBe(true); expect(cmds(r.actions)).toContain("input.submit"); }); it("tab → consume, insertText tab", () => { - const r = handleInsertKey(state, "tab", ev("tab")); + const r = handleInsertKey(state, "tab", ev("tab"), mockPrompt); expect(r.consume).toBe(true); expect(r.actions).toContainEqual({ type: "insertText", text: "\t" }); }); it("regular key → passthrough", () => { - const r = handleInsertKey(state, "a", ev("a")); + const r = handleInsertKey(state, "a", ev("a"), mockPrompt); expect(r.consume).toBe(false); }); + it("escape mid-line moves cursor one left", () => { + const midLinePrompt: PromptAccess = { + getLine: () => "hello world", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 5, + getPlainText: () => "hello world", + }; + const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("escape at position 0 does not move cursor", () => { + const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); + expect(cursorTos(r.actions)).toEqual([]); + }); + + it("escape at start of line does not move cursor", () => { + const startOfLinePrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line"][n] ?? "", + getLineCount: () => 2, + getCursorLine: () => 1, + getCursorOffset: () => 12, + getPlainText: () => "hello world\nsecond line", + }; + const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); + expect(cursorTos(r.actions)).toEqual([]); + }); + it("ctrl+o enters normal mode with oneShotNormal flag", () => { - const r = handleInsertKey(state, "o", ev("o", { ctrl: true })); + const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); expect(r.consume).toBe(true); expect(state.mode).toBe("normal"); expect(state.oneShotNormal).toBe(true); @@ -260,7 +290,7 @@ describe("handleInsertKey", () => { }); it("ctrl+o emits (insert) mode action", () => { - const r = handleInsertKey(state, "o", ev("o", { ctrl: true })); + const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); expect(r.actions.some((a) => a.type === "mode" && a.mode === "(insert)")).toBe(true); }); }); @@ -994,7 +1024,7 @@ describe("handleVisualKey — exit and passthrough", () => { describe("Ctrl+O one-shot normal mode", () => { function enterOneShot() { state.mode = "insert"; - handleInsertKey(state, "o", ev("o", { ctrl: true })); + handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); } it("w auto-returns to insert", () => {