diff --git a/README.md b/README.md index d585833ad4bd..6c26f5fd6fdd 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`). **Editing** -`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `diW` `daW` `di"` `da"` `di'` `da'` di` da` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `ci"` `ca"` `ci'` `ca'` ci` ca` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` +`i` `I` `a` `A` `o` `O` `R` `r` `x` `~` `dd` `dw` `db` `diw` `daw` `diW` `daW` `di"` `da"` `di'` `da'` di` da` `di(` `da(` `di[` `da[` `di{` `da{` `di<` `da<` `df` `dF` `dt` `dT` `d%` `d}` `d{` `cc` `cw` `cb` `ciw` `caw` `ciW` `caW` `ci"` `ca"` `ci'` `ca'` ci` ca` `ci(` `ca(` `ci[` `ca[` `ci{` `ca{` `ci<` `ca<` `cf` `cF` `ct` `cT` `C` `c%` `c}` `c{` `s` `S` `J` **yank / put / undo / repeat** -`yy` `yw` `yiw` `yaw` `yiW` `yaW` `yi"` `ya"` `yi'` `ya'` yi` ya` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` +`yy` `yw` `yiw` `yaw` `yiW` `yaW` `yi"` `ya"` `yi'` `ya'` yi` ya` `yi(` `ya(` `yi[` `ya[` `yi{` `ya{` `yi<` `ya<` `y%` `y}` `y{` `p` `P` `u` `ctrl+r` `.` - Copy the current prompt selection with `y` (default: `ctrl+x` then `y`). - Configure it with `keybinds.prompt_copy_selection`. diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts index 5dcf2fd4e36d..6174b96bfde0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts @@ -50,6 +50,7 @@ import { pasteAfter, pasteBefore, previousParagraphOperation, + bracketTextObjectOperation, quoteTextObjectOperation, prevWordStart, replaceUnderCursor, @@ -136,7 +137,15 @@ export function createVimHandler(input: { if (event.name === "apostrophe") return "'" if (event.name === "backtick") return "`" const text = event.sequence?.length === 1 ? event.sequence : event.raw?.length === 1 ? event.raw : undefined - if (text === "/" || text === "@" || text === '"' || text === "'" || text === "`") return text + if (text && (text === "/" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text + if (event.shift) { + if (event.name === "9") return "(" + if (event.name === "0") return ")" + if (event.name === "[") return "{" + if (event.name === "]") return "}" + if (event.name === ",") return "<" + if (event.name === ".") return ">" + } return event.name ?? "" } @@ -398,6 +407,9 @@ export function createVimHandler(input: { if ((key === '"' || key === "'" || key === "`") && !hasModifier(event)) { return () => quoteTextObjectOperation(input.textarea(), scope === "around", key) } + if ("()[]{}<>".includes(key) && !hasModifier(event)) { + return () => bracketTextObjectOperation(input.textarea(), scope === "around", key, operation) + } } function pendingTextObjectOperator(event: VimEvent, key: string): boolean { diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts index 0e85ef22eeb1..2dafbca6b236 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts @@ -464,6 +464,91 @@ function wordTextObjectAroundBlankSpan(text: string, blank: VimSpan, big: boolea return { start: blank.start, end } } +export function bracketTextObjectOperation( + textarea: TextareaRenderable, + around: boolean, + bracket: string, + operation: VimOperator, +): VimOperatorResult { + const text = textarea.plainText + if (!text.length) return { span: null, register: null } + + const pair = bracketTextObjectPair(text, textarea.cursorOffset, bracket) + if (!pair) return { span: null, register: null } + + const span = around ? { start: pair.start, end: pair.end + 1 } : bracketTextObjectInnerSpan(text, pair, operation) + const registerSpan = around ? null : bracketTextObjectInnerSpan(text, pair, "d") + if (span.start < span.end) return buildOperatorResult(text, span, registerSpan, false) + return { span: { start: span.start, end: span.start }, register: { text: "", linewise: false } } +} + +function bracketTextObjectInnerSpan(text: string, pair: VimSpan, operation: VimOperator) { + const start = pair.start + 1 + const end = pair.end + if (text[start] === "\n" && text[end - 1] === "\n") return { start: start + 1, end: operation === "c" ? end - 1 : end } + return { start, end } +} + +function bracketTextObjectPair(text: string, cursor: number, bracket: string): VimSpan | null { + const pair = bracketTextObjectPairChars(bracket) + if (!pair) return null + + const containing = bracketTextObjectContainingPair(text, cursor, 0, text.length, pair.open, pair.close) + if (containing) return containing + + const pairStart = bracketTextObjectOpenAfterCursor(text, cursor, text.length, pair.open) + if (pairStart === null) return null + + const pairEnd = bracketTextObjectClose(text, pairStart, text.length, pair.open, pair.close) + return pairEnd === null ? null : { start: pairStart, end: pairEnd } +} + +function bracketTextObjectPairChars(bracket: string) { + if (bracket === "(" || bracket === ")") return { open: "(", close: ")" } + if (bracket === "[" || bracket === "]") return { open: "[", close: "]" } + if (bracket === "{" || bracket === "}") return { open: "{", close: "}" } + if (bracket === "<" || bracket === ">") return { open: "<", close: ">" } + return null +} + +function bracketTextObjectContainingPair( + text: string, + cursor: number, + start: number, + end: number, + open: string, + close: string, +): VimSpan | null { + const stack = [] + let result: VimSpan | null = null + for (let index = start; index < end; index++) { + if (text[index] === open) stack.push(index) + if (text[index] !== close) continue + + const pairStart = stack.pop() + if (pairStart === undefined || pairStart > cursor || index < cursor) continue + if (!result || pairStart > result.start) result = { start: pairStart, end: index } + } + return result +} + +function bracketTextObjectOpenAfterCursor(text: string, cursor: number, end: number, open: string) { + const index = text.indexOf(open, cursor) + return index === -1 || index >= end ? null : index +} + +function bracketTextObjectClose(text: string, start: number, end: number, open: string, close: string) { + let depth = 0 + for (let index = start; index < end; index++) { + if (text[index] === open) depth++ + if (text[index] === close) { + depth-- + if (depth === 0) return index + } + } + return null +} + export function quoteTextObjectOperation(textarea: TextareaRenderable, around: boolean, quote: string): VimOperatorResult { const text = textarea.plainText if (!text.length) return { span: null, register: null } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 6d9960df7257..fda38e3ad20f 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2983,6 +2983,213 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) }) + test("di parenthesis deletes inside brackets", () => { + const ctx = createHandler("say (hello) now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("say () now") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("ca square bracket changes around brackets", () => { + const ctx = createHandler("say [hello] now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("]").event) + + expect(ctx.textarea.plainText).toBe("say now") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "[hello]", linewise: false }) + }) + + test("yi curly bracket yanks inside brackets", () => { + const ctx = createHandler("say {hello} now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("}").event) + + expect(ctx.textarea.plainText).toBe("say {hello} now") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("da angle bracket deletes around brackets", () => { + const ctx = createHandler("say now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent(">").event) + + expect(ctx.textarea.plainText).toBe("say now") + expect(ctx.textarea.cursorOffset).toBe(4) + expect(ctx.state.register()).toEqual({ text: "", linewise: false }) + }) + + test("bracket text object selects nested pair", () => { + const ctx = createHandler("(a (b) c)") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("(a () c)") + expect(ctx.state.register()).toEqual({ text: "b", linewise: false }) + }) + + test("bracket text object selects containing pair after nested pair", () => { + const ctx = createHandler("(a (b) c)") + ctx.textarea.cursorOffset = 7 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("()") + expect(ctx.textarea.cursorOffset).toBe(1) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "a (b) c", linewise: false }) + }) + + test("bracket text object handles many unmatched openers", () => { + const ctx = createHandler("(".repeat(500) + "hello") + ctx.textarea.cursorOffset = 502 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("(".repeat(500) + "hello") + expect(ctx.state.register()).toBeNull() + expect(ctx.state.pending()).toBe("") + }) + + test("ci parenthesis from opening empty pair enters between brackets", () => { + const ctx = createHandler("say () now") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent(")").event) + + expect(ctx.textarea.plainText).toBe("say () now") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "", linewise: false }) + }) + + test("bracket text object finds pair after cursor", () => { + const ctx = createHandler("say before (hello) now") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("say before () now") + expect(ctx.textarea.cursorOffset).toBe(12) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("bracket text object no-ops after pair", () => { + const ctx = createHandler("say (hello) now") + ctx.textarea.cursorOffset = 12 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("say (hello) now") + expect(ctx.state.register()).toBeNull() + expect(ctx.state.pending()).toBe("") + }) + + test("bracket text object spans multiple lines", () => { + const ctx = createHandler("call(\n hello\n)") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("call(\n)") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.register()).toEqual({ text: " hello\n", linewise: false }) + }) + + test("change bracket text object spans multiple lines", () => { + const ctx = createHandler("call(\n hello\n)") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("call(\n\n)") + expect(ctx.textarea.cursorOffset).toBe(6) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.register()).toEqual({ text: " hello\n", linewise: false }) + }) + + test("yank around bracket text object spans multiple lines", () => { + const ctx = createHandler("call(\n hello\n)") + ctx.textarea.cursorOffset = 8 + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("(").event) + + expect(ctx.textarea.plainText).toBe("call(\n hello\n)") + expect(ctx.state.register()).toEqual({ text: "(\n hello\n)", linewise: false }) + }) + + test("bracket text object normalizes shifted bracket key", () => { + const ctx = createHandler("say {hello} now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("[", { shift: true, sequence: "{" }).event) + + expect(ctx.textarea.plainText).toBe("say {} now") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("bracket text object normalizes shifted bracket key without sequence", () => { + const ctx = createHandler("say {hello} now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("[", { shift: true }).event) + + expect(ctx.textarea.plainText).toBe("say {} now") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + + test("bracket text object normalizes shifted parenthesis key without sequence", () => { + const ctx = createHandler("say (hello) now") + ctx.textarea.cursorOffset = 6 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("i").event) + ctx.handler.handleKey(createEvent("9", { shift: true }).event) + + expect(ctx.textarea.plainText).toBe("say () now") + expect(ctx.state.register()).toEqual({ text: "hello", linewise: false }) + }) + test("text object pending display shows operator and object scope", () => { const ctx = createHandler("hello world")