diff --git a/AGENTS.md b/AGENTS.md index 4ca0be8..774a233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,12 +53,12 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (190 lines) Plugin entry: intercept registration, action application - vim.ts (549 lines) Pure vim engine: state, handlers, command tables, types + index.ts (270 lines) Plugin entry: intercept registration, action application + vim.ts (676 lines) Pure vim engine: state, handlers, command tables, types 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 (904 lines) Characterization tests for all key handling branches + vim.test.ts (1419 lines) Characterization tests for all key handling branches ``` **Data flow:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 7989d98..e692e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Added - `Ctrl+O` in insert mode — run one normal-mode command, then return to insert. Motions, operators, counts, and `r{char}` all work. +- `.` in normal mode repeats the last command-based change. For example, `dw..` deletes three words. +- `V` selects the current line and enters visual mode. + +### Fixed + +- Counted delete/change commands such as `3dw` now undo in one `u` press instead of one undo per repeated host command. +- Normal/visual-mode leader keys now pass through both the configured prefix key and the following key, so OpenCode bindings like `e`, `l`, and `n` can run even when vimcode is in normal mode. ## [0.10.0] — 2026-05-31 diff --git a/README.md b/README.md index 5b2c7a1..1db9888 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,14 @@ Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 ### Visual mode -Press `v` in normal mode to enter character-wise visual mode. Motions extend the selection, operators act on it: +Press `v` in normal mode to enter character-wise visual mode. Press `V` to select the current line. Motions extend the selection, operators act on it: | Key | Action | |-----|--------| | `d` `x` | Delete selection | | `c` | Delete selection, enter insert mode | | `y` | Yank (copy) selection | +| `V` | Select current line | | `Escape` `v` | Exit visual mode | All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `$` `G`, with counts. @@ -146,6 +147,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b | `x` | Delete character | | `u` | Undo | | `Ctrl+r` | Redo | +| `.` | Repeat last command-based change (`dw`, `dd`, `x`, `r{char}`, etc.) | | `p` | Paste from yank register | | `:` | Command palette | | `/` | Jump to message (session timeline) | @@ -159,8 +161,9 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b ## Known gaps -- `V`, `Ctrl+v` - only character-wise visual mode (`v`) is supported, no line-wise or block +- `Ctrl+v` - block visual mode is not supported - `ciw`, `di"`, etc. (text objects) - not yet implemented +- `.` does not yet repeat range-computed changes like `de` or `dG` - No persistent mode indicator - the toast fades after about a second. Cursor shape is the persistent signal, but a status bar indicator would need the host's SolidJS runtime, which external plugins can't access. Configurable key bindings are next once the core vim coverage stabilizes. diff --git a/src/index.ts b/src/index.ts index 60ce161..11b6ef3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,10 +18,10 @@ const plugin: TuiPluginModule = { const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; - // Snapshot for single-step undo of deleteRange operations. - // The host editor's undo system splits multi-line deletions into - // multiple entries, so we save/restore the buffer ourselves. - let undoSnapshot: { text: string; cursor: number } | null = null; + // Snapshots for single-step undo of vim changes. + // The host editor's undo system splits repeated commands into multiple + // entries, so we save/restore the buffer ourselves. + let undoSnapshots: Array<{ text: string; cursor: number }> = []; const prompt = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", @@ -38,11 +38,12 @@ const plugin: TuiPluginModule = { } function applyActions(actions: Action[]) { + let keepUndoSnapshotForBatch = false; for (const action of actions) { // Any buffer-modifying action (other than our own deleteRange/undo) // invalidates the undo snapshot. - if (action.type === "cmd" || action.type === "insertText") { - undoSnapshot = null; + if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { + undoSnapshots = []; } switch (action.type) { case "cmd": @@ -83,7 +84,7 @@ const plugin: TuiPluginModule = { const editor = api.renderer?.currentFocusedEditor; const eb = editor?.editBuffer; if (eb?.deleteRange) { - undoSnapshot = { text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 }; + undoSnapshots.push({ text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 }); const text = editor.plainText ?? ""; const [sl, sc] = offsetToLineCol(text, action.start); const [el, ec] = offsetToLineCol(text, action.end + 1); @@ -91,7 +92,14 @@ const plugin: TuiPluginModule = { } break; } + case "saveUndoSnapshot": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) undoSnapshots.push({ text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 }); + keepUndoSnapshotForBatch = true; + break; + } case "undo": { + const undoSnapshot = undoSnapshots.pop(); if (undoSnapshot) { const editor = api.renderer?.currentFocusedEditor; const eb = editor?.editBuffer; @@ -99,7 +107,6 @@ const plugin: TuiPluginModule = { eb.setText(undoSnapshot.text); editor.cursorOffset = undoSnapshot.cursor; } - undoSnapshot = null; } else { setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); } @@ -174,14 +181,15 @@ const plugin: TuiPluginModule = { } } - const key = translateKey(ctx.event); + const event = { ...ctx.event, leader: isLeaderKey(ctx.event, getLeaderBinding(api)) }; + const key = translateKey(event); const handlerMode = state.mode; const result = state.mode === "insert" - ? handleInsertKey(state, key, ctx.event) + ? handleInsertKey(state, key, event) : state.mode === "visual" - ? handleVisualKey(state, key, ctx.event) - : handleNormalKey(state, key, ctx.event, prompt); + ? handleVisualKey(state, key, event) + : handleNormalKey(state, key, event, prompt); if (handlerMode === "normal") finishOneShotIfComplete(state, result); if (result.consume) ctx.consume(); applyActions(result.actions); @@ -197,4 +205,66 @@ function offsetToLineCol(text: string, offset: number): [number, number] { return [lines.length - 1, lines[lines.length - 1].length]; } +function getLeaderBinding(api: any): unknown { + return api.tuiConfig?.keybinds?.leader ?? api.state?.config?.keybinds?.leader ?? "ctrl+x"; +} + +function isLeaderKey( + ev: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean }, + binding: unknown, +): boolean { + if (binding === false || binding === "none") return false; + if (Array.isArray(binding)) return binding.some((entry) => isLeaderKey(ev, entry)); + if (typeof binding === "object" && binding !== null) { + const value = binding as { + key?: unknown; + name?: unknown; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; + super?: boolean; + }; + if (value.key !== undefined) return isLeaderKey(ev, value.key); + if (typeof value.name === "string") return matchesKeySpec(ev, value); + } + if (typeof binding !== "string") return false; + return binding.split(",").some((part) => matchesKeySpec(ev, parseKeySpec(part))); +} + +function parseKeySpec(spec: string): { + name: string; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; + super?: boolean; +} { + const parts = spec + .trim() + .toLowerCase() + .split("+") + .map((part) => part.trim()) + .filter(Boolean); + const key = parts.at(-1) ?? ""; + return { + name: key, + ctrl: parts.includes("ctrl"), + shift: parts.includes("shift"), + meta: parts.includes("alt") || parts.includes("meta"), + super: parts.includes("cmd") || parts.includes("super"), + }; +} + +function matchesKeySpec( + ev: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean }, + spec: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean }, +): boolean { + return ( + ev.name.toLowerCase() === spec.name.toLowerCase() && + Boolean(ev.ctrl) === Boolean(spec.ctrl) && + Boolean(ev.shift) === Boolean(spec.shift) && + Boolean(ev.meta) === Boolean(spec.meta) && + Boolean(ev.super) === Boolean(spec.super) + ); +} + export default plugin; diff --git a/src/vim.ts b/src/vim.ts index c245386..e8b81a7 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -10,6 +10,7 @@ export type Action = | { type: "yankSelection" } | { type: "clearSelection" } | { type: "deleteRange"; start: number; end: number } + | { type: "saveUndoSnapshot" } | { type: "undo" } | { type: "cursorTo"; offset: number } | { type: "selectRange"; start: number; end: number }; @@ -26,6 +27,8 @@ export type VimState = { count: number; yankRegister: string; oneShotNormal: boolean; + lastChange: Action[] | null; + passNextKey: boolean; }; export type KeyEvent = { @@ -34,6 +37,7 @@ export type KeyEvent = { ctrl?: boolean; meta?: boolean; super?: boolean; + leader?: boolean; eventType?: string; }; @@ -86,7 +90,16 @@ 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: "", oneShotNormal: false }; + return { + mode: "insert", + pendingOp: null, + pendingChar: null, + count: 0, + yankRegister: "", + oneShotNormal: false, + lastChange: null, + passNextKey: false, + }; } export function endOfWord(text: string, offset: number, count = 1): number { @@ -153,7 +166,21 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent): Ha } export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.leader) { + resetPending(state); + state.passNextKey = true; + return PASS; + } + if (ev.name === "tab") { + resetPending(state); + state.passNextKey = true; + return PASS; + } if (ev.meta || ev.super) return PASS; + if (state.passNextKey) { + state.passNextKey = false; + return PASS; + } if (ev.ctrl) { if (ev.name === "r") { resetPending(state); @@ -180,7 +207,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom pushN(actions, "input.delete", n); actions.push({ type: "insertText", text: key.repeat(n) }); state.pendingChar = null; - return { consume: true, actions }; + return finishChange(state, actions); } // Pending g prefix (gg, ge, etc.) @@ -196,8 +223,6 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } - if (ev.name === "tab") return PASS; - // Everything below is consumed const actions: Action[] = []; @@ -206,6 +231,10 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === ".") { + return repeatLastChange(state); + } + if (ev.name === "return") { actions.push({ type: "cmd", cmd: "input.submit" }); resetPending(state); @@ -252,12 +281,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom if (state.yankRegister) actions.push({ type: "yank", text: state.yankRegister }); actions.push({ type: "cmd", cmd: "prompt.paste" }); resetPending(state); - return { consume: true, actions }; + return finishChange(state, actions); } if (key === "X") { pushN(actions, "input.backspace", consumeCount(state)); - return { consume: true, actions }; + return finishChange(state, actions); } if (key === "J") { @@ -266,7 +295,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom actions.push({ type: "cmd", cmd: "input.line.end" }); actions.push({ type: "cmd", cmd: "input.delete" }); } - return { consume: true, actions }; + return finishChange(state, actions); } // Operators: d, c, y @@ -284,6 +313,8 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } else { pushN(actions, "input.delete.line", n); if (key === "c") enterInsert(state, actions); + else resetPending(state); + return finishChange(state, actions); } state.pendingOp = null; return { consume: true, actions }; @@ -295,13 +326,13 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom if (key === "D") { actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); resetPending(state); - return { consume: true, actions }; + return finishChange(state, actions); } if (key === "C") { actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); enterInsert(state, actions); - return { consume: true, actions }; + return finishChange(state, actions); } // Pending operator + e (end-of-word needs special handling) @@ -318,6 +349,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom actions.push({ type: "deleteRange", start: offset, end: target }); if (state.pendingOp === "c") enterInsert(state, actions); else resetPending(state); + clearLastChange(state); } return { consume: true, actions }; } @@ -340,14 +372,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom pushN(actions, "input.delete.line", n + 1); if (state.pendingOp === "c") enterInsert(state, actions); else resetPending(state); - return { consume: true, actions }; + return finishChange(state, actions); } if (key === "k") { pushN(actions, "input.move.up", n); pushN(actions, "input.delete.line", n + 1); if (state.pendingOp === "c") enterInsert(state, actions); else resetPending(state); - return { consume: true, actions }; + return finishChange(state, actions); } if (key === "G") { consumeCount(state); @@ -356,6 +388,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); if (state.pendingOp === "c") enterInsert(state, actions); else resetPending(state); + clearLastChange(state); return { consume: true, actions }; } @@ -364,7 +397,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom pushN(actions, deleteCmd, n); if (state.pendingOp === "c") enterInsert(state, actions); else resetPending(state); - return { consume: true, actions }; + return finishChange(state, actions); } resetPending(state); @@ -399,7 +432,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom if (key === "x") { pushN(actions, "input.delete", consumeCount(state)); - return { consume: true, actions }; + return finishChange(state, actions); } if (key === "r") { @@ -414,6 +447,20 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } // Visual mode entry + if (key === "V") { + const range = currentLineRange(prompt.getPlainText(), prompt.getCursorOffset()); + state.mode = "visual"; + state.oneShotNormal = false; + resetPending(state); + return { + consume: true, + actions: [ + { type: "selectRange", start: range.start, end: range.end }, + { type: "mode", mode: "visual" }, + ], + }; + } + if (key === "v") { state.mode = "visual"; state.oneShotNormal = false; @@ -459,7 +506,21 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } export function handleVisualKey(state: VimState, key: string, ev: KeyEvent): HandlerResult { + if (ev.leader) { + resetPending(state); + state.passNextKey = true; + return PASS; + } + if (ev.name === "tab") { + resetPending(state); + state.passNextKey = true; + return PASS; + } if (ev.meta || ev.super) return PASS; + if (state.passNextKey) { + state.passNextKey = false; + return PASS; + } if (ev.ctrl) return PASS; const actions: Action[] = []; @@ -544,6 +605,43 @@ function resetPending(state: VimState) { state.count = 0; } +function currentLineRange(text: string, offset: number): { start: number; end: number } { + if (text.length === 0) return { start: 0, end: 0 }; + const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); + const start = text.lastIndexOf("\n", safeOffset - 1) + 1; + const newline = text.indexOf("\n", safeOffset); + return { start, end: newline === -1 ? text.length - 1 : newline }; +} + +function finishChange(state: VimState, actions: Action[]): HandlerResult { + const repeatableActions: Action[] = [{ type: "saveUndoSnapshot" }, ...actions]; + state.lastChange = cloneActions(repeatableActions); + return { consume: true, actions: repeatableActions }; +} + +function clearLastChange(state: VimState) { + state.lastChange = null; +} + +function repeatLastChange(state: VimState): HandlerResult { + const n = consumeCount(state); + if (!state.lastChange) return { consume: true, actions: [] }; + + const actions: Action[] = []; + for (let i = 0; i < n; i++) actions.push(...cloneActions(state.lastChange)); + for (const action of actions) { + if (action.type === "mode") { + state.mode = action.mode; + state.oneShotNormal = false; + } + } + return { consume: true, actions }; +} + +function cloneActions(actions: Action[]): Action[] { + return actions.map((action) => ({ ...action })); +} + function consumeCount(state: VimState): number { const n = state.count || 1; state.count = 0; diff --git a/test/vim.test.ts b/test/vim.test.ts index 0623602..67bd09d 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -26,12 +26,26 @@ function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> .map((a) => ({ start: a.start, end: a.end })); } -const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ +function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => a.type === "selectRange") + .map((a) => ({ start: a.start, end: a.end })); +} + +function saveUndoSnapshots(actions: Action[]): Action[] { + return actions.filter((a) => a.type === "saveUndoSnapshot"); +} + +const ev = ( + name: string, + opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean; leader?: boolean }, +) => ({ name, shift: opts?.shift ?? false, ctrl: opts?.ctrl ?? false, meta: opts?.meta ?? false, super: opts?.super ?? false, + leader: opts?.leader ?? false, }); const mockPrompt: PromptAccess = { @@ -346,6 +360,18 @@ describe("handleNormalKey — operators", () => { expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); }); + it("3dw saves one undo snapshot around the repeated deletes", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + expect(cmds(r.actions)).toEqual([ + "input.delete.word.forward", + "input.delete.word.forward", + "input.delete.word.forward", + ]); + }); + it("d$ dispatches input.delete.to.line.end", () => { handleNormalKey(state, "d", ev("d"), mockPrompt); const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); @@ -559,6 +585,59 @@ describe("handleNormalKey — special keys", () => { expect(r.consume).toBe(false); expect(state.pendingOp).toBeNull(); }); + + it(". repeats the last change", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "w", ev("w"), mockPrompt); + + const r = handleNormalKey(state, ".", ev("."), mockPrompt); + + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); + }); + + it("2. repeats the last change twice", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "w", ev("w"), mockPrompt); + handleNormalKey(state, "2", ev("2"), mockPrompt); + + const r = handleNormalKey(state, ".", ev("."), mockPrompt); + + expect(saveUndoSnapshots(r.actions)).toHaveLength(2); + expect(cmds(r.actions)).toEqual(["input.delete.word.forward", "input.delete.word.forward"]); + }); + + it("leader prefix passes through leader and the following key", () => { + const leader = handleNormalKey(state, "tab", ev("tab", { leader: true }), mockPrompt); + expect(leader.consume).toBe(false); + expect(state.passNextKey).toBe(true); + + const next = handleNormalKey(state, "e", ev("e"), mockPrompt); + expect(next.consume).toBe(false); + expect(next.actions).toEqual([]); + expect(state.passNextKey).toBe(false); + }); + + it("leader prefix passthrough only affects one following key", () => { + handleNormalKey(state, "tab", ev("tab", { leader: true }), mockPrompt); + handleNormalKey(state, "e", ev("e"), mockPrompt); + + const r = handleNormalKey(state, "e", ev("e"), mockPrompt); + + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("tab prefix still passes through when leader config is unavailable", () => { + const tab = handleNormalKey(state, "tab", ev("tab"), mockPrompt); + expect(tab.consume).toBe(false); + expect(state.passNextKey).toBe(true); + + const next = handleNormalKey(state, "n", ev("n"), mockPrompt); + expect(next.consume).toBe(false); + expect(next.actions).toEqual([]); + expect(state.passNextKey).toBe(false); + }); }); // ── handleNormalKey — replace (r) ────────────────────────── @@ -725,6 +804,43 @@ describe("handleNormalKey — visual mode entry", () => { expect(state.pendingOp).toBeNull(); expect(state.mode).toBe("visual"); }); + + it("V selects the current line and enters visual mode", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 8, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(r.consume).toBe(true); + expect(state.mode).toBe("visual"); + expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 12 }]); + expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); + }); + + it("V selects the first line including its newline", () => { + const r = handleNormalKey(state, "V", ev("v", { shift: true }), mockPrompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 11 }]); + }); + + it("V selects the last line without requiring a trailing newline", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 2, + getCursorOffset: () => 15, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); + }); }); // ── handleVisualKey — motions ────────────────────────────── @@ -874,6 +990,18 @@ describe("handleVisualKey — exit and passthrough", () => { expect(r.consume).toBe(true); expect(r.actions).toEqual([]); }); + + it("leader prefix passes through leader and the following key", () => { + const leader = handleVisualKey(state, "tab", ev("tab", { leader: true })); + expect(leader.consume).toBe(false); + expect(state.passNextKey).toBe(true); + + const r = handleVisualKey(state, "n", ev("n")); + + expect(r.consume).toBe(false); + expect(r.actions).toEqual([]); + expect(state.passNextKey).toBe(false); + }); }); // ── Ctrl+O one-shot normal mode ─────────────────────────── @@ -1082,6 +1210,63 @@ describe("plugin init", () => { }); }); +describe("configured leader passthrough", () => { + async function setup(leader: unknown) { + const plugin = (await import("../src/index")).default; + const editor = { + plainText: "hello world", + cursorOffset: 0, + visualCursor: { logicalRow: 0 }, + cursorStyle: { style: "block" as const, blinking: true }, + insertText: () => {}, + editorView: { resetSelection: () => {} }, + }; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + const api = { + renderer: { currentFocusedEditor: editor, currentFocusedRenderable: editor }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: () => ({ ok: false }), + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + tuiConfig: { keybinds: { leader } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, undefined, undefined as any); + + const press = (name: string, opts: Record = {}) => { + let consumed = false; + handler?.({ event: { name, eventType: "press", ...opts }, consume: () => (consumed = true) }); + return consumed; + }; + + press("escape"); + return { press }; + } + + it("does not consume a configured tab leader or the following key", async () => { + const { press } = await setup("tab"); + + expect(press("tab")).toBe(false); + expect(press("n")).toBe(false); + }); + + it("does not consume the default ctrl+x leader or the following key", async () => { + const { press } = await setup(undefined); + + expect(press("x", { ctrl: true })).toBe(false); + expect(press("n")).toBe(false); + }); +}); + // ── undo snapshot integration ───────────────────────────── describe("undo snapshot — deleteRange + u", () => { @@ -1195,4 +1380,40 @@ describe("undo snapshot — deleteRange + u", () => { await new Promise((r) => setTimeout(r, 20)); expect(dispatched).toContain("input.undo"); }); + + it("u after 3dw restores the full buffer via editBuffer.setText", async () => { + const original = "hello world second line third line"; + const { press, calls, dispatched, getCursor } = await setup(original, 0); + + press("3"); + press("d"); + press("w"); + + calls.length = 0; + press("u"); + + expect(calls).toContainEqual({ method: "setText", args: [original] }); + expect(getCursor()).toBe(0); + expect(dispatched).not.toContain("input.undo"); + }); + + it("u after dw.. walks back each repeated change snapshot", async () => { + const original = "hello world second line third line"; + const { press, calls, dispatched } = await setup(original, 0); + + press("d"); + press("w"); + press("."); + press("."); + + calls.length = 0; + dispatched.length = 0; + + press("u"); + press("u"); + press("u"); + + expect(calls.filter((c) => c.method === "setText")).toHaveLength(3); + expect(dispatched).not.toContain("input.undo"); + }); });