Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` `diw` `daw` `diW` `daW` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `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` `diW` `daW` `di"` `da"` `di'` `da'` <code>di`</code> <code>da`</code> `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `ci"` `ca"` `ci'` `ca'` <code>ci`</code> <code>ca`</code> `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J`

**yank / put / undo / repeat**

`yy` `yw` `yiw` `yaw` `yiW` `yaW` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`
`yy` `yw` `yiw` `yaw` `yiW` `yaW` `yi"` `ya"` `yi'` `ya'` <code>yi`</code> <code>ya`</code> `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`

- Copy the current prompt selection with `<leader>y` (default: `ctrl+x` then `y`).
- Configure it with `keybinds.prompt_copy_selection`.
Expand Down
16 changes: 12 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 @@ -50,6 +50,7 @@ import {
pasteAfter,
pasteBefore,
previousParagraphOperation,
quoteTextObjectOperation,
prevWordStart,
replaceUnderCursor,
replaceSelection,
Expand Down Expand Up @@ -131,8 +132,11 @@ export function createVimHandler(input: {
function normalizedKeyName(event: VimEvent) {
if (event.name === "slash") return "/"
if (event.name === "at") return "@"
if (event.name === "quote") return '"'
if (event.name === "apostrophe") return "'"
if (event.name === "backtick") return "`"
const text = event.sequence?.length === 1 ? event.sequence : event.raw?.length === 1 ? event.raw : undefined
if (text === "/" || text === "@") return text
if (text === "/" || text === "@" || text === '"' || text === "'" || text === "`") return text
return event.name ?? ""
}

Expand Down Expand Up @@ -237,7 +241,8 @@ export function createVimHandler(input: {
input.state.clearPending()
return false
}
if (next.span) deleteSpan(input.textarea(), next.span)
if (next.span && next.span.end > next.span.start) deleteSpan(input.textarea(), next.span)
if (next.span && next.span.end === next.span.start) input.textarea().cursorOffset = next.span.start
if (next.register) setRegister(next.register)
input.state.clearPending()
if (operation === "c") input.state.setMode("insert")
Expand Down Expand Up @@ -385,11 +390,14 @@ export function createVimHandler(input: {
return false
}

function resolveTextObject(event: VimEvent, key: string, scope: VimTextObjectScope) {
function resolveTextObject(event: VimEvent, key: string, scope: VimTextObjectScope, operation: VimOperator) {
if ((key === "w" || isShifted(event, "w")) && !hasModifier(event)) {
const big = isShifted(event, "w")
return () => wordTextObjectOperation(input.textarea(), scope === "around", big)
}
if ((key === '"' || key === "'" || key === "`") && !hasModifier(event)) {
return () => quoteTextObjectOperation(input.textarea(), scope === "around", key)
}
}

function pendingTextObjectOperator(event: VimEvent, key: string): boolean {
Expand All @@ -400,7 +408,7 @@ export function createVimHandler(input: {
}

const textObject = pendingTextObject
const operation = resolveTextObject(event, key, textObject.scope)
const operation = resolveTextObject(event, key, textObject.scope, textObject.operation)
pendingTextObject = undefined
if (operation) {
applyOperatorResult(operation, textObject.operation)
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,64 @@ function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolea
return { start: blank.start, end }
}

export function quoteTextObjectOperation(textarea: TextareaRenderable, around: boolean, quote: string): VimOperatorResult {
const text = textarea.plainText
if (!text.length) return { span: null, register: null }

const pair = quoteTextObjectPair(text, textarea.cursorOffset, quote)
if (!pair) return { span: null, register: null }

const span = around ? quoteTextObjectAroundSpan(text, pair) : { start: pair.start + 1, end: pair.end }
if (span.start < span.end) return buildOperatorResult(text, span, null, false)
return { span: { start: span.start, end: span.start }, register: { text: "", linewise: false } }
}

function quoteTextObjectAroundSpan(text: string, pair: VimSpan) {
let end = pair.end + 1
while (end < text.length && text[end] !== "\n" && isHorizontalWhitespace(text[end])) end++
if (end > pair.end + 1) return { start: pair.start, end }

let start = pair.start
while (start > 0 && text[start - 1] !== "\n" && isHorizontalWhitespace(text[start - 1])) start--
return { start, end: pair.end + 1 }
}

function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSpan | null {
const start = lineStart(text, cursor)
const end = lineEnd(text, cursor)
const positions = []
for (let position = start; position < end; position++) {
if (text[position] === quote && !isEscaped(text, position)) positions.push(position)
}
if (positions.length < 2) return null

const index = positions.findIndex((position) => position >= cursor)
if (index === -1) return null
if (positions[index] === cursor) {
const pairIndex = index % 2 === 0 ? index : index - 1
const pairEnd = positions[pairIndex + 1]
return pairEnd === undefined ? null : { start: positions[pairIndex]!, end: pairEnd }
}

const previous = positions[index - 1]
if (previous === undefined) {
const pairEnd = positions[1]
return pairEnd === undefined ? null : { start: positions[0]!, end: pairEnd }
}

return { start: previous, end: positions[index]! }
}

function isHorizontalWhitespace(char: string | undefined) {
return char === " " || char === "\t"
}

function isEscaped(text: string, position: number) {
let backslashes = 0
for (let index = position - 1; index >= 0 && text[index] === "\\"; index--) backslashes++
return backslashes % 2 === 1
}

function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) {
if (endOffset <= startOffset) return
const end = Math.min(endOffset, textarea.plainText.length)
Expand Down
232 changes: 232 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2751,6 +2751,238 @@ describe("vim motion handler", () => {
expect(ctx.state.register()).toEqual({ text: "foo.bar", linewise: false })
})

test("di double quote deletes inside quotes", () => {
const ctx = createHandler('say "hello" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "" now')
expect(ctx.textarea.cursorOffset).toBe(5)
expect(ctx.state.register()).toEqual({ text: "hello", linewise: false })
})

test("ca double quote changes around quotes", () => {
const ctx = createHandler('say "hello" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("c").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe("say now")
expect(ctx.textarea.cursorOffset).toBe(4)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: '"hello" ', linewise: false })
})

test("yi single quote yanks inside quotes", () => {
const ctx = createHandler("say 'hello' now")
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("y").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent("'").event)

expect(ctx.textarea.plainText).toBe("say 'hello' now")
expect(ctx.state.register()).toEqual({ text: "hello", linewise: false })
})

test("da backtick deletes around quotes", () => {
const ctx = createHandler("say `hello` now")
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent("`").event)

expect(ctx.textarea.plainText).toBe("say now")
expect(ctx.textarea.cursorOffset).toBe(4)
expect(ctx.state.register()).toEqual({ text: "`hello` ", linewise: false })
})

test("quote text object selects later pair from opening quote", () => {
const ctx = createHandler('"a" "b"')
ctx.textarea.cursorOffset = 4

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('"a" ""')
expect(ctx.textarea.cursorOffset).toBe(5)
expect(ctx.state.register()).toEqual({ text: "b", linewise: false })
})

test("di double quote deletes empty inner quote text", () => {
const ctx = createHandler('say "" now')
ctx.textarea.cursorOffset = 5

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "" now')
expect(ctx.textarea.cursorOffset).toBe(5)
expect(ctx.state.register()).toEqual({ text: "", linewise: false })
})

test("ci double quote changes empty inner quote text", () => {
const ctx = createHandler('say "" now')
ctx.textarea.cursorOffset = 5

ctx.handler.handleKey(createEvent("c").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "" now')
expect(ctx.textarea.cursorOffset).toBe(5)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: "", linewise: false })
})

test("ci double quote from opening empty quote enters between quotes", () => {
const ctx = createHandler('say "" now')
ctx.textarea.cursorOffset = 4

ctx.handler.handleKey(createEvent("c").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "" now')
expect(ctx.textarea.cursorOffset).toBe(5)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: "", linewise: false })
})

test("da double quote deletes around quotes and trailing whitespace", () => {
const ctx = createHandler('say "hello" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe("say now")
expect(ctx.textarea.cursorOffset).toBe(4)
expect(ctx.state.register()).toEqual({ text: '"hello" ', linewise: false })
})

test("da double quote deletes around quotes and leading whitespace at line end", () => {
const ctx = createHandler('say "hello"')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe("say")
expect(ctx.textarea.cursorOffset).toBe(3)
expect(ctx.state.register()).toEqual({ text: ' "hello"', linewise: false })
})

test("ya double quote yanks around quotes and trailing whitespace", () => {
const ctx = createHandler('say "hello" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("y").event)
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "hello" now')
expect(ctx.state.register()).toEqual({ text: '"hello" ', linewise: false })
})

test("quote text object selects surrounding quotes between pairs", () => {
const ctx = createHandler('"a" "b"')
ctx.textarea.cursorOffset = 3

ctx.handler.handleKey(createEvent("c").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)
ctx.textarea.insertText("X")

expect(ctx.textarea.plainText).toBe('"a"X"b"')
expect(ctx.state.register()).toEqual({ text: " ", linewise: false })
})

test("quote text object ignores escaped quotes", () => {
const ctx = createHandler('say "hello \\"world\\"" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("c").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "" now')
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: 'hello \\"world\\"', linewise: false })
})

test("quote text object handles astral Unicode before quotes", () => {
const ctx = createHandler('🙂 "hello" now')
ctx.textarea.cursorOffset = 5

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('🙂 "" now')
expect(ctx.textarea.cursorOffset).toBe(4)
expect(ctx.state.register()).toEqual({ text: "hello", linewise: false })
})

test("quote text object treats double backslash quote as delimiter", () => {
const ctx = createHandler('"a\\\\" "b"')
ctx.textarea.cursorOffset = 4

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('"" "b"')
expect(ctx.state.register()).toEqual({ text: "a\\\\", linewise: false })
})

test("quote text object no-ops when pair is missing", () => {
const ctx = createHandler('say "hello now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "hello now')
expect(ctx.state.register()).toBeNull()
expect(ctx.state.pending()).toBe("")
})

test("quote text object stays on current line", () => {
const ctx = createHandler('say "hello\nworld" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)

expect(ctx.textarea.plainText).toBe('say "hello\nworld" now')
expect(ctx.state.register()).toBeNull()
})

test("quote text object normalizes named quote key", () => {
const ctx = createHandler('say "hello" now')
ctx.textarea.cursorOffset = 6

ctx.handler.handleKey(createEvent("d").event)
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent("quote", { sequence: '"' }).event)

expect(ctx.textarea.plainText).toBe('say "" now')
expect(ctx.state.register()).toEqual({ text: "hello", linewise: false })
})

test("text object pending display shows operator and object scope", () => {
const ctx = createHandler("hello world")

Expand Down
Loading