From 3f2b7bcc5e89b6b9e3acc31866057c0aa5ff6c42 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 00:24:47 +0800 Subject: [PATCH 1/9] feat: add word text objects --- .../cli/cmd/tui/component/vim/vim-handler.ts | 44 +++++++++++++++++++ .../cli/cmd/tui/component/vim/vim-motions.ts | 35 +++++++++++++++ 2 files changed, 79 insertions(+) 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 c36c82296252..11adb882cfcd 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 @@ -59,6 +59,7 @@ import { toggleCase, toggleSelectionCase, wordEnd, + wordTextObjectOperation, yankLine, yankLineSpan, yankSelection, @@ -120,6 +121,7 @@ export function createVimHandler(input: { }) { let wantedColumn: VimWantedColumn | undefined let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined + let pendingTextObject: { operation: VimOperator; around: boolean } | undefined function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super @@ -369,6 +371,38 @@ export function createVimHandler(input: { return false } + function startTextObject(event: VimEvent, operation: VimOperator, around: boolean) { + pendingTextObject = { operation, around } + input.state.setPending(operation, operation + (around ? "a" : "i")) + event.preventDefault() + return true + } + + function operatorTextObject(event: VimEvent, key: string, operation: VimOperator) { + if (key === "i" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, false) + if (key === "a" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, true) + return false + } + + function pendingTextObjectOperator(event: VimEvent, key: string): boolean { + if (!pendingTextObject) return false + if (input.state.pending() !== pendingTextObject.operation) { + pendingTextObject = undefined + return false + } + if (key === "w" && !event.shift && !hasModifier(event)) { + const textObject = pendingTextObject + pendingTextObject = undefined + applyOperatorResult(() => wordTextObjectOperation(input.textarea(), textObject.around), textObject.operation) + event.preventDefault() + return true + } + pendingTextObject = undefined + input.state.clearPending() + event.preventDefault() + return true + } + function pendingFindOperator(event: VimEvent): boolean { if (!pendingOperatorFind) return false if (input.state.pending() !== pendingOperatorFind.find) { @@ -440,6 +474,7 @@ export function createVimHandler(input: { } if (pendingFindOperator(event)) return true + if (pendingTextObjectOperator(event, key)) return true if (input.state.pending() === "vr" && input.state.isVisual()) { if (hasModifier(event)) { @@ -694,6 +729,8 @@ export function createVimHandler(input: { return true } + if (operatorTextObject(event, key, "c")) return true + if (paragraphOperator(key, "c")) { event.preventDefault() return true @@ -706,6 +743,7 @@ export function createVimHandler(input: { if (operatorFind(event, key, "c")) return true + pendingTextObject = undefined input.state.clearPending() } @@ -730,6 +768,8 @@ export function createVimHandler(input: { return true } + if (operatorTextObject(event, key, "d")) return true + if (paragraphOperator(key, "d")) { event.preventDefault() return true @@ -742,6 +782,7 @@ export function createVimHandler(input: { if (operatorFind(event, key, "d")) return true + pendingTextObject = undefined input.state.clearPending() } @@ -766,6 +807,8 @@ export function createVimHandler(input: { return true } + if (operatorTextObject(event, key, "y")) return true + if (paragraphOperator(key, "y")) { event.preventDefault() return true @@ -776,6 +819,7 @@ export function createVimHandler(input: { return true } + pendingTextObject = undefined input.state.clearPending() } 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 ff763ca4aa1d..11fd33924d82 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 @@ -404,6 +404,41 @@ export function wordEnd(text: string, offset: number, big: boolean) { return wordRunEnd(text, pos, big) } +export function wordTextObjectOperation(textarea: TextareaRenderable, around: boolean): VimOperatorResult { + const text = textarea.plainText + if (!text.length) return { span: null, register: null } + + const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset) + if (!inner) return { span: null, register: null } + if (!around) return buildOperatorResult(text, inner, null, false) + + let end = inner.end + while (end < text.length && wordClass(text[end], false) === "blank") end++ + if (end > inner.end) return buildOperatorResult(text, { start: inner.start, end }, null, false) + + let start = inner.start + while (start > 0 && wordClass(text[start - 1], false) === "blank") start-- + return buildOperatorResult(text, { start, end: inner.end }, null, false) +} + +function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null { + let pos = Math.min(cursor, text.length - 1) + + if (wordClass(text[pos], false) === "blank") { + while (pos < text.length && wordClass(text[pos], false) === "blank") pos++ + if (pos >= text.length) return null + } + + const target = wordClass(text[pos], false) + let start = pos + while (start > 0 && wordClass(text[start - 1], false) === target) start-- + + let end = pos + 1 + while (end < text.length && wordClass(text[end], false) === target) end++ + + return start < end ? { start, end } : null +} + function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) { if (endOffset <= startOffset) return const end = Math.min(endOffset, textarea.plainText.length) From b1cfeecc6c9911190a8e81c2b72ff7049f482731 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 00:29:11 +0800 Subject: [PATCH 2/9] test: word text objects --- .../opencode/test/cli/tui/vim-motions.test.ts | 168 +++++++++++++++++- 1 file changed, 163 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index a39989cbc29c..8779b8876c3f 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2412,6 +2412,149 @@ describe("vim motion handler", () => { expect(reg).toEqual({ text: "wo", linewise: false }) }) + test("diw deletes inner word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) + expect(ctx.state.pending()).toBe("") + }) + + test("ciw changes inner word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) + }) + + test("diw deletes punctuation text object", () => { + const ctx = createHandler("foo...bar") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("foobar") + expect(ctx.textarea.cursorOffset).toBe(3) + expect(ctx.state.register()).toEqual({ text: "...", linewise: false }) + }) + + test("caw changes punctuation and following whitespace", () => { + const ctx = createHandler("foo... bar") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("a").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("daw deletes word and following whitespace", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) + }) + + test("daw deletes leading whitespace for the final word", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " world", linewise: false }) + }) + + test("caw changes word and following whitespace", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello test") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) + }) + + test("yiw yanks inner word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world test") + expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) + }) + + test("yaw yanks word and following whitespace", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world test") + expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) + }) + + test("text object pending display shows operator and object scope", () => { + const ctx = createHandler("hello world") + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + + expect(ctx.state.pending()).toBe("c") + expect(ctx.state.pendingDisplay()).toBe("ci") + }) + + test("text object invalid target clears pending", () => { + const ctx = createHandler("hello world") + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + const invalid = createEvent("x") + expect(ctx.handler.handleKey(invalid.event)).toBe(true) + expect(invalid.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.pendingDisplay()).toBe("") + }) + test("de deletes to end of word and clears pending", () => { const ctx = createHandler("hello world test") ctx.textarea.cursorOffset = 0 @@ -3746,11 +3889,11 @@ describe("vim motion handler", () => { expect(ctx.handler.handleKey(createEvent("d").event)).toBe(true) expect(ctx.state.pending()).toBe("d") - const i = createEvent("i") - expect(ctx.handler.handleKey(i.event)).toBe(true) - expect(i.prevented()).toBe(true) + const q = createEvent("q") + expect(ctx.handler.handleKey(q.event)).toBe(true) + expect(q.prevented()).toBe(true) expect(ctx.state.pending()).toBe("") - expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.mode()).toBe("normal") }) test("mode switch clears pending state", () => { @@ -3758,7 +3901,7 @@ describe("vim motion handler", () => { expect(ctx.handler.handleKey(createEvent("d").event)).toBe(true) expect(ctx.state.pending()).toBe("d") - expect(ctx.handler.handleKey(createEvent("i").event)).toBe(true) + expect(ctx.handler.handleKey(createEvent("o").event)).toBe(true) expect(ctx.state.mode()).toBe("insert") expect(ctx.state.pending()).toBe("") @@ -5129,6 +5272,21 @@ describe("vim dot repeat", () => { expect(ctx.textarea.plainText).toBe("hi hi") }) + test("dot repeats ciw inserted text", () => { + const ctx = createHandler("hello world") + + press(ctx, "c") + press(ctx, "i") + press(ctx, "w") + ctx.textarea.insertText("hi") + press(ctx, "escape") + expect(ctx.textarea.plainText).toBe("hi world") + + ctx.textarea.cursorOffset = 3 + press(ctx, ".") + expect(ctx.textarea.plainText).toBe("hi hi") + }) + test("dot repeats s inserted text", () => { const ctx = createHandler("abc def") From b08c7281d025e6241046248ad6201c9d99d9fa9e Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 00:29:26 +0800 Subject: [PATCH 3/9] docs: document word text object --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5ec3ebcc20f1..e435481b05fb 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ 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` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` +`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` **yank / put / undo / repeat** -`yy` `yw` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` +`yy` `yw` `yiw` `yaw` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` - Copy the current prompt selection with `y` (default: `ctrl+x` then `y`). - Configure it with `keybinds.prompt_copy_selection`. From d0840acaa9d0cf9f4ba3bcc95f349f6c498b3d32 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 00:47:38 +0800 Subject: [PATCH 4/9] fix: handle blank word text objects --- .../cli/cmd/tui/component/vim/vim-motions.ts | 38 +++++++-- .../opencode/test/cli/tui/vim-motions.test.ts | 78 +++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) 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 11fd33924d82..77098bb06258 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 @@ -408,6 +408,12 @@ export function wordTextObjectOperation(textarea: TextareaRenderable, around: bo const text = textarea.plainText if (!text.length) return { span: null, register: null } + const blank = wordTextObjectBlankSpan(text, textarea.cursorOffset) + if (blank) { + if (!around) return buildOperatorResult(text, blank, null, false) + return buildOperatorResult(text, wordTextObjectAroundBlankSpan(text, blank), null, false) + } + const inner = wordTextObjectInnerSpan(text, textarea.cursorOffset) if (!inner) return { span: null, register: null } if (!around) return buildOperatorResult(text, inner, null, false) @@ -422,13 +428,7 @@ export function wordTextObjectOperation(textarea: TextareaRenderable, around: bo } function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null { - let pos = Math.min(cursor, text.length - 1) - - if (wordClass(text[pos], false) === "blank") { - while (pos < text.length && wordClass(text[pos], false) === "blank") pos++ - if (pos >= text.length) return null - } - + const pos = Math.min(cursor, text.length - 1) const target = wordClass(text[pos], false) let start = pos while (start > 0 && wordClass(text[start - 1], false) === target) start-- @@ -439,6 +439,30 @@ function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null { return start < end ? { start, end } : null } +function wordTextObjectBlankSpan(text: string, cursor: number): VimSpan | null { + let start = Math.min(cursor, text.length - 1) + if (wordClass(text[start], false) !== "blank" || text[start] === "\n") return null + while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], false) === "blank") start-- + + let end = start + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + + return start < end ? { start, end } : null +} + +function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan): VimSpan | null { + let end = blank.end + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + if (end >= text.length || text[end] === "\n") return null + + const inner = wordTextObjectInnerSpan(text, end) + if (!inner) return null + end = inner.end + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ + + return { start: blank.start, end } +} + function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) { if (endOffset <= startOffset) return const end = Math.min(endOffset, textarea.plainText.length) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 8779b8876c3f..2a9b2790f17e 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2519,6 +2519,71 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "world", linewise: false }) }) + test("diw deletes middle whitespace run", () => { + const ctx = createHandler("hello world") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("helloworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("diw deletes trailing whitespace run", () => { + const ctx = createHandler("hello world ") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.textarea.cursorOffset).toBe(11) + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("ciw changes trailing whitespace run", () => { + const ctx = createHandler("hello world ") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world") + expect(ctx.textarea.cursorOffset).toBe(11) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("diw deletes trailing whitespace run before line end", () => { + const ctx = createHandler("hello \nworld") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + + test("yiw yanks trailing whitespace run", () => { + const ctx = createHandler("hello world ") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello world ") + expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) + }) + test("yaw yanks word and following whitespace", () => { const ctx = createHandler("hello world test") ctx.textarea.cursorOffset = 8 @@ -2531,6 +2596,19 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "world ", linewise: false }) }) + test("daw deletes middle whitespace with following word", () => { + const ctx = createHandler("hello world test") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hellotest") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: " world ", linewise: false }) + }) + test("text object pending display shows operator and object scope", () => { const ctx = createHandler("hello world") From 28bdcec0c5d0370f4138e50477a02439dd6e136b Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 00:51:15 +0800 Subject: [PATCH 5/9] refactor: generalize text object dispatch --- .../cli/cmd/tui/component/vim/vim-handler.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 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 11adb882cfcd..ff0d60697cd3 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 @@ -78,6 +78,7 @@ export type VimEvent = { export type VimCopyMove = "up" | "down" | "left" | "right" type VimFindOperator = "f" | "F" | "t" | "T" +type VimTextObjectScope = "inner" | "around" export function createVimHandler(input: { enabled: Accessor @@ -121,7 +122,7 @@ export function createVimHandler(input: { }) { let wantedColumn: VimWantedColumn | undefined let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined - let pendingTextObject: { operation: VimOperator; around: boolean } | undefined + let pendingTextObject: { operation: VimOperator; scope: VimTextObjectScope } | undefined function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super @@ -371,33 +372,41 @@ export function createVimHandler(input: { return false } - function startTextObject(event: VimEvent, operation: VimOperator, around: boolean) { - pendingTextObject = { operation, around } - input.state.setPending(operation, operation + (around ? "a" : "i")) + function startTextObject(event: VimEvent, operation: VimOperator, scope: VimTextObjectScope) { + pendingTextObject = { operation, scope } + input.state.setPending(operation, operation + (scope === "around" ? "a" : "i")) event.preventDefault() return true } function operatorTextObject(event: VimEvent, key: string, operation: VimOperator) { - if (key === "i" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, false) - if (key === "a" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, true) + if (key === "i" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, "inner") + if (key === "a" && !event.shift && !hasModifier(event)) return startTextObject(event, operation, "around") return false } + function resolveTextObject(event: VimEvent, key: string, scope: VimTextObjectScope) { + if (key === "w" && !event.shift && !hasModifier(event)) { + return () => wordTextObjectOperation(input.textarea(), scope === "around") + } + } + function pendingTextObjectOperator(event: VimEvent, key: string): boolean { if (!pendingTextObject) return false if (input.state.pending() !== pendingTextObject.operation) { pendingTextObject = undefined return false } - if (key === "w" && !event.shift && !hasModifier(event)) { - const textObject = pendingTextObject - pendingTextObject = undefined - applyOperatorResult(() => wordTextObjectOperation(input.textarea(), textObject.around), textObject.operation) + + const textObject = pendingTextObject + const result = resolveTextObject(event, key, textObject.scope) + pendingTextObject = undefined + if (result) { + applyOperatorResult(result, textObject.operation) event.preventDefault() return true } - pendingTextObject = undefined + input.state.clearPending() event.preventDefault() return true From 9c5a13a23579e552dc21ffdd70de3c00e73649bf Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 00:53:26 +0800 Subject: [PATCH 6/9] refactor: renamed result to operation in pendingTextObjectOperator --- .../opencode/src/cli/cmd/tui/component/vim/vim-handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 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 ff0d60697cd3..e7f05c799012 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 @@ -399,10 +399,10 @@ export function createVimHandler(input: { } const textObject = pendingTextObject - const result = resolveTextObject(event, key, textObject.scope) + const operation = resolveTextObject(event, key, textObject.scope) pendingTextObject = undefined - if (result) { - applyOperatorResult(result, textObject.operation) + if (operation) { + applyOperatorResult(operation, textObject.operation) event.preventDefault() return true } From edbd18359e0a8c6d44e983224d971fcdab3f74d3 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 01:00:40 +0800 Subject: [PATCH 7/9] fix: ignore newlines for word text objects --- .../cli/cmd/tui/component/vim/vim-motions.ts | 1 + .../opencode/test/cli/tui/vim-motions.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) 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 77098bb06258..578f2630f6f8 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 @@ -429,6 +429,7 @@ export function wordTextObjectOperation(textarea: TextareaRenderable, around: bo function wordTextObjectInnerSpan(text: string, cursor: number): VimSpan | null { const pos = Math.min(cursor, text.length - 1) + if (text[pos] === "\n") return null const target = wordClass(text[pos], false) let start = pos while (start > 0 && wordClass(text[start - 1], false) === target) start-- diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 2a9b2790f17e..6d9e9af6e3ba 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2572,6 +2572,33 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: " ", linewise: false }) }) + test("diw on newline does not join lines", () => { + const ctx = createHandler("hello\nworld") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toBeNull() + }) + + test("ciw on newline does not enter insert", () => { + const ctx = createHandler("hello\nworld") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toBeNull() + }) + test("yiw yanks trailing whitespace run", () => { const ctx = createHandler("hello world ") ctx.textarea.cursorOffset = 12 From 1a6a0a8c9c91ea87e19e73d0200d2a9316239aee Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 01:06:49 +0800 Subject: [PATCH 8/9] fix: keep word text objects within lines --- .../cli/cmd/tui/component/vim/vim-motions.ts | 4 +-- .../opencode/test/cli/tui/vim-motions.test.ts | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 578f2630f6f8..7320a342fae6 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 @@ -419,11 +419,11 @@ export function wordTextObjectOperation(textarea: TextareaRenderable, around: bo if (!around) return buildOperatorResult(text, inner, null, false) let end = inner.end - while (end < text.length && wordClass(text[end], false) === "blank") end++ + while (end < text.length && text[end] !== "\n" && wordClass(text[end], false) === "blank") end++ if (end > inner.end) return buildOperatorResult(text, { start: inner.start, end }, null, false) let start = inner.start - while (start > 0 && wordClass(text[start - 1], false) === "blank") start-- + while (start > 0 && text[start - 1] !== "\n" && wordClass(text[start - 1], false) === "blank") start-- return buildOperatorResult(text, { start, end: inner.end }, null, false) } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 6d9e9af6e3ba..b1223e49384c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2636,6 +2636,31 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: " world ", linewise: false }) }) + test("daw does not delete newline after word", () => { + const ctx = createHandler("hello\nworld") + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("\nworld") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("daw does not delete newline before word", () => { + const ctx = createHandler("hello\n world") + ctx.textarea.cursorOffset = 9 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\n") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: " world", linewise: false }) + }) + test("text object pending display shows operator and object scope", () => { const ctx = createHandler("hello world") From 280db8325ec4fb3dbf9f2baaa08391061df74b2a Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 18 May 2026 01:08:38 +0800 Subject: [PATCH 9/9] test: cover word text objects on newlines --- packages/opencode/test/cli/tui/vim-motions.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index b1223e49384c..c5a787a19d21 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2599,6 +2599,19 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toBeNull() }) + test("yiw on newline does not yank", () => { + const ctx = createHandler("hello\nworld") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("w").event) + + expect(ctx.textarea.plainText).toBe("hello\nworld") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toBeNull() + }) + test("yiw yanks trailing whitespace run", () => { const ctx = createHandler("hello world ") ctx.textarea.cursorOffset = 12