Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
findChar,
findCharInLine,
firstNonWhitespace,
getLineColumn,
insertLineStart,
joinLines,
moveBigWordEnd,
Expand All @@ -42,6 +43,7 @@ import {
openLineBelow,
type ParagraphOperation,
type ParagraphResult,
type VimWantedColumn,
pasteAfter,
pasteBefore,
previousParagraphOperation,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -147,6 +167,7 @@ export function createVimHandler(input: {
}

function restore(next: VimSnapshot) {
clearWantedColumn()
clearSelection(input.textarea())
input.state.clearPending()
input.state.setMode("normal")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -860,6 +883,7 @@ export function createVimHandler(input: {

if (key === "$" && !hasModifier(event)) {
moveLineEnd(input.textarea())
wantedColumn = "end"
event.preventDefault()
return true
}
Expand Down
27 changes: 17 additions & 10 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
157 changes: 157 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
Loading