From 443171a23915130264712f45d14433704fdee06a Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Mon, 27 Apr 2026 20:34:56 -0500 Subject: [PATCH] fix: treat punctuation as its own word in vim motions * w/b/W now class punctuation, words, and blanks separately so motions land on punctuation boundaries * cw/cW change to end of word like vim instead of deleting through next word start --- .../cli/cmd/tui/component/vim/vim-handler.ts | 41 +++- .../cli/cmd/tui/component/vim/vim-motions.ts | 36 ++-- .../opencode/test/cli/tui/vim-motions.test.ts | 176 +++++++++++++++++- 3 files changed, 231 insertions(+), 22 deletions(-) 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 2a6f37872740..a0ccb9324837 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 @@ -211,6 +211,12 @@ export function createVimHandler(input: { return true } + function changeWord(big: boolean) { + const textarea = input.textarea() + const char = textarea.plainText[textarea.cursorOffset] + return char && !/\s/.test(char) ? deleteWordEnd(textarea, big) : deleteWord(textarea) + } + function undo() { if (!tracked()) return false const next = input.state.undo(snapshot()) @@ -407,7 +413,18 @@ export function createVimHandler(input: { if (key === "w" && !event.shift) { begin(() => { - const reg = deleteWord(input.textarea()) + const reg = changeWord(false) + if (reg) setRegister(reg) + input.state.clearPending() + input.state.setMode("insert") + }) + event.preventDefault() + return true + } + + if (isShifted(event, "w") && !hasModifier(event)) { + begin(() => { + const reg = changeWord(true) if (reg) setRegister(reg) input.state.clearPending() input.state.setMode("insert") @@ -473,7 +490,17 @@ export function createVimHandler(input: { return true } - if (key === "b" && !event.shift) { + if (isShifted(event, "w") && !hasModifier(event)) { + edit(() => { + const reg = deleteWord(input.textarea(), true) + if (reg) setRegister(reg) + input.state.clearPending() + }) + event.preventDefault() + return true + } + + if (key === "b" && !event.shift && !hasModifier(event)) { edit(() => { const reg = deleteWordBackward(input.textarea()) if (reg) setRegister(reg) @@ -528,6 +555,16 @@ export function createVimHandler(input: { return true } + if (isShifted(event, "w") && !hasModifier(event)) { + const span = yankWordSpan(input.textarea(), true) + const reg = yankWord(input.textarea(), true) + if (reg) setRegister(reg, true) + if (span && span.end > span.start) input.flash?.(span) + input.state.clearPending() + event.preventDefault() + return true + } + if ((key === "e" || key === "E") && !hasModifier(event)) { const big = key === "E" || !!event.shift const span = yankWordEndSpan(input.textarea(), big) diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts index bdc1d4d3ceac..99eb882adc2d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts @@ -282,20 +282,28 @@ export function isBigWord(char: string) { } export function nextWordStart(text: string, offset: number, big: boolean) { - const match = big ? isBigWord : isWord let pos = offset - if (pos < text.length && match(text[pos])) { - while (pos < text.length && match(text[pos])) pos++ + if (pos >= text.length) return text.length + + const startClass = wordClass(text[pos], big) + if (startClass !== "blank") { + while (pos < text.length && wordClass(text[pos], big) === startClass) pos++ } - while (pos < text.length && !match(text[pos])) pos++ + + while (pos < text.length && wordClass(text[pos], big) === "blank") pos++ return pos } export function prevWordStart(text: string, offset: number, big: boolean) { - const match = big ? isBigWord : isWord - let pos = offset - while (pos > 0 && !match(text[pos - 1])) pos-- - while (pos > 0 && match(text[pos - 1])) pos-- + let pos = Math.min(offset, text.length) + if (pos <= 0) return 0 + pos-- + + while (pos > 0 && wordClass(text[pos], big) === "blank") pos-- + + const target = wordClass(text[pos], big) + while (pos > 0 && wordClass(text[pos - 1], big) === target) pos-- + return pos } @@ -518,10 +526,10 @@ export function deleteUnderCursor(textarea: TextareaRenderable): VimRegister { return { text: yanked, linewise: false } } -export function deleteWord(textarea: TextareaRenderable): VimRegister { +export function deleteWord(textarea: TextareaRenderable, big = false): VimRegister { const text = textarea.plainText const startOffset = textarea.cursorOffset - const endOffset = nextWordStart(text, startOffset, false) + const endOffset = nextWordStart(text, startOffset, big) if (endOffset <= startOffset) return null const yanked = text.slice(startOffset, endOffset) deleteOffsets(textarea, startOffset, endOffset) @@ -688,16 +696,16 @@ export function yankLineSpan(textarea: TextareaRenderable): VimSpan { return { start, end } } -export function yankWord(textarea: TextareaRenderable): VimRegister { - const span = yankWordSpan(textarea) +export function yankWord(textarea: TextareaRenderable, big = false): VimRegister { + const span = yankWordSpan(textarea, big) if (!span) return null return { text: textarea.plainText.slice(span.start, span.end), linewise: false } } -export function yankWordSpan(textarea: TextareaRenderable): VimSpan | null { +export function yankWordSpan(textarea: TextareaRenderable, big = false): VimSpan | null { const text = textarea.plainText const start = textarea.cursorOffset - const end = nextWordStart(text, start, false) + const end = nextWordStart(text, start, big) if (end <= start) return null return { start, end } } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 9fa609b12e1b..b3ae52fbda11 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -473,7 +473,7 @@ describe("vim motion handler", () => { const w = createEvent("w") expect(ctx.handler.handleKey(w.event)).toBe(true) expect(w.prevented()).toBe(true) - expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.textarea.cursorOffset).toBe(3) const upperW = createEvent("W") expect(ctx.handler.handleKey(upperW.event)).toBe(true) @@ -574,11 +574,36 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) - test("b skips punctuation to previous word", () => { + test("b treats punctuation as its own word", () => { const ctx = createHandler("foo,bar") ctx.textarea.cursorOffset = 4 + ctx.handler.handleKey(createEvent("b").event) - expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.textarea.cursorOffset).toBe(3) + }) + + test("w lands on trailing punctuation", () => { + const ctx = createHandler("changed?") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.textarea.cursorOffset).toBe(7) + }) + + test("w advances from punctuation to next word", () => { + const ctx = createHandler("changed? next") + ctx.textarea.cursorOffset = 7 + + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.textarea.cursorOffset).toBe(9) + }) + + test("W treats punctuation as part of big word", () => { + const ctx = createHandler("changed? next") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("W").event) + expect(ctx.textarea.cursorOffset).toBe(9) }) test("0 moves to line beginning", () => { @@ -1258,7 +1283,7 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) - test("cw deletes to next word and enters insert", () => { + test("cw changes to end of word and enters insert", () => { const ctx = createHandler("hello world test") ctx.textarea.cursorOffset = 0 @@ -1269,10 +1294,73 @@ describe("vim motion handler", () => { const w = createEvent("w") expect(ctx.handler.handleKey(w.event)).toBe(true) expect(w.prevented()).toBe(true) - expect(ctx.textarea.plainText).toBe("world test") + expect(ctx.textarea.plainText).toBe(" world test") expect(ctx.textarea.cursorOffset).toBe(0) expect(ctx.state.mode()).toBe("insert") expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("cw from mid-word changes to end of word", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.textarea.plainText).toBe("he world") + expect(ctx.textarea.cursorOffset).toBe(2) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "llo", linewise: false }) + }) + + test("cw on punctuation changes punctuation word", () => { + const ctx = createHandler("foo!!!bar") + ctx.textarea.cursorOffset = 3 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.textarea.plainText).toBe("foobar") + expect(ctx.textarea.cursorOffset).toBe(3) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "!!!", linewise: false }) + }) + + test("cw from whitespace changes through next word start", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.textarea.plainText).toBe("helloworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("cW changes through end of big word and enters insert", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe(" baz") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) + }) + + test("cW from whitespace changes through next big word start", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 7 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("W").event) + expect(ctx.textarea.plainText).toBe("foo.barbaz") + expect(ctx.textarea.cursorOffset).toBe(7) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) }) test("pending c clears on escape", () => { @@ -1630,6 +1718,42 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("dw stops before trailing punctuation", () => { + const ctx = createHandler("changed?") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.textarea.plainText).toBe("?") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "changed", linewise: false }) + }) + + test("dW deletes through next big word start", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("baz") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false }) + }) + + test("dW at final big word deletes to end", () => { + const ctx = createHandler("foo.bar") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("W").event) + expect(ctx.textarea.plainText).toBe("") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false }) + }) + test("db deletes to current word start", () => { const ctx = createHandler("hello world test") ctx.textarea.cursorOffset = 8 @@ -2245,6 +2369,46 @@ describe("vim motion handler", () => { expect(ctx.textarea.plainText).toBe("hello world") }) + test("yw stops before trailing punctuation", () => { + const ctx = createHandler("changed?") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("w").event) + expect(ctx.state.register()).toEqual({ text: "changed", linewise: false }) + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.textarea.plainText).toBe("changed?") + }) + + test("yW yanks through next big word start", () => { + const ctx = createHandler("foo.bar baz") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("y").event) + const w = createEvent("W") + expect(ctx.handler.handleKey(w.event)).toBe(true) + expect(w.prevented()).toBe(true) + expect(ctx.state.register()).toEqual({ text: "foo.bar ", linewise: false }) + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.textarea.plainText).toBe("foo.bar baz") + expect(ctx.state.pending()).toBe("") + }) + + test("yW flashes yanked big word span", () => { + const spans: Array<{ start: number; end: number }> = [] + const ctx = createHandler("foo.bar baz", { + flash(span) { + spans.push(span) + }, + }) + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("W").event) + + expect(spans).toEqual([{ start: 0, end: 8 }]) + }) + test("yw flashes yanked word span", () => { const spans: Array<{ start: number; end: number }> = [] const ctx = createHandler("hello world", { @@ -3821,7 +3985,7 @@ describe("vim undo redo", () => { ctx.textarea.insertText("hi") ctx.handler.handleKey(createEvent("escape").event) - expect(ctx.textarea.plainText).toBe("hiworld") + expect(ctx.textarea.plainText).toBe("hi world") ctx.handler.handleKey(createEvent("u").event) expect(ctx.textarea.plainText).toBe("hello world")