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")