From a52877c907e9c7cf6549fe2599496884e4c37474 Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:22:16 +0800
Subject: [PATCH 01/10] feat: add quote text objects
---
.../cli/cmd/tui/component/vim/vim-handler.ts | 9 ++++++-
.../cli/cmd/tui/component/vim/vim-motions.ts | 26 +++++++++++++++++++
2 files changed, 34 insertions(+), 1 deletion(-)
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..bb4db8023dda 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 ?? ""
}
@@ -390,6 +394,9 @@ export function createVimHandler(input: {
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 {
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..8db7267d868c 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,32 @@ 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 ? { start: pair.start, end: pair.end + 1 } : { start: pair.start + 1, end: pair.end }
+ return buildOperatorResult(text, span, null, false)
+}
+
+function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSpan | null {
+ const start = lineStart(text, cursor)
+ const end = lineEnd(text, cursor)
+ const positions = Array.from(text.slice(start, end), (char, index) => (char === quote ? start + index : null)).filter(
+ (position): position is number => position !== null,
+ )
+ const pairStart = positions.find((position, index) => {
+ const next = positions[index + 1]
+ return next !== undefined && position <= cursor && cursor <= next
+ })
+ if (pairStart === undefined) return null
+
+ return { start: pairStart, end: positions[positions.indexOf(pairStart) + 1]! }
+}
+
function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) {
if (endOffset <= startOffset) return
const end = Math.min(endOffset, textarea.plainText.length)
From afb798f02e588ba7fef102305794b7e58f289d91 Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:25:14 +0800
Subject: [PATCH 02/10] fix: select correct quote pair
---
.../cli/cmd/tui/component/vim/vim-motions.ts | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
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 8db7267d868c..1762d20bacbf 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
@@ -481,13 +481,19 @@ function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSp
const positions = Array.from(text.slice(start, end), (char, index) => (char === quote ? start + index : null)).filter(
(position): position is number => position !== null,
)
- const pairStart = positions.find((position, index) => {
- const next = positions[index + 1]
- return next !== undefined && position <= cursor && cursor <= next
- })
- if (pairStart === undefined) return null
+ 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 }
+ }
- return { start: pairStart, end: positions[positions.indexOf(pairStart) + 1]! }
+ const pairIndex = index % 2 === 0 ? index : index - 1
+ const pairEnd = positions[pairIndex + 1]
+ return pairEnd === undefined ? null : { start: positions[pairIndex]!, end: pairEnd }
}
function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) {
From 2f0c8c7620034f31e01492e0a42aa6e84da9cc82 Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:28:48 +0800
Subject: [PATCH 03/10] fix: resolve quote text object pairs by cursor position
---
.../src/cli/cmd/tui/component/vim/vim-motions.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
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 1762d20bacbf..9115f9e62308 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
@@ -491,9 +491,13 @@ function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSp
return pairEnd === undefined ? null : { start: positions[pairIndex]!, end: pairEnd }
}
- 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 deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) {
From bd9fe7e6f6dfb7fc8a11364bb6fa52410abd81c1 Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:31:23 +0800
Subject: [PATCH 04/10] test: quote text objects
---
.../opencode/test/cli/tui/vim-motions.test.ts | 115 ++++++++++++++++++
1 file changed, 115 insertions(+)
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index ad4443f26608..5ecc8db402ae 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -2751,6 +2751,121 @@ 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("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 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")
From 1df7a41ec89b9c44baf641f5204bafa0b242c621 Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:31:35 +0800
Subject: [PATCH 05/10] docs: add quote text objects to readme
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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`.
From b8a1c0dc7f95c9ae7bb42b253eaae88b0340359e Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:40:24 +0800
Subject: [PATCH 06/10] fix: handle empty quote pairs
---
.../cli/cmd/tui/component/vim/vim-handler.ts | 6 ++---
.../cli/cmd/tui/component/vim/vim-motions.ts | 11 ++++++--
.../opencode/test/cli/tui/vim-motions.test.ts | 27 +++++++++++++++++++
3 files changed, 39 insertions(+), 5 deletions(-)
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 bb4db8023dda..b5f7047d115a 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
@@ -389,13 +389,13 @@ 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)
+ return () => quoteTextObjectOperation(input.textarea(), scope === "around", key, operation)
}
}
@@ -407,7 +407,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 9115f9e62308..ddcf835ca975 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,7 +464,12 @@ function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolea
return { start: blank.start, end }
}
-export function quoteTextObjectOperation(textarea: TextareaRenderable, around: boolean, quote: string): VimOperatorResult {
+export function quoteTextObjectOperation(
+ textarea: TextareaRenderable,
+ around: boolean,
+ quote: string,
+ operation: VimOperator,
+): VimOperatorResult {
const text = textarea.plainText
if (!text.length) return { span: null, register: null }
@@ -472,7 +477,9 @@ export function quoteTextObjectOperation(textarea: TextareaRenderable, around: b
if (!pair) return { span: null, register: null }
const span = around ? { start: pair.start, end: pair.end + 1 } : { start: pair.start + 1, end: pair.end }
- return buildOperatorResult(text, span, null, false)
+ if (span.start < span.end) return buildOperatorResult(text, span, null, false)
+ if (operation !== "c") return { span: null, register: { text: "", linewise: false } }
+ return buildOperatorResult(text, { start: pair.start, end: pair.end + 1 }, { start: pair.start + 1, end: pair.end }, false)
}
function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSpan | null {
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index 5ecc8db402ae..0b13f5f71846 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -2816,6 +2816,33 @@ describe("vim motion handler", () => {
expect(ctx.state.register()).toEqual({ text: "b", linewise: false })
})
+ test("di double quote yanks 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(4)
+ expect(ctx.state.mode()).toBe("insert")
+ expect(ctx.state.register()).toEqual({ text: "", linewise: false })
+ })
+
test("quote text object selects surrounding quotes between pairs", () => {
const ctx = createHandler('"a" "b"')
ctx.textarea.cursorOffset = 3
From f55469e4e320aff76b69506797b1793036174acc Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:46:25 +0800
Subject: [PATCH 07/10] fix: ignored escaped quote delimiters
---
.../cli/cmd/tui/component/vim/vim-motions.ts | 13 +++++++---
.../opencode/test/cli/tui/vim-motions.test.ts | 25 +++++++++++++++++++
2 files changed, 35 insertions(+), 3 deletions(-)
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 ddcf835ca975..d79a7ae3e12e 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
@@ -485,9 +485,10 @@ export function quoteTextObjectOperation(
function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSpan | null {
const start = lineStart(text, cursor)
const end = lineEnd(text, cursor)
- const positions = Array.from(text.slice(start, end), (char, index) => (char === quote ? start + index : null)).filter(
- (position): position is number => position !== null,
- )
+ const positions = Array.from(text.slice(start, end), (char, index) => {
+ const position = start + index
+ return char === quote && !isEscaped(text, position) ? position : null
+ }).filter((position): position is number => position !== null)
if (positions.length < 2) return null
const index = positions.findIndex((position) => position >= cursor)
@@ -507,6 +508,12 @@ function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSp
return { start: previous, end: positions[index]! }
}
+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 0b13f5f71846..b8a6b1a8495c 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -2856,6 +2856,31 @@ describe("vim motion handler", () => {
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 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
From 65ada6a829496a38688cf1a5cb94206fc41c9e4b Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 01:58:07 +0800
Subject: [PATCH 08/10] fix: place cursor inside empty quote
---
.../cli/cmd/tui/component/vim/vim-handler.ts | 5 +++--
.../cli/cmd/tui/component/vim/vim-motions.ts | 10 ++--------
.../opencode/test/cli/tui/vim-motions.test.ts | 20 ++++++++++++++++---
3 files changed, 22 insertions(+), 13 deletions(-)
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 b5f7047d115a..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
@@ -241,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")
@@ -395,7 +396,7 @@ export function createVimHandler(input: {
return () => wordTextObjectOperation(input.textarea(), scope === "around", big)
}
if ((key === '"' || key === "'" || key === "`") && !hasModifier(event)) {
- return () => quoteTextObjectOperation(input.textarea(), scope === "around", key, operation)
+ return () => quoteTextObjectOperation(input.textarea(), scope === "around", key)
}
}
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 d79a7ae3e12e..1b7e13037016 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,12 +464,7 @@ function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolea
return { start: blank.start, end }
}
-export function quoteTextObjectOperation(
- textarea: TextareaRenderable,
- around: boolean,
- quote: string,
- operation: VimOperator,
-): VimOperatorResult {
+export function quoteTextObjectOperation(textarea: TextareaRenderable, around: boolean, quote: string): VimOperatorResult {
const text = textarea.plainText
if (!text.length) return { span: null, register: null }
@@ -478,8 +473,7 @@ export function quoteTextObjectOperation(
const span = around ? { start: pair.start, end: pair.end + 1 } : { start: pair.start + 1, end: pair.end }
if (span.start < span.end) return buildOperatorResult(text, span, null, false)
- if (operation !== "c") return { span: null, register: { text: "", linewise: false } }
- return buildOperatorResult(text, { start: pair.start, end: pair.end + 1 }, { start: pair.start + 1, end: pair.end }, false)
+ return { span: { start: span.start, end: span.start }, register: { text: "", linewise: false } }
}
function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSpan | null {
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index b8a6b1a8495c..60d3def22629 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -2816,7 +2816,7 @@ describe("vim motion handler", () => {
expect(ctx.state.register()).toEqual({ text: "b", linewise: false })
})
- test("di double quote yanks empty inner quote text", () => {
+ test("di double quote deletes empty inner quote text", () => {
const ctx = createHandler('say "" now')
ctx.textarea.cursorOffset = 5
@@ -2837,8 +2837,22 @@ describe("vim motion handler", () => {
ctx.handler.handleKey(createEvent("i").event)
ctx.handler.handleKey(createEvent('"').event)
- expect(ctx.textarea.plainText).toBe('say now')
- expect(ctx.textarea.cursorOffset).toBe(4)
+ 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 })
})
From 3709a8528ddf6aefd7c6766f8035ecf09e1f1a5f Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 02:28:47 +0800
Subject: [PATCH 09/10] fix: use code-unit offsets for quote text objects
---
.../src/cli/cmd/tui/component/vim/vim-motions.ts | 8 ++++----
packages/opencode/test/cli/tui/vim-motions.test.ts | 13 +++++++++++++
2 files changed, 17 insertions(+), 4 deletions(-)
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 1b7e13037016..5bdbc2a73a82 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
@@ -479,10 +479,10 @@ export function quoteTextObjectOperation(textarea: TextareaRenderable, around: b
function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSpan | null {
const start = lineStart(text, cursor)
const end = lineEnd(text, cursor)
- const positions = Array.from(text.slice(start, end), (char, index) => {
- const position = start + index
- return char === quote && !isEscaped(text, position) ? position : null
- }).filter((position): position is number => position !== null)
+ 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)
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index 60d3def22629..ef1a80add681 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -2883,6 +2883,19 @@ describe("vim motion handler", () => {
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
From 68899e31c188858fdf7dd396d3e378afcdd5aa25 Mon Sep 17 00:00:00 2001
From: leohenon <77656081+lhenon999@users.noreply.github.com>
Date: Mon, 18 May 2026 02:35:53 +0800
Subject: [PATCH 10/10] fix: include adjacent whitespace in around quote text
objects
---
.../cli/cmd/tui/component/vim/vim-motions.ts | 16 ++++++-
.../opencode/test/cli/tui/vim-motions.test.ts | 46 +++++++++++++++++--
2 files changed, 57 insertions(+), 5 deletions(-)
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 5bdbc2a73a82..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
@@ -471,11 +471,21 @@ export function quoteTextObjectOperation(textarea: TextareaRenderable, around: b
const pair = quoteTextObjectPair(text, textarea.cursorOffset, quote)
if (!pair) return { span: null, register: null }
- const span = around ? { start: pair.start, end: pair.end + 1 } : { start: pair.start + 1, end: pair.end }
+ 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)
@@ -502,6 +512,10 @@ function quoteTextObjectPair(text: string, cursor: number, quote: string): VimSp
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++
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index ef1a80add681..6d9960df7257 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -2772,10 +2772,10 @@ describe("vim motion handler", () => {
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent('"').event)
- expect(ctx.textarea.plainText).toBe("say now")
+ 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 })
+ expect(ctx.state.register()).toEqual({ text: '"hello" ', linewise: false })
})
test("yi single quote yanks inside quotes", () => {
@@ -2798,9 +2798,9 @@ describe("vim motion handler", () => {
ctx.handler.handleKey(createEvent("a").event)
ctx.handler.handleKey(createEvent("`").event)
- expect(ctx.textarea.plainText).toBe("say now")
+ expect(ctx.textarea.plainText).toBe("say now")
expect(ctx.textarea.cursorOffset).toBe(4)
- expect(ctx.state.register()).toEqual({ text: "`hello`", linewise: false })
+ expect(ctx.state.register()).toEqual({ text: "`hello` ", linewise: false })
})
test("quote text object selects later pair from opening quote", () => {
@@ -2857,6 +2857,44 @@ describe("vim motion handler", () => {
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