From dc0bdc09b90b177dd860eb1d66dd9d6006f70e39 Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Fri, 1 May 2026 19:18:58 -0500 Subject: [PATCH 1/2] fix: move copy-mode e/E across rows Adds row-aware copy-mode word-end motion so e can advance to the next row. This also wires E through the same path for big-word traversal and adds coverage for blank rows, row offsets, and final-word no-ops. --- .../cli/cmd/tui/component/prompt/index.tsx | 4 + .../cli/cmd/tui/component/vim/vim-handler.ts | 9 ++ .../cli/cmd/tui/component/vim/vim-motions.ts | 30 ++++- .../cli/cmd/tui/routes/session/copy-mode.ts | 14 +++ .../opencode/test/cli/tui/vim-motions.test.ts | 111 ++++++++++++++++++ 5 files changed, 165 insertions(+), 3 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 25faf03dd20a..2d11669c24be 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -91,6 +91,7 @@ export type PromptProps = { jump: (action: "top" | "bottom" | "high" | "middle" | "low") => void wordNext: (big: boolean) => boolean wordPrev: (big: boolean) => boolean + wordEnd: (big: boolean) => boolean nextParagraph: () => boolean previousParagraph: () => boolean text: () => string @@ -567,6 +568,9 @@ export function Prompt(props: PromptProps) { copyWordPrev(big) { return props.copy?.wordPrev(big) ?? false }, + copyWordEnd(big) { + return props.copy?.wordEnd(big) ?? 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 3cb834ff3720..4b6a6b6a0180 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 @@ -91,6 +91,7 @@ export function createVimHandler(input: { copyJump?: (action: VimJump) => void copyWordNext?: (big: boolean) => boolean copyWordPrev?: (big: boolean) => boolean + copyWordEnd?: (big: boolean) => boolean copyNextParagraph?: () => boolean copyPreviousParagraph?: () => boolean copyText?: () => string @@ -1157,6 +1158,10 @@ export function createVimHandler(input: { } if (key === "e" && !event.shift) { + if (input.copyWordEnd?.(false)) { + event.preventDefault() + return true + } const text = input.copyText?.() ?? "" copyMotion(wordEnd(text, pos, false)) event.preventDefault() @@ -1187,6 +1192,10 @@ export function createVimHandler(input: { } if (isShifted(event, "e")) { + if (input.copyWordEnd?.(true)) { + event.preventDefault() + return true + } const text = input.copyText?.() ?? "" copyMotion(wordEnd(text, pos, true)) 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 47cd3cbf250b..e50cf622cf47 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 @@ -317,6 +317,13 @@ function wordClass(char: string, big: boolean): "blank" | "word" | "punct" { return "punct" } +function wordRunEnd(text: string, offset: number, big: boolean) { + const target = wordClass(text[offset], big) + let pos = offset + while (pos + 1 < text.length && wordClass(text[pos + 1], big) === target) pos++ + return pos +} + export function wordEnd(text: string, offset: number, big: boolean) { if (text.length === 0) return 0 let pos = offset @@ -331,9 +338,7 @@ export function wordEnd(text: string, offset: number, big: boolean) { if (pos >= text.length) return text.length - 1 } - const target = wordClass(text[pos], big) - while (pos + 1 < text.length && wordClass(text[pos + 1], big) === target) pos++ - return pos + return wordRunEnd(text, pos, big) } function deleteOffsets(textarea: TextareaRenderable, startOffset: number, endOffset: number) { @@ -453,6 +458,25 @@ export function copyWordPrev(rows: VimCopyRow[], get: (idx: number) => string, i return { idx, col: min } } +export function copyWordEnd(rows: VimCopyRow[], get: (idx: number) => string, idx: number, col: number, big: boolean) { + const row = rows[idx] + if (!row) return { idx, col } + const min = row.col + const text = get(idx) + const pos = Math.max(0, col - min) + const end = wordEnd(text, pos, big) + if (end > pos) return { idx, col: min + end } + for (let i = idx + 1; i < rows.length; i++) { + const nextRow = rows[i] + if (!nextRow) continue + const nextText = get(i) + const start = nextText.split("").findIndex((char) => wordClass(char, big) !== "blank") + if (start === -1) continue + return { idx: i, col: nextRow.col + wordRunEnd(nextText, start, big) } + } + return { idx, col: min + Math.max(0, text.length - 1) } +} + 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 da074879b93e..01c6bb53cfef 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 @@ -4,6 +4,7 @@ import type { Part } from "@opencode-ai/sdk/v2" import { copyNextParagraph, copyPreviousParagraph, + copyWordEnd, copyWordNext, copyWordPrev, firstNonWhitespace, @@ -390,6 +391,18 @@ export function createCopyMode(input: { return true } + function wordEnd(big: boolean) { + const s = state() + if (!s.active) return false + const list = rows() + if (!list.length) return false + const next = copyWordEnd(list, (idx) => rowText(list[idx]!), s.idx, s.col, big) + 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, @@ -669,6 +682,7 @@ export function createCopyMode(input: { jump, wordNext, wordPrev, + wordEnd, 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 1498eb3fdc9a..e13dae6c9452 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 type { VimJump } from "../../../src/cli/cmd/tui/component/vim/vim-motion- import { copyNextParagraph, copyPreviousParagraph, + copyWordEnd, copyWordNext, copyWordPrev, deleteSelection, @@ -331,6 +332,14 @@ function createHandler( setCopyCol(prev.col) return moved }, + copyWordEnd(big) { + if (!copyRows) return false + const next = copyWordEnd(copyRows, (idx) => options?.copy?.texts?.[idx] ?? "", copyIdx(), copyCol(), big) + 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()) @@ -4280,6 +4289,108 @@ describe("copy mode", () => { expect(ctx.copyCol()).toBe(6) }) + test("e advances to next copy row like vim", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 4, + rows: [{ col: 0 }, { col: 0 }], + texts: ["alpha", "beta gamma"], + }, + }) + + const evt = createEvent("e") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(1) + expect(ctx.copyCol()).toBe(3) + }) + + test("e lands on single-char word on next copy row", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 4, + rows: [{ col: 0 }, { col: 0 }], + texts: ["alpha", "a beta"], + }, + }) + + ctx.handler.handleKey(createEvent("e").event) + expect(ctx.copyIdx()).toBe(1) + expect(ctx.copyCol()).toBe(0) + }) + + test("E advances to next copy row with big word", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 4, + rows: [{ col: 0 }, { col: 0 }], + texts: ["alpha", "foo,bar baz"], + }, + }) + + const evt = createEvent("E") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(1) + expect(ctx.copyCol()).toBe(6) + }) + + test("e skips blank copy rows", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 4, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }], + texts: ["alpha", " ", " beta"], + }, + }) + + ctx.handler.handleKey(createEvent("e").event) + expect(ctx.copyIdx()).toBe(2) + expect(ctx.copyCol()).toBe(5) + }) + + test("e respects copy row column offsets", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 14, + rows: [{ col: 10 }, { col: 20 }], + texts: ["alpha", " beta"], + }, + }) + + ctx.handler.handleKey(createEvent("e").event) + expect(ctx.copyIdx()).toBe(1) + expect(ctx.copyCol()).toBe(24) + }) + + test("e at final copy word end stays put", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 1, + col: 3, + rows: [{ col: 0 }, { col: 0 }], + texts: ["alpha", "beta"], + }, + }) + + const evt = createEvent("e") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(1) + expect(ctx.copyCol()).toBe(3) + }) + test("B retreats to previous copy row with big word", () => { const ctx = createHandler("abc", { mode: "copy", From 54d38b945989ad836bf4e896fc0062f20e8f70f0 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 2 May 2026 11:49:50 +0800 Subject: [PATCH 2/2] fix: skip blank current row for copy-mode word-end --- .../src/cli/cmd/tui/component/vim/vim-motions.ts | 2 +- .../opencode/test/cli/tui/vim-motions.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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 e50cf622cf47..c7ed493a1e5c 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 @@ -465,7 +465,7 @@ export function copyWordEnd(rows: VimCopyRow[], get: (idx: number) => string, id const text = get(idx) const pos = Math.max(0, col - min) const end = wordEnd(text, pos, big) - if (end > pos) return { idx, col: min + end } + if (end > pos && wordClass(text[end], big) !== "blank") return { idx, col: min + end } for (let i = idx + 1; i < rows.length; i++) { const nextRow = rows[i] if (!nextRow) continue diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index e13dae6c9452..ce8f677ab247 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -4341,6 +4341,22 @@ describe("copy mode", () => { expect(ctx.copyCol()).toBe(6) }) + test("e skips whitespace-only current copy row", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 0, + rows: [{ col: 0 }, { col: 0 }], + texts: [" ", "beta"], + }, + }) + + ctx.handler.handleKey(createEvent("e").event) + expect(ctx.copyIdx()).toBe(1) + expect(ctx.copyCol()).toBe(3) + }) + test("e skips blank copy rows", () => { const ctx = createHandler("abc", { mode: "copy",