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
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` `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`
`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> `di(` `da(` `di[` `da[` `di{` `da{` `di<` `da<` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `ci"` `ca"` `ci'` `ca'` <code>ci`</code> <code>ca`</code> `ci(` `ca(` `ci[` `ca[` `ci{` `ca{` `ci<` `ca<` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J`

**yank / put / undo / repeat**

`yy` `yw` `yiw` `yaw` `yiW` `yaW` `yi"` `ya"` `yi'` `ya'` <code>yi`</code> <code>ya`</code> `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`
`yy` `yw` `yiw` `yaw` `yiW` `yaW` `yi"` `ya"` `yi'` `ya'` <code>yi`</code> <code>ya`</code> `yi(` `ya(` `yi[` `ya[` `yi{` `ya{` `yi<` `ya<` `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
14 changes: 13 additions & 1 deletion 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,
bracketTextObjectOperation,
quoteTextObjectOperation,
prevWordStart,
replaceUnderCursor,
Expand Down Expand Up @@ -136,7 +137,15 @@ export function createVimHandler(input: {
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 === "@" || text === '"' || text === "'" || text === "`") return text
if (text && (text === "/" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text
if (event.shift) {
if (event.name === "9") return "("
if (event.name === "0") return ")"
if (event.name === "[") return "{"
if (event.name === "]") return "}"
if (event.name === ",") return "<"
if (event.name === ".") return ">"
}
return event.name ?? ""
}

Expand Down Expand Up @@ -398,6 +407,9 @@ export function createVimHandler(input: {
if ((key === '"' || key === "'" || key === "`") && !hasModifier(event)) {
return () => quoteTextObjectOperation(input.textarea(), scope === "around", key)
}
if ("()[]{}<>".includes(key) && !hasModifier(event)) {
return () => bracketTextObjectOperation(input.textarea(), scope === "around", key, operation)
}
}

function pendingTextObjectOperator(event: VimEvent, key: string): boolean {
Expand Down
85 changes: 85 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,91 @@ function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolea
return { start: blank.start, end }
}

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

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

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

function bracketTextObjectInnerSpan(text: string, pair: VimSpan, operation: VimOperator) {
const start = pair.start + 1
const end = pair.end
if (text[start] === "\n" && text[end - 1] === "\n") return { start: start + 1, end: operation === "c" ? end - 1 : end }
return { start, end }
}

function bracketTextObjectPair(text: string, cursor: number, bracket: string): VimSpan | null {
const pair = bracketTextObjectPairChars(bracket)
if (!pair) return null

const containing = bracketTextObjectContainingPair(text, cursor, 0, text.length, pair.open, pair.close)
if (containing) return containing

const pairStart = bracketTextObjectOpenAfterCursor(text, cursor, text.length, pair.open)
if (pairStart === null) return null

const pairEnd = bracketTextObjectClose(text, pairStart, text.length, pair.open, pair.close)
return pairEnd === null ? null : { start: pairStart, end: pairEnd }
}

function bracketTextObjectPairChars(bracket: string) {
if (bracket === "(" || bracket === ")") return { open: "(", close: ")" }
if (bracket === "[" || bracket === "]") return { open: "[", close: "]" }
if (bracket === "{" || bracket === "}") return { open: "{", close: "}" }
if (bracket === "<" || bracket === ">") return { open: "<", close: ">" }
return null
}

function bracketTextObjectContainingPair(
text: string,
cursor: number,
start: number,
end: number,
open: string,
close: string,
): VimSpan | null {
const stack = []
let result: VimSpan | null = null
for (let index = start; index < end; index++) {
if (text[index] === open) stack.push(index)
if (text[index] !== close) continue

const pairStart = stack.pop()
if (pairStart === undefined || pairStart > cursor || index < cursor) continue
if (!result || pairStart > result.start) result = { start: pairStart, end: index }
}
return result
}

function bracketTextObjectOpenAfterCursor(text: string, cursor: number, end: number, open: string) {
const index = text.indexOf(open, cursor)
return index === -1 || index >= end ? null : index
}

function bracketTextObjectClose(text: string, start: number, end: number, open: string, close: string) {
let depth = 0
for (let index = start; index < end; index++) {
if (text[index] === open) depth++
if (text[index] === close) {
depth--
if (depth === 0) return index
}
}
return null
}

export function quoteTextObjectOperation(textarea: TextareaRenderable, around: boolean, quote: string): VimOperatorResult {
const text = textarea.plainText
if (!text.length) return { span: null, register: null }
Expand Down
207 changes: 207 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2983,6 +2983,213 @@ describe("vim motion handler", () => {
expect(ctx.state.register()).toEqual({ text: "hello", linewise: false })
})

test("di parenthesis deletes inside brackets", () => {
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 square bracket changes around brackets", () => {
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 curly bracket yanks inside brackets", () => {
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 angle bracket deletes around brackets", () => {
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("bracket text object selects nested pair", () => {
const ctx = createHandler("(a (b) c)")
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 () c)")
expect(ctx.state.register()).toEqual({ text: "b", linewise: false })
})

test("bracket text object selects containing pair after nested pair", () => {
const ctx = createHandler("(a (b) c)")
ctx.textarea.cursorOffset = 7

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

expect(ctx.textarea.plainText).toBe("()")
expect(ctx.textarea.cursorOffset).toBe(1)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: "a (b) c", linewise: false })
})

test("bracket text object handles many unmatched openers", () => {
const ctx = createHandler("(".repeat(500) + "hello")
ctx.textarea.cursorOffset = 502

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

expect(ctx.textarea.plainText).toBe("(".repeat(500) + "hello")
expect(ctx.state.register()).toBeNull()
expect(ctx.state.pending()).toBe("")
})

test("ci parenthesis from opening empty pair enters between brackets", () => {
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("bracket text object finds pair after cursor", () => {
const ctx = createHandler("say before (hello) now")
ctx.textarea.cursorOffset = 0

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

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

test("bracket text object no-ops after pair", () => {
const ctx = createHandler("say (hello) now")
ctx.textarea.cursorOffset = 12

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("bracket text object spans multiple lines", () => {
const ctx = createHandler("call(\n hello\n)")
ctx.textarea.cursorOffset = 8

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

expect(ctx.textarea.plainText).toBe("call(\n)")
expect(ctx.textarea.cursorOffset).toBe(6)
expect(ctx.state.register()).toEqual({ text: " hello\n", linewise: false })
})

test("change bracket text object spans multiple lines", () => {
const ctx = createHandler("call(\n hello\n)")
ctx.textarea.cursorOffset = 8

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

expect(ctx.textarea.plainText).toBe("call(\n\n)")
expect(ctx.textarea.cursorOffset).toBe(6)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.state.register()).toEqual({ text: " hello\n", linewise: false })
})

test("yank around bracket text object spans multiple lines", () => {
const ctx = createHandler("call(\n hello\n)")
ctx.textarea.cursorOffset = 8

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

expect(ctx.textarea.plainText).toBe("call(\n hello\n)")
expect(ctx.state.register()).toEqual({ text: "(\n hello\n)", linewise: false })
})

test("bracket text object normalizes shifted bracket 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("[", { shift: true, sequence: "{" }).event)

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

test("bracket text object normalizes shifted bracket key without sequence", () => {
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("[", { shift: true }).event)

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

test("bracket text object normalizes shifted parenthesis key without sequence", () => {
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("9", { shift: true }).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