From 535ee517eea5d11f3e7ae88a78334c25cf20c80f Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Fri, 8 May 2026 14:44:07 -0500 Subject: [PATCH] feat: add vim matching bracket motion --- .../cli/cmd/tui/component/prompt/index.tsx | 4 + .../cli/cmd/tui/component/vim/vim-handler.ts | 51 +++++- .../cli/cmd/tui/component/vim/vim-motions.ts | 84 +++++++++ .../cli/cmd/tui/routes/session/copy-mode.ts | 15 ++ .../opencode/test/cli/tui/vim-motions.test.ts | 171 +++++++++++++++++- 5 files changed, 321 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 0b4e661cb0e4..3f5d6d104fd8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -97,6 +97,7 @@ export type PromptProps = { wordNext: (big: boolean) => boolean wordPrev: (big: boolean) => boolean wordEnd: (big: boolean) => boolean + matchingBracket: () => boolean nextParagraph: () => boolean previousParagraph: () => boolean text: () => string @@ -669,6 +670,9 @@ export function Prompt(props: PromptProps) { copyWordEnd(big) { return props.copy?.wordEnd(big) ?? false }, + copyMatchingBracket() { + return props.copy?.matchingBracket() ?? false + }, copyNextParagraph() { return props.copy?.nextParagraph() ?? false }, 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 e59b1aea7e28..9878bc3f09a1 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 @@ -21,6 +21,8 @@ import { getLineColumn, insertLineStart, joinLines, + matchingBracketOperation, + matchingBracketTarget, moveBigWordEnd, moveBigWordNext, moveBigWordPrev, @@ -29,6 +31,7 @@ import { moveLineBeginning, moveLineDown, moveLineUp, + moveMatchingBracket, moveNextParagraph, movePreviousParagraph, moveRight, @@ -94,6 +97,7 @@ export function createVimHandler(input: { copyWordNext?: (big: boolean) => boolean copyWordPrev?: (big: boolean) => boolean copyWordEnd?: (big: boolean) => boolean + copyMatchingBracket?: () => boolean copyNextParagraph?: () => boolean copyPreviousParagraph?: () => boolean copyText?: () => string @@ -242,6 +246,18 @@ export function createVimHandler(input: { return true } + function matchingBracketOperator(key: string, operation: ParagraphOperation): boolean { + if (key !== "%") return false + + const textarea = input.textarea() + const result = matchingBracketOperation(textarea) + if (!result.span && !result.register) input.state.clearPending() + else if (operation === "y") applyParagraphYank(result) + else applyParagraphEdit(textarea, result, operation) + + return true + } + function changeWord(big: boolean) { const textarea = input.textarea() const char = textarea.plainText[textarea.cursorOffset] @@ -549,6 +565,11 @@ export function createVimHandler(input: { return true } + if (matchingBracketOperator(key, "c")) { + event.preventDefault() + return true + } + input.state.clearPending() } @@ -614,6 +635,11 @@ export function createVimHandler(input: { return true } + if (matchingBracketOperator(key, "d")) { + event.preventDefault() + return true + } + input.state.clearPending() } @@ -669,6 +695,11 @@ export function createVimHandler(input: { return true } + if (matchingBracketOperator(key, "y")) { + event.preventDefault() + return true + } + input.state.clearPending() } @@ -959,6 +990,12 @@ export function createVimHandler(input: { return true } + if (key === "%" && !hasModifier(event)) { + moveMatchingBracket(input.textarea()) + event.preventDefault() + return true + } + if (key === "{" && !hasModifier(event)) { movePreviousParagraph(input.textarea()) event.preventDefault() @@ -1201,6 +1238,8 @@ export function createVimHandler(input: { return true } + const pos = input.copyCol?.() ?? 0 + // line motions if (key === "0") { copyMotion(0) @@ -1225,6 +1264,16 @@ export function createVimHandler(input: { return true } + if (key === "%") { + if (!input.copyMatchingBracket?.()) { + const text = input.copyText?.() ?? "" + const target = matchingBracketTarget(text, pos) + if (target !== null) copyMotion(target) + } + event.preventDefault() + return true + } + if (key === "z" && !event.shift) { input.state.setPending("z") event.preventDefault() @@ -1232,8 +1281,6 @@ export function createVimHandler(input: { } // word motions - const pos = input.copyCol?.() ?? 0 - if (key === "w" && !event.shift) { if (input.copyWordNext?.(false)) { event.preventDefault() 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 7f5ca7b53fe9..bdae3253a2e8 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 @@ -5,6 +5,13 @@ export type VimSpan = { start: number; end: number } export type VimCopyRow = { col: number } export type VimWantedColumn = number | "end" +const bracketPairs = new Map([ + ["(", ")"], + ["[", "]"], + ["{", "}"], +]) +const bracketClosers = new Map(Array.from(bracketPairs, ([open, close]) => [close, open])) + function lineStart(text: string, offset: number) { if (offset <= 0) return 0 const index = text.lastIndexOf("\n", offset - 1) @@ -91,6 +98,46 @@ function lineColumn(text: string, offset: number) { return offset - lineStart(text, offset) } +function bracketAtOrAfter(text: string, offset: number) { + const end = lineEnd(text, offset) + for (let pos = offset; pos < end; pos++) { + if (bracketPairs.has(text[pos]!) || bracketClosers.has(text[pos]!)) return pos + } + return null +} + +function matchingForward(text: string, offset: number, open: string, close: string) { + let depth = 1 + for (let pos = offset + 1; pos < text.length; pos++) { + if (text[pos] === open) depth++ + if (text[pos] === close) depth-- + if (depth === 0) return pos + } + return null +} + +function matchingBackward(text: string, offset: number, open: string, close: string) { + let depth = 1 + for (let pos = offset - 1; pos >= 0; pos--) { + if (text[pos] === close) depth++ + if (text[pos] === open) depth-- + if (depth === 0) return pos + } + return null +} + +export function matchingBracketTarget(text: string, offset: number) { + if (!text.length) return null + const source = bracketAtOrAfter(text, Math.max(0, Math.min(offset, text.length - 1))) + if (source === null) return null + const char = text[source]! + const close = bracketPairs.get(char) + if (close) return matchingForward(text, source, char, close) + const open = bracketClosers.get(char) + if (open) return matchingBackward(text, source, open, char) + return null +} + function moveUp(text: string, offset: number, column: VimWantedColumn = lineColumn(text, offset)) { const targetStart = prevLineStart(text, offset) if (targetStart === undefined) return offset @@ -154,6 +201,13 @@ export function moveLineDown(textarea: TextareaRenderable, column?: VimWantedCol textarea.cursorOffset = moveDown(text, textarea.cursorOffset, column) } +export function moveMatchingBracket(textarea: TextareaRenderable) { + const target = matchingBracketTarget(textarea.plainText, textarea.cursorOffset) + if (target === null) return false + textarea.cursorOffset = target + return true +} + export function movePreviousParagraph(textarea: TextareaRenderable) { textarea.cursorOffset = previousParagraphTarget(textarea.plainText, textarea.cursorOffset) } @@ -169,6 +223,15 @@ export type ParagraphResult = { register: VimRegister } +export function matchingBracketOperation(textarea: TextareaRenderable): ParagraphResult { + const text = textarea.plainText + const cursor = textarea.cursorOffset + const target = matchingBracketTarget(text, cursor) + if (target === null) return { span: null, register: null } + const span = target < cursor ? { start: target, end: cursor + 1 } : { start: cursor, end: target + 1 } + return { span, register: { text: text.slice(span.start, span.end), linewise: false } } +} + // vim linewise register convention: content ends with \n per line terminator. function asLinewise(slice: string): string { return slice.endsWith("\n") ? slice : slice + "\n" @@ -477,6 +540,27 @@ export function copyWordEnd(rows: VimCopyRow[], get: (idx: number) => string, id return { idx, col: min + Math.max(0, text.length - 1) } } +export function copyMatchingBracket(rows: VimCopyRow[], get: (idx: number) => string, idx: number, col: number) { + const row = rows[idx] + if (!row) return { idx, col } + const texts = rows.map((_, i) => get(i)) + let start = 0 + const starts = texts.map((text) => { + const current = start + start += text.length + 1 + return current + }) + const text = texts.join("\n") + const local = Math.max(0, col - row.col) + const target = matchingBracketTarget(text, starts[idx]! + local) + if (target === null) return { idx, col } + + const targetIdx = starts.findLastIndex((start, i) => target >= start && target < start + texts[i]!.length) + const targetRow = rows[targetIdx] + if (!targetRow) return { idx, col } + return { idx: targetIdx, col: targetRow.col + target - starts[targetIdx]! } +} + export type CopyParagraphResult = { index: number; atEnd: boolean } // `atEnd` is true only when content runs to EOF without a trailing blank line, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 39a615467d21..045721f4698d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import type { ScrollBoxRenderable } from "@opentui/core" import type { Part } from "@opencode-ai/sdk/v2" import { + copyMatchingBracket, copyNextParagraph, copyPreviousParagraph, copyWordEnd, @@ -413,6 +414,19 @@ export function createCopyMode(input: { return true } + function matchingBracket() { + const s = state() + if (!s.active) return false + const list = rows() + if (!list.length) return false + const cache = new Map(input.scroll().getChildren().map((c) => [c.id, c])) + const next = copyMatchingBracket(wordRows(list, cache), (idx) => rowText(list[idx]!, cache), s.idx, s.col) + if (next.idx === s.idx && next.col === s.col) return false + if (next.idx !== s.idx) sync(next.idx) + setCol(next.col) + return true + } + function paragraphColumn( row: CopyRow, atEnd: boolean, @@ -732,6 +746,7 @@ export function createCopyMode(input: { wordNext, wordPrev, wordEnd, + matchingBracket, nextParagraph, previousParagraph, text: copyText, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 808f5c092532..b193d7e1d70e 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -9,6 +9,7 @@ import { vimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" import { createCopyMode } from "../../../src/cli/cmd/tui/routes/session/copy-mode" import type { VimJump } from "../../../src/cli/cmd/tui/component/vim/vim-motion-jump" import { + copyMatchingBracket, copyNextParagraph, copyPreviousParagraph, copyWordEnd, @@ -347,6 +348,14 @@ function createHandler( setCopyCol(next.col) return moved }, + copyMatchingBracket() { + if (!copyRows) return false + const next = copyMatchingBracket(copyRows, (idx) => options?.copy?.texts?.[idx] ?? "", copyIdx(), copyCol()) + const moved = next.idx !== copyIdx() || next.col !== copyCol() + setCopyIdx(next.idx) + setCopyCol(next.col) + return moved + }, copyNextParagraph() { if (!copyRows) return false const next = copyNextParagraph(copyRows, (idx) => options?.copy?.texts?.[idx] ?? "", copyIdx()) @@ -764,6 +773,87 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(4) }) + test("% jumps from opening bracket to matching close", () => { + const ctx = createHandler("a (b [c]) d") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("%").event) + expect(ctx.textarea.cursorOffset).toBe(8) + }) + + test("% searches forward on current line for a bracket", () => { + const ctx = createHandler("a (b)") + + ctx.handler.handleKey(createEvent("%").event) + expect(ctx.textarea.cursorOffset).toBe(4) + }) + + test("% matches brackets across lines", () => { + const ctx = createHandler("{\n [x]\n}") + + ctx.handler.handleKey(createEvent("%").event) + expect(ctx.textarea.cursorOffset).toBe(8) + }) + + test("% leaves cursor in place for unmatched bracket", () => { + const ctx = createHandler("(abc") + + ctx.handler.handleKey(createEvent("%").event) + expect(ctx.textarea.cursorOffset).toBe(0) + }) + + test("d% deletes through matching bracket", () => { + const ctx = createHandler("(abc) def") + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("%").event) + + expect(ctx.textarea.plainText).toBe(" def") + expect(ctx.state.register()).toEqual({ text: "(abc)", linewise: false }) + }) + + test("d% searches forward from non-bracket cursor like vim", () => { + const ctx = createHandler("a (b) c") + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("%").event) + + expect(ctx.textarea.plainText).toBe(" c") + expect(ctx.state.register()).toEqual({ text: "a (b)", linewise: false }) + }) + + test("d% from closing bracket deletes back through match", () => { + const ctx = createHandler("(abc) def") + ctx.textarea.cursorOffset = 4 + + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("%").event) + + expect(ctx.textarea.plainText).toBe(" def") + expect(ctx.state.register()).toEqual({ text: "(abc)", linewise: false }) + }) + + test("y% yanks through matching bracket", () => { + const ctx = createHandler("(abc) def") + + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("%").event) + + expect(ctx.textarea.plainText).toBe("(abc) def") + expect(ctx.state.register()).toEqual({ text: "(abc)", linewise: false }) + }) + + test("c% changes through matching bracket", () => { + const ctx = createHandler("(abc) def") + + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("%").event) + + expect(ctx.textarea.plainText).toBe(" def") + expect(ctx.state.register()).toEqual({ text: "(abc)", linewise: false }) + expect(ctx.state.mode()).toBe("insert") + }) + test("} jumps to the next blank line", () => { const ctx = createHandler("one\ntwo\n\nthree\nfour") ctx.textarea.cursorOffset = 0 @@ -3653,6 +3743,17 @@ describe("vim motion handler", () => { expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 6, end: 11 }) }) + test("visual mode % extends selection through matching bracket", () => { + const ctx = createHandler("a (b) c") + ctx.textarea.cursorOffset = 2 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("%").event) + + expect(ctx.textarea.cursorOffset).toBe(4) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 2, end: 5 }) + }) + test("V enters visual-line mode", () => { const ctx = createHandler("one\ntwo\nthree") ctx.textarea.cursorOffset = 5 @@ -4568,12 +4669,42 @@ describe("copy mode", () => { expect(next).toEqual({ idx: 1, col: 5 }) }) + test("copyMatchingBracket matches across copy rows", () => { + const next = copyMatchingBracket( + [{ col: 2 }, { col: 4 }, { col: 2 }], + (idx) => ["call(", " value", ")"][idx]!, + 0, + 6, + ) + expect(next).toEqual({ idx: 2, col: 2 }) + }) + + test("copyMatchingBracket respects target row column offsets", () => { + const next = copyMatchingBracket([{ col: 10 }, { col: 20 }], (idx) => ["[abc", "]"][idx]!, 0, 10) + expect(next).toEqual({ idx: 1, col: 20 }) + }) + + test("copyMatchingBracket matches backward across copy rows", () => { + const next = copyMatchingBracket( + [{ col: 2 }, { col: 4 }, { col: 2 }], + (idx) => ["call(", " value", ")"][idx]!, + 2, + 2, + ) + expect(next).toEqual({ idx: 0, col: 6 }) + }) + + test("copyMatchingBracket leaves unmatched bracket in place", () => { + const next = copyMatchingBracket([{ col: 2 }], () => "call(", 0, 6) + expect(next).toEqual({ idx: 0, col: 6 }) + }) + test("w advances to next copy row like vim", () => { const ctx = createHandler("abc", { mode: "copy", copy: { idx: 0, - col: 4, + col: 5, rows: [{ col: 0 }, { col: 0 }], texts: ["alpha", "beta gamma"], }, @@ -4864,7 +4995,7 @@ describe("copy mode", () => { mode: "copy", copy: { idx: 0, - col: 5, + col: 4, rows: [{ col: 3 }, { col: 3 }, { col: 7 }, { col: 3 }], texts: ["one", "two", "", "three"], }, @@ -5229,6 +5360,42 @@ describe("copy mode", () => { expect(ctx.copyCol()).toBe(6) }) + test("copy mode % matches across copy rows", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 4, + rows: [{ col: 0 }, { col: 2 }, { col: 0 }], + texts: ["call(", " value", ")"], + }, + }) + + const evt = createEvent("%") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(2) + expect(ctx.copyCol()).toBe(0) + }) + + test("copy mode % leaves cursor in place without a matching bracket", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 4, + rows: [{ col: 0 }], + texts: ["call("], + }, + }) + + const evt = createEvent("%") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(0) + expect(ctx.copyCol()).toBe(4) + }) + test("copy mode find and repeat update column", () => { const ctx = createHandler("alpha beta gamma", { mode: "copy", copy: { text: "alpha beta gamma", col: 0 } })