diff --git a/README.md b/README.md
index ae37b104c5ff..d585833ad4bd 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` `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'` 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`
**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'` 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 1e5c27f85c1e..5dcf2fd4e36d 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,
+ quoteTextObjectOperation,
prevWordStart,
replaceUnderCursor,
replaceSelection,
@@ -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 ?? ""
}
@@ -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")
@@ -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 {
@@ -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)
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 a0bbbfe8260a..0e85ef22eeb1 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,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)
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index ad4443f26608..6d9960df7257 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -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")