Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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);
},
Expand Down
31 changes: 30 additions & 1 deletion src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type VimState = {
pendingChar: "r" | "g" | null;
count: number;
yankRegister: string;
oneShotNormal: boolean;
};

export type KeyEvent = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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" }] };
}
Expand Down Expand Up @@ -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;
Expand All @@ -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" });
}

Expand Down
185 changes: 185 additions & 0 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type Action,
createVimState,
endOfWord,
finishOneShotIfComplete,
handleInsertKey,
handleNormalKey,
handleVisualKey,
Expand Down Expand Up @@ -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 ───────────────────────────────
Expand Down Expand Up @@ -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");
Expand Down
Loading