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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] };
Expand Down
46 changes: 38 additions & 8 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,45 +222,75 @@ 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);
expect(r.actions).toContainEqual({ type: "mode", mode: "(insert)" });
});

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);
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading