diff --git a/README.md b/README.md index abb0748bc21e..b81313e7e0bc 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`). **Editing** -`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `C` `c%` `c}` `c{` `S` `J` +`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `S` `J` **yank / put / undo / repeat** diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts index 0c12dc753931..9269cd383d83 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts @@ -359,6 +359,14 @@ export function createVimHandler(input: { return true } + function operatorFind(event: VimEvent, key: string, operation: VimOperator) { + if (key === "f" && !event.shift && !hasModifier(event)) return startOperatorFind(event, operation, "f") + if (isShifted(event, "f") && !hasModifier(event)) return startOperatorFind(event, operation, "F") + if (key === "t" && !event.shift && !hasModifier(event)) return startOperatorFind(event, operation, "t") + if (isShifted(event, "t") && !hasModifier(event)) return startOperatorFind(event, operation, "T") + return false + } + function pendingFindOperator(event: VimEvent): boolean { if (!pendingOperatorFind) return false if (input.state.pending() !== pendingOperatorFind.find) { @@ -677,6 +685,8 @@ export function createVimHandler(input: { return true } + if (operatorFind(event, key, "c")) return true + input.state.clearPending() } @@ -711,13 +721,7 @@ export function createVimHandler(input: { return true } - if (key === "f" && !event.shift && !hasModifier(event)) return startOperatorFind(event, "d", "f") - - if (isShifted(event, "f") && !hasModifier(event)) return startOperatorFind(event, "d", "F") - - if (key === "t" && !event.shift && !hasModifier(event)) return startOperatorFind(event, "d", "t") - - if (isShifted(event, "t") && !hasModifier(event)) return startOperatorFind(event, "d", "T") + if (operatorFind(event, key, "d")) return true input.state.clearPending() } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 185c27a6772c..e04462e96f11 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -3146,6 +3146,117 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("cf changes forward including found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + expect(ctx.state.pending()).toBe("f") + expect(ctx.state.pendingDisplay()).toBe("cf") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(o.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe(" world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("cF changes backward including found char and excluding cursor", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("F").event) + expect(ctx.state.pendingDisplay()).toBe("cF") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(ctx.textarea.plainText).toBe("hello wrld") + expect(ctx.textarea.cursorOffset).toBe(7) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "o", linewise: false }) + }) + + test("ct changes forward up to found char", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("t").event) + expect(ctx.state.pendingDisplay()).toBe("ct") + + const o = createEvent("o") + expect(ctx.handler.handleKey(o.event)).toBe(true) + expect(ctx.textarea.plainText).toBe("o world") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "hell", linewise: false }) + }) + + test("cT changes backward from after found char and excludes cursor", () => { + const ctx = createHandler("abcxdefgh") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("T").event) + expect(ctx.state.pendingDisplay()).toBe("cT") + + const x = createEvent("x") + expect(ctx.handler.handleKey(x.event)).toBe(true) + expect(ctx.textarea.plainText).toBe("abcxfgh") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "de", linewise: false }) + }) + + test("cf not found leaves mode and register unchanged", () => { + const ctx = createHandler("hello") + ctx.state.setRegister({ text: "kept", linewise: false }) + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("z").event) + expect(ctx.textarea.plainText).toBe("hello") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toEqual({ text: "kept", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("cT adjacent target is a no-op", () => { + const ctx = createHandler("ab") + ctx.textarea.cursorOffset = 1 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("T").event) + ctx.handler.handleKey(createEvent("a").event) + expect(ctx.textarea.plainText).toBe("ab") + expect(ctx.textarea.cursorOffset).toBe(1) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.pending()).toBe("") + }) + + test("dot repeats cf inserted text", () => { + const ctx = createHandler("a-b-c") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("-").event) + ctx.textarea.insertText("x") + ctx.handler.handleKey(createEvent("escape").event) + expect(ctx.textarea.plainText).toBe("xb-c") + + ctx.textarea.cursorOffset = 1 + ctx.handler.handleKey(createEvent(".").event) + expect(ctx.textarea.plainText).toBe("xxc") + }) + test("yy yanks current line into register", () => { const ctx = createHandler("one\ntwo\nthree") ctx.textarea.cursorOffset = 5