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
75 changes: 75 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

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]
Expand Down Expand Up @@ -978,6 +1038,11 @@ export function createVimHandler(input: {
return true
}

if (verticalMotionOperator(event, key, "c")) {
event.preventDefault()
return true
}

if (wordOperator(event, key, "c")) {
event.preventDefault()
return true
Expand Down Expand Up @@ -1023,6 +1088,11 @@ export function createVimHandler(input: {
return true
}

if (verticalMotionOperator(event, key, "d")) {
event.preventDefault()
return true
}

if (wordOperator(event, key, "d")) {
event.preventDefault()
return true
Expand Down Expand Up @@ -1066,6 +1136,11 @@ export function createVimHandler(input: {
return true
}

if (verticalMotionOperator(event, key, "y")) {
event.preventDefault()
return true
}

if (wordOperator(event, key, "y")) {
event.preventDefault()
return true
Expand Down
91 changes: 91 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading