From ed66d81ab7083e016725caf74a9381434843c5db Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:40:38 +0400 Subject: [PATCH 1/2] feat: add vetical delete operator motions --- .../cli/cmd/tui/component/vim/vim-handler.ts | 75 +++++++++++++++ .../opencode/test/cli/tui/vim-motions.test.ts | 91 +++++++++++++++++++ 2 files changed, 166 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 be9e4b7d66a8..635361b2153c 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 @@ -483,6 +483,66 @@ export function createVimHandler(input: { return substituteLine(textarea, anchor) } + function lineMotionAnchor(direction: "up" | "down", count: number) { + const textarea = input.textarea() + const cursor = textarea.cursorOffset + const start = lineStartOffset(textarea.plainText, cursor) + repeatCount(count, () => (direction === "down" ? moveLineDown(textarea, 0) : moveLineUp(textarea, 0))) + const anchor = textarea.cursorOffset + textarea.cursorOffset = cursor + return lineStartOffset(textarea.plainText, anchor) === start ? null : anchor + } + + function yankLineMotion(direction: "up" | "down", count: number) { + const textarea = input.textarea() + const anchor = lineMotionAnchor(direction, count) + if (anchor === null) return { span: null, register: null } + const cursorLine = lineStartOffset(textarea.plainText, textarea.cursorOffset) + const anchorLine = lineStartOffset(textarea.plainText, anchor) + const span = { + start: Math.min(cursorLine, anchorLine), + end: lineEndOffset(textarea.plainText, Math.max(cursorLine, anchorLine)), + } + return { span, register: { text: textarea.plainText.slice(span.start, span.end), linewise: true } } + } + + function lineMotionOperator(event: VimEvent, key: string, operation: VimOperator): boolean { + const direction = key === "j" || key === "down" ? "down" : key === "k" || key === "up" ? "up" : undefined + if (!direction || event.shift || hasModifier(event)) return false + + const count = takeCount() + if (operation === "y") { + const result = yankLineMotion(direction, count) + if (result.register) setRegister(result.register, true) + if (result.span && result.span.end > result.span.start) input.flash?.(result.span) + input.state.clearPending() + return true + } + + if (operation === "d") { + edit(() => { + const anchor = lineMotionAnchor(direction, count) + const reg = anchor === null ? null : deleteLine(input.textarea(), anchor) + input.state.clearPending() + if (!reg) return false + setRegister(reg) + return true + }) + return true + } + + begin(() => { + const anchor = lineMotionAnchor(direction, count) + const reg = anchor === null ? null : substituteLine(input.textarea(), anchor) + input.state.clearPending() + if (!reg) return false + setRegister(reg) + input.state.setMode("insert") + return true + }) + return true + } + function changeWordOperation(big: boolean, count = 1) { const textarea = input.textarea() const char = textarea.plainText[textarea.cursorOffset] @@ -978,6 +1038,11 @@ export function createVimHandler(input: { return true } + if (lineMotionOperator(event, key, "c")) { + event.preventDefault() + return true + } + if (wordOperator(event, key, "c")) { event.preventDefault() return true @@ -1023,6 +1088,11 @@ export function createVimHandler(input: { return true } + if (lineMotionOperator(event, key, "d")) { + event.preventDefault() + return true + } + if (wordOperator(event, key, "d")) { event.preventDefault() return true @@ -1066,6 +1136,11 @@ export function createVimHandler(input: { return true } + if (lineMotionOperator(event, key, "y")) { + event.preventDefault() + return true + } + if (wordOperator(event, key, "y")) { event.preventDefault() return true diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index e18d83a32128..a41bbdc4a554 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2760,6 +2760,97 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("dj deletes current and next line", () => { + const ctx = createHandler("one\ntwo\nthree\nfour") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("d").event) + const motion = createEvent("j") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\nfour") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "two\nthree", linewise: true }) + }) + + test("dk deletes previous and current line", () => { + const ctx = createHandler("one\ntwo\nthree\nfour") + ctx.textarea.cursorOffset = 11 + + ctx.handler.handleKey(createEvent("d").event) + const motion = createEvent("k") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\nfour") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "two\nthree", linewise: true }) + }) + + test("counted dj deletes through target line", () => { + const ctx = createHandler("one\ntwo\nthree\nfour\nfive") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("d").event) + const motion = createEvent("j") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\nfive") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "two\nthree\nfour", linewise: true }) + }) + + test("yk yanks previous and current line", () => { + const ctx = createHandler("one\ntwo\nthree\nfour") + ctx.textarea.cursorOffset = 11 + + ctx.handler.handleKey(createEvent("y").event) + const motion = createEvent("k") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\ntwo\nthree\nfour") + expect(ctx.textarea.cursorOffset).toBe(11) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "two\nthree", linewise: true }) + }) + + test("cj changes current and next line", () => { + const ctx = createHandler("one\ntwo\nthree\nfour") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("c").event) + const motion = createEvent("j") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\n\nfour") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "two\nthree", linewise: true }) + }) + + test("dk at first line is a no-op", () => { + const ctx = createHandler("one\ntwo") + + ctx.handler.handleKey(createEvent("d").event) + const motion = createEvent("k") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\ntwo") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toBeNull() + }) + test("d$ deletes to end of line", () => { const ctx = createHandler("one\ntwo three\nfour") ctx.textarea.cursorOffset = 5 From 37d8508ddd85b909b37cb4bc82032f3ed316a93f Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:52:24 +0400 Subject: [PATCH 2/2] refactor: rename vertical delete operator helper --- .../opencode/src/cli/cmd/tui/component/vim/vim-handler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 635361b2153c..99d05b597d02 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 @@ -506,7 +506,7 @@ export function createVimHandler(input: { return { span, register: { text: textarea.plainText.slice(span.start, span.end), linewise: true } } } - function lineMotionOperator(event: VimEvent, key: string, operation: VimOperator): boolean { + function verticalMotionOperator(event: VimEvent, key: string, operation: VimOperator): boolean { const direction = key === "j" || key === "down" ? "down" : key === "k" || key === "up" ? "up" : undefined if (!direction || event.shift || hasModifier(event)) return false @@ -1038,7 +1038,7 @@ export function createVimHandler(input: { return true } - if (lineMotionOperator(event, key, "c")) { + if (verticalMotionOperator(event, key, "c")) { event.preventDefault() return true } @@ -1088,7 +1088,7 @@ export function createVimHandler(input: { return true } - if (lineMotionOperator(event, key, "d")) { + if (verticalMotionOperator(event, key, "d")) { event.preventDefault() return true } @@ -1136,7 +1136,7 @@ export function createVimHandler(input: { return true } - if (lineMotionOperator(event, key, "y")) { + if (verticalMotionOperator(event, key, "y")) { event.preventDefault() return true }