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