diff --git a/README.md b/README.md
index d585833ad4bd..6c26f5fd6fdd 100644
--- a/README.md
+++ b/README.md
@@ -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'` di` da` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `ci"` `ca"` `ci'` `ca'` ci` ca` `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'` di` da` `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'` ci` ca` `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'` yi` ya` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`
+`yy` `yw` `yiw` `yaw` `yiW` `yaW` `yi"` `ya"` `yi'` `ya'` yi` ya` `yi(` `ya(` `yi[` `ya[` `yi{` `ya{` `yi<` `ya<` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.`
- Copy the current prompt selection with `y` (default: `ctrl+x` then `y`).
- Configure it with `keybinds.prompt_copy_selection`.
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 5dcf2fd4e36d..6174b96bfde0 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
@@ -50,6 +50,7 @@ import {
pasteAfter,
pasteBefore,
previousParagraphOperation,
+ bracketTextObjectOperation,
quoteTextObjectOperation,
prevWordStart,
replaceUnderCursor,
@@ -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 ?? ""
}
@@ -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 {
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 0e85ef22eeb1..2dafbca6b236 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
@@ -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 }
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index 6d9960df7257..fda38e3ad20f 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -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 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: "", 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")