From 88f5d2026efcbb27d90b191e529c778e528fc0de Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Wed, 29 Apr 2026 14:35:03 -0500 Subject: [PATCH] feat: preserve desired column when moving vertically in prompt * keep wanted column across j/k and arrow up/down so short lines don't clamp the cursor * make $ stick to line end on subsequent vertical moves * preserve column when toggling visual/visual-line mode --- .../cli/cmd/tui/component/vim/vim-handler.ts | 32 +++- .../cli/cmd/tui/component/vim/vim-motions.ts | 27 +-- .../opencode/test/cli/tui/vim-motions.test.ts | 157 ++++++++++++++++++ 3 files changed, 202 insertions(+), 14 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 a0ccb9324837..3cb834ff3720 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 @@ -19,6 +19,7 @@ import { findChar, findCharInLine, firstNonWhitespace, + getLineColumn, insertLineStart, joinLines, moveBigWordEnd, @@ -42,6 +43,7 @@ import { openLineBelow, type ParagraphOperation, type ParagraphResult, + type VimWantedColumn, pasteAfter, pasteBefore, previousParagraphOperation, @@ -104,6 +106,8 @@ export function createVimHandler(input: { register?: () => VimRegister setRegister?: (register: VimRegister, notify?: boolean) => void }) { + let wantedColumn: VimWantedColumn | undefined + function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super } @@ -138,6 +142,22 @@ export function createVimHandler(input: { input.state.setRegister(next) } + function clearWantedColumn() { + wantedColumn = undefined + } + + function moveVertical(direction: "up" | "down") { + const column = wantedColumn ?? getLineColumn(input.textarea()) + if (direction === "up") moveLineUp(input.textarea(), column) + else moveLineDown(input.textarea(), column) + wantedColumn = column + } + + function preservesWantedColumn(event: VimEvent, key: string) { + if ((key === "j" || key === "k" || key === "down" || key === "up") && !event.shift && !hasModifier(event)) return true + return (key === "v" || isShifted(event, "v")) && !hasModifier(event) + } + function snapshot(): VimSnapshot { if (input.snapshot) return input.snapshot() return { @@ -147,6 +167,7 @@ export function createVimHandler(input: { } function restore(next: VimSnapshot) { + clearWantedColumn() clearSelection(input.textarea()) input.state.clearPending() input.state.setMode("normal") @@ -234,6 +255,8 @@ export function createVimHandler(input: { } function dispatch(event: VimEvent, key: string): boolean { + if (!preservesWantedColumn(event, key)) clearWantedColumn() + const scroll = vimScroll(event) if (scroll) { input.state.clearPending() @@ -816,8 +839,8 @@ export function createVimHandler(input: { return true } - if (key === "j" && !event.shift && !hasModifier(event)) { - moveLineDown(input.textarea()) + if ((key === "j" || key === "down") && !event.shift && !hasModifier(event)) { + moveVertical("down") event.preventDefault() return true } @@ -840,8 +863,8 @@ export function createVimHandler(input: { return true } - if (key === "k" && !event.shift && !hasModifier(event)) { - moveLineUp(input.textarea()) + if ((key === "k" || key === "up") && !event.shift && !hasModifier(event)) { + moveVertical("up") event.preventDefault() return true } @@ -860,6 +883,7 @@ export function createVimHandler(input: { if (key === "$" && !hasModifier(event)) { moveLineEnd(input.textarea()) + wantedColumn = "end" event.preventDefault() return true } 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 99eb882adc2d..cc6b9bf5384d 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 @@ -3,6 +3,7 @@ import type { VimRegister } from "./vim-state" export type VimSpan = { start: number; end: number } export type VimCopyRow = { col: number } +export type VimWantedColumn = number | "end" function lineStart(text: string, offset: number) { if (offset <= 0) return 0 @@ -86,24 +87,30 @@ function previousParagraphTarget(text: string, cursor: number): number { return 0 } -function moveUp(text: string, offset: number) { - const currentStart = lineStart(text, offset) +function lineColumn(text: string, offset: number) { + return offset - lineStart(text, offset) +} + +function moveUp(text: string, offset: number, column: VimWantedColumn = lineColumn(text, offset)) { const targetStart = prevLineStart(text, offset) if (targetStart === undefined) return offset const targetLast = lineLast(text, targetStart) - const col = offset - currentStart + const col = column === "end" ? targetLast - targetStart : column return Math.min(targetStart + col, targetLast) } -function moveDown(text: string, offset: number) { - const currentStart = lineStart(text, offset) +function moveDown(text: string, offset: number, column: VimWantedColumn = lineColumn(text, offset)) { const targetStart = nextLineStart(text, offset) if (targetStart === undefined) return offset const targetLast = lineLast(text, targetStart) - const col = offset - currentStart + const col = column === "end" ? targetLast - targetStart : column return Math.min(targetStart + col, targetLast) } +export function getLineColumn(textarea: TextareaRenderable) { + return lineColumn(textarea.plainText, textarea.cursorOffset) +} + export function moveLeft(textarea: TextareaRenderable) { const text = textarea.plainText const start = lineStart(text, textarea.cursorOffset) @@ -137,14 +144,14 @@ export function moveRight(textarea: TextareaRenderable) { textarea.cursorOffset = Math.min(last, textarea.cursorOffset + 1) } -export function moveLineUp(textarea: TextareaRenderable) { +export function moveLineUp(textarea: TextareaRenderable, column?: VimWantedColumn) { const text = textarea.plainText - textarea.cursorOffset = moveUp(text, textarea.cursorOffset) + textarea.cursorOffset = moveUp(text, textarea.cursorOffset, column) } -export function moveLineDown(textarea: TextareaRenderable) { +export function moveLineDown(textarea: TextareaRenderable, column?: VimWantedColumn) { const text = textarea.plainText - textarea.cursorOffset = moveDown(text, textarea.cursorOffset) + textarea.cursorOffset = moveDown(text, textarea.cursorOffset, column) } export function movePreviousParagraph(textarea: TextareaRenderable) { diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index b3ae52fbda11..1498eb3fdc9a 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -467,6 +467,77 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 3, 0)) }) + test("j and k preserve desired column across short lines", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 1, 0)) + + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 5)) + + ctx.handler.handleKey(createEvent("k").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 1, 0)) + + ctx.handler.handleKey(createEvent("k").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 0, 5)) + }) + + test("arrow up and down preserve desired column across short lines", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 4) + + ctx.handler.handleKey(createEvent("down").event) + ctx.handler.handleKey(createEvent("down").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 4)) + + ctx.handler.handleKey(createEvent("up").event) + ctx.handler.handleKey(createEvent("up").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 0, 4)) + }) + + test("non-vertical motion resets desired column", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("j").event) + ctx.handler.handleKey(createEvent("h").event) + ctx.handler.handleKey(createEvent("j").event) + + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 0)) + }) + + test("shifted j join resets desired column", () => { + const text = "abcdef\nx\nabc\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("j").event) + ctx.handler.handleKey(createEvent("j", { shift: true }).event) + ctx.handler.handleKey(createEvent("j").event) + + expect(ctx.textarea.plainText).toBe("abcdef\nx abc\nabcdef") + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(ctx.textarea.plainText, 2, 1)) + }) + + test("$ makes vertical movement stick to line end", () => { + const text = "abc\ndefgh\nxy" + const ctx = createHandler(text) + + ctx.handler.handleKey(createEvent("$").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 0, 2)) + + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 1, 4)) + + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 1)) + }) + test("supports word and big-word key shapes", () => { const ctx = createHandler("foo,bar baz") @@ -2925,6 +2996,92 @@ describe("vim motion handler", () => { expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 0, end: 7 }) }) + test("visual j preserves desired column across short lines", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 1, 0)) + + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 5)) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ + start: rowColToOffset(text, 0, 5), + end: rowColToOffset(text, 2, 5) + 1, + }) + }) + + test("visual arrow down preserves desired column across short lines", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("down").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 1, 0)) + + ctx.handler.handleKey(createEvent("down").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 5)) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ + start: rowColToOffset(text, 0, 5), + end: rowColToOffset(text, 2, 5) + 1, + }) + + ctx.handler.handleKey(createEvent("up").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 1, 0)) + + ctx.handler.handleKey(createEvent("up").event) + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 0, 5)) + }) + + test("entering visual mode preserves desired column", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("j").event) + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("j").event) + + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 5)) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ + start: rowColToOffset(text, 1, 0), + end: rowColToOffset(text, 2, 5) + 1, + }) + }) + + test("entering visual-line mode preserves desired column", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("j").event) + ctx.handler.handleKey(createEvent("V").event) + ctx.handler.handleKey(createEvent("j").event) + + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 5)) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ + start: rowColToOffset(text, 1, 0), + end: text.length, + }) + }) + + test("exiting visual mode with v preserves desired column", () => { + const text = "abcdef\nx\nabcdef" + const ctx = createHandler(text) + ctx.textarea.cursorOffset = rowColToOffset(text, 0, 5) + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("j").event) + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("j").event) + + expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(text, 2, 5)) + expect(ctx.state.mode()).toBe("normal") + }) + test("v then escape exits visual mode", () => { const ctx = createHandler("hello world") ctx.textarea.cursorOffset = 2