diff --git a/CHANGELOG.md b/CHANGELOG.md index 35aa6fb..7989d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- `Ctrl+O` in insert mode — run one normal-mode command, then return to insert. Motions, operators, counts, and `r{char}` all work. + ## [0.10.0] — 2026-05-31 ### Added diff --git a/README.md b/README.md index 0ec4b86..5b2c7a1 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 | `o` | Open line below | | `O` | Open line above | +`Ctrl+O` runs one normal-mode command and returns to insert. Motions, operators, counts, and `r{char}` all work. + ### Visual mode Press `v` in normal mode to enter character-wise visual mode. Motions extend the selection, operators act on it: @@ -139,6 +141,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b | Key | Action | |-----|--------| +| `Ctrl+O` | One-shot normal mode (execute one command, return to insert) | | `r{char}` | Replace character under cursor with `{char}` | | `x` | Delete character | | `u` | Undo | diff --git a/src/index.ts b/src/index.ts index bdabede..60ce161 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,15 @@ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; import { writeClipboard } from "./clipboard"; import { checkForUpdate } from "./version"; -import { type Action, createVimState, handleInsertKey, handleNormalKey, handleVisualKey, translateKey } from "./vim"; +import { + type Action, + createVimState, + finishOneShotIfComplete, + handleInsertKey, + handleNormalKey, + handleVisualKey, + translateKey, +} from "./vim"; const plugin: TuiPluginModule = { id: "vimcode", @@ -167,12 +175,14 @@ const plugin: TuiPluginModule = { } const key = translateKey(ctx.event); + const handlerMode = state.mode; const result = state.mode === "insert" ? handleInsertKey(state, key, ctx.event) : state.mode === "visual" ? handleVisualKey(state, key, ctx.event) : handleNormalKey(state, key, ctx.event, prompt); + if (handlerMode === "normal") finishOneShotIfComplete(state, result); if (result.consume) ctx.consume(); applyActions(result.actions); }, diff --git a/src/vim.ts b/src/vim.ts index 4117584..c245386 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -25,6 +25,7 @@ export type VimState = { pendingChar: "r" | "g" | null; count: number; yankRegister: string; + oneShotNormal: boolean; }; export type KeyEvent = { @@ -85,7 +86,7 @@ const PASS: HandlerResult = { consume: false, actions: [] }; const _CONSUME: HandlerResult = { consume: true, actions: [] }; export function createVimState(): VimState { - return { mode: "insert", pendingOp: null, pendingChar: null, count: 0, yankRegister: "" }; + return { mode: "insert", pendingOp: null, pendingChar: null, count: 0, yankRegister: "", oneShotNormal: false }; } export function endOfWord(text: string, offset: number, count = 1): number { @@ -143,6 +144,11 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent): Ha if (ev.name === "tab") { return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; } + if (ev.name === "o" && ev.ctrl) { + state.mode = "normal"; + state.oneShotNormal = true; + return { consume: true, actions: [{ type: "toast", message: "(insert)", duration: 800 }] }; + } return PASS; } @@ -157,6 +163,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } if (ev.name === "escape") { + if (state.oneShotNormal) { + state.oneShotNormal = false; + state.mode = "insert"; + resetPending(state); + return { consume: true, actions: [{ type: "mode", mode: "insert" }] }; + } resetPending(state); return PASS; } @@ -404,6 +416,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom // Visual mode entry if (key === "v") { state.mode = "visual"; + state.oneShotNormal = false; resetPending(state); return { consume: true, actions: [{ type: "mode", mode: "visual" }] }; } @@ -511,6 +524,20 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent): Han // ── Helpers ────────────────────────────────────────────────── +export function finishOneShotIfComplete(state: VimState, result: HandlerResult): void { + if (!state.oneShotNormal) return; + if (!result.consume) return; + if (state.pendingOp !== null || state.pendingChar !== null || state.count > 0) return; + const alreadyEnteringInsert = result.actions.some((a) => a.type === "mode" && a.mode === "insert"); + if (alreadyEnteringInsert) { + state.oneShotNormal = false; + return; + } + state.oneShotNormal = false; + state.mode = "insert"; + result.actions.push({ type: "mode", mode: "insert" }); +} + function resetPending(state: VimState) { state.pendingOp = null; state.pendingChar = null; @@ -526,12 +553,14 @@ function consumeCount(state: VimState): number { function enterInsert(state: VimState, actions: Action[]) { resetPending(state); state.mode = "insert"; + state.oneShotNormal = false; actions.push({ type: "mode", mode: "insert" }); } function enterNormal(state: VimState, actions: Action[]) { state.mode = "normal"; state.count = 0; + state.oneShotNormal = false; actions.push({ type: "mode", mode: "normal" }); } diff --git a/test/vim.test.ts b/test/vim.test.ts index 21b72a7..0623602 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -3,6 +3,7 @@ import { type Action, createVimState, endOfWord, + finishOneShotIfComplete, handleInsertKey, handleNormalKey, handleVisualKey, @@ -173,6 +174,19 @@ describe("handleInsertKey", () => { const r = handleInsertKey(state, "a", ev("a")); expect(r.consume).toBe(false); }); + + it("ctrl+o enters normal mode with oneShotNormal flag", () => { + const r = handleInsertKey(state, "o", ev("o", { ctrl: true })); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(state.oneShotNormal).toBe(true); + expect(r.actions).toContainEqual({ type: "toast", message: "(insert)", duration: 800 }); + }); + + it("ctrl+o does not emit a mode action", () => { + const r = handleInsertKey(state, "o", ev("o", { ctrl: true })); + expect(r.actions.some((a) => a.type === "mode")).toBe(false); + }); }); // ── handleNormalKey — motions ─────────────────────────────── @@ -862,6 +876,177 @@ describe("handleVisualKey — exit and passthrough", () => { }); }); +// ── Ctrl+O one-shot normal mode ─────────────────────────── + +describe("Ctrl+O one-shot normal mode", () => { + function enterOneShot() { + state.mode = "insert"; + handleInsertKey(state, "o", ev("o", { ctrl: true })); + } + + it("w auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("3w auto-returns to insert after count is consumed", () => { + enterOneShot(); + handleNormalKey(state, "3", ev("3"), mockPrompt); + expect(state.oneShotNormal).toBe(true); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("dw auto-returns to insert after operator+motion", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("dd auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("r{char} auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "r", ev("r"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "a", ev("a"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("gg auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "g", ev("g"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "g", ev("g"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("cw enters insert directly without double mode switch", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); + expect(modeActions).toHaveLength(1); + }); + + it("u auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "u", ev("u"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("p auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "p", ev("p"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it(": auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, ":", ev(":"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("e auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "e", ev("e"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("escape during one-shot returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("v during one-shot cancels one-shot and enters visual", () => { + enterOneShot(); + handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(state.mode).toBe("visual"); + expect(state.oneShotNormal).toBe(false); + }); + + it("sequential Ctrl+O usage works (flag resets cleanly)", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + // Second round + enterOneShot(); + expect(state.oneShotNormal).toBe(true); + const r2 = handleNormalKey(state, "b", ev("b"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("cc enters insert directly without double mode switch", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); + expect(modeActions).toHaveLength(1); + }); + + it("de auto-returns to insert (deleteRange path)", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "e", ev("e"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r2.actions.some((a) => a.type === "deleteRange")).toBe(true); + }); + + it("does not auto-return when not in one-shot mode", () => { + state.mode = "normal"; + state.oneShotNormal = false; + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("normal"); + }); +}); + describe("version sync", () => { it("VERSION matches package.json", async () => { const pkg = await import("../package.json");