From e1a29f6ff6173184eb3ca929ca595cfeaee66659 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sat, 30 May 2026 19:20:55 +0400 Subject: [PATCH 1/5] feat: add copy mode visual block --- README.md | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 8 +- .../cli/cmd/tui/component/vim/vim-handler.ts | 8 +- .../cmd/tui/component/vim/vim-indicator.ts | 3 +- .../cli/cmd/tui/routes/session/copy-mode.ts | 112 +++++++++++--- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../test/cli/tui/vim-indicator.test.ts | 3 +- .../opencode/test/cli/tui/vim-motions.test.ts | 137 +++++++++++++++++- 8 files changed, 248 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 28b32acb62b5..fec3b073939b 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Text selection from the chat session view. | ------------------------------ | ------------------------------------------------------- | | `v`, `Ctrl+W k` | Enter copy mode | | `h`, `j`, `k`, `l`, arrow keys | Navigate | -| `v`, `V` | Start character-wise or line-wise selection | +| `v`, `V`, `Ctrl+V` | Start character-wise, line-wise, or block selection | | `y`, `yy` | Yank to the vim register | | `Enter` | Copy to the system clipboard | | `Y` | Yank to the vim register and scroll to the bottom | 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 a922166aa733..1f3895005b71 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -99,14 +99,14 @@ export type PromptProps = { exit: (scrollToBottom?: boolean) => void exitPreserveScroll: () => void focusInput: () => void - visual: (mode: "char" | "line") => void + visual: (mode: "char" | "line" | "block") => void yank: () => { text: string; linewise: boolean } | null yankLine: () => { text: string; linewise: boolean } | null yankMatchingBracket: () => { text: string; linewise: boolean } | null copy: () => Promise | void isVisual: () => boolean exitVisual: () => void - visualMode: () => undefined | "char" | "line" + visualMode: () => undefined | "char" | "line" | "block" move: (action: "up" | "down" | "left" | "right") => void jump: (action: "top" | "bottom" | "high" | "middle" | "low") => void wordNext: (big: boolean) => boolean @@ -1515,7 +1515,7 @@ export function Prompt(props: PromptProps) { useBindings(() => { return { target: inputTarget, - enabled: inputTarget() !== undefined && !props.disabled, + enabled: inputTarget() !== undefined && !props.disabled && !vimState.isCopy(), bindings: tuiConfig.keybinds.get("prompt.paste"), } }) @@ -2170,7 +2170,7 @@ export function Prompt(props: PromptProps) { } function isVisualIndicator(indicator: string) { - return ["-- VISUAL --", "-- VISUAL LINE --"].includes(indicator) + return ["-- VISUAL --", "-- VISUAL LINE --", "-- VISUAL BLOCK --"].includes(indicator) } function VimIndicator() { 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 d1b38f1b8736..94bbd233e0fc 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 @@ -124,7 +124,7 @@ export function createVimHandler(input: { jump: (action: VimJump) => void navigate?: (action: VimWindowNavigation) => void copy?: (action: VimCopyMove) => void - copyVisual?: (mode: "char" | "line") => void + copyVisual?: (mode: "char" | "line" | "block") => void copyExitVisual?: () => void copyExit?: (scrollToBottom?: boolean) => void copyExitPreserveScroll?: () => void @@ -1381,6 +1381,12 @@ export function createVimHandler(input: { event.preventDefault() return true } + if (key === "v" && event.ctrl && !event.shift && !event.meta && !event.super) { + clearCopyPending() + input.copyVisual?.("block") + event.preventDefault() + return true + } if (key === "q") { input.state.setMode("normal") event.preventDefault() diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts index 97e0525e5e86..569be920f510 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts @@ -5,7 +5,7 @@ export function useVimIndicator(input: { enabled: Accessor active: Accessor state: ReturnType - copyVisual?: Accessor + copyVisual?: Accessor }) { return createMemo(() => { if (!input.enabled() || !input.active()) return @@ -14,6 +14,7 @@ export function useVimIndicator(input: { if (input.state.isCopy()) { if (input.copyVisual?.() === "char") return "-- VISUAL --" if (input.copyVisual?.() === "line") return "-- VISUAL LINE --" + if (input.copyVisual?.() === "block") return "-- VISUAL BLOCK --" return "-- COPY --" } if (input.state.isInsert()) return "-- INSERT --" 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 87189801dd74..435e0ebc7097 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 @@ -31,12 +31,14 @@ export type CopyHighlight = { text: string } +type CopyVisualMode = "char" | "line" | "block" + type CopyState = { active: boolean idx: number col: number stick: undefined | "start" | "first" | "end" | number - visual: undefined | "char" | "line" + visual: undefined | CopyVisualMode anchor: undefined | { idx: number; col: number } } @@ -630,7 +632,8 @@ export function createCopyMode(input: { if (action === "left") { const row = rows()[s.idx] const min = copyMin(row) - const c = Math.max(min, s.col - 1) + const c = Math.max(min, Math.min(scroll.width - 2, s.col - 1)) + if (c === s.col) return setState((prev) => ({ ...prev, col: c, stick: c - min })) return } @@ -638,7 +641,8 @@ export function createCopyMode(input: { const min = copyMin(row) const text = copyText() const max = text.length > 0 ? Math.min(scroll.width - 2, text.length - 1) : min - const c = Math.min(max, s.col + 1) + const c = Math.max(min, Math.min(max, s.col + 1)) + if (c === s.col) return setState((prev) => ({ ...prev, col: c, stick: c - min })) } @@ -745,9 +749,23 @@ export function createCopyMode(input: { return paragraphMove(copyPreviousParagraph) } + function rowEndCol(row: CopyRow, cache?: Map) { + const min = copyMin(row, cache) + const text = rowText(row, cache) + return text.length > 0 ? min + text.length - 1 : min + } + + function blockHeadCol(s: CopyState, list = rows(), cache?: Map) { + const row = list[s.idx] + if (!row) return s.col + if (s.stick === "end") return rowEndCol(row, cache) + if (typeof s.stick === "number") return Math.min(input.scroll().width - 2, copyMin(row, cache) + s.stick) + return s.col + } + // --- visual --- - function visual(mode: "char" | "line") { + function visual(mode: CopyVisualMode) { const s = state() if (!s.active) return if (s.visual === mode) { @@ -765,7 +783,7 @@ export function createCopyMode(input: { setState((s) => ({ ...s, visual: undefined, anchor: undefined })) } - function rangeText(anchor: Endpoint, head: Endpoint, visual: "char" | "line"): string { + function rangeText(anchor: Endpoint, head: Endpoint, visual: CopyVisualMode): string { const list = rows() const cache = new Map( input @@ -780,6 +798,25 @@ export function createCopyMode(input: { .map((row) => signedText(row, cache)) .join("\n") } + if (visual === "block") { + const left = Math.min(anchor.col, head.col) + const right = Math.max(anchor.col, head.col) + const endMode = state().stick === "end" + return Array.from({ length: end.idx - start.idx + 1 }, (_, i) => list[start.idx + i]) + .filter((row): row is CopyRow => !!row) + .map((row) => { + const text = rowText(row, cache) + const min = copyMin(row, cache) + const rowRight = endMode ? rowEndCol(row, cache) : right + const rowLeft = endMode && rowRight < anchor.col ? anchor.col : endMode ? Math.min(anchor.col, rowRight) : left + const selected = `${rowLeft < min ? " ".repeat(min - rowLeft) : ""}${text.slice( + Math.max(0, rowLeft - min), + Math.max(0, rowRight - min + 1), + )}` + return endMode ? selected : selected.padEnd(right - left + 1, " ") + }) + .join("\n") + } if (start.idx === end.idx) { const row = list[start.idx] if (!row) return "" @@ -802,7 +839,7 @@ export function createCopyMode(input: { function selectionText(): string { const s = state() if (!s.visual || !s.anchor) return "" - return rangeText(s.anchor, { idx: s.idx, col: s.col }, s.visual) + return rangeText(s.anchor, { idx: s.idx, col: s.visual === "block" ? blockHeadCol(s) : s.col }, s.visual) } function yank() { @@ -960,13 +997,16 @@ export function createCopyMode(input: { const out = new Map() if (!s.active) return out const flashIdx = yankLineFlash() - const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => { + const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number, placeholder = false) => { if (left > right) return + const selected = text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)) + if (!selected && !placeholder) return + const start = selected ? Math.max(left, min) : Math.max(left, min) const entry = { line: row.line, - left, - right, - text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), + left: start, + right: start + Math.max(1, selected.length) - 1, + text: selected || " ", } const arr = out.get(row.id) if (arr) arr.push(entry) @@ -1010,8 +1050,11 @@ export function createCopyMode(input: { } if (!s.visual || !s.anchor) return out - const h = { idx: s.idx, col: s.col } + const h = { idx: s.idx, col: s.visual === "block" ? blockHeadCol(s, list, cache) : s.col } const { start, end } = orderEndpoints(s.anchor, h) + const blockLeft = Math.min(s.anchor.col, h.col) + const blockRight = Math.max(s.anchor.col, h.col) + const blockEnd = s.visual === "block" && s.stick === "end" for (let i = start.idx; i <= end.idx; i++) { const r = list[i] @@ -1020,29 +1063,63 @@ export function createCopyMode(input: { const text = rowText(r, cache) || "" const max = text.length > 0 ? min + text.length - 1 : min const left = - s.visual === "line" ? min : i === start.idx && i === end.idx ? start.col : i === start.idx ? start.col : min + s.visual === "line" + ? min + : s.visual === "block" + ? blockEnd && max < s.anchor.col + ? s.anchor.col + : blockEnd + ? Math.min(s.anchor.col, max) + : blockLeft + : i === start.idx && i === end.idx + ? start.col + : i === start.idx + ? start.col + : min const right = - s.visual === "line" ? max : i === start.idx && i === end.idx ? end.col : i === end.idx ? end.col : max + s.visual === "line" + ? max + : s.visual === "block" + ? blockEnd + ? Math.max(s.anchor.col, max) + : blockRight + : i === start.idx && i === end.idx + ? end.col + : i === end.idx + ? end.col + : max if (i !== h.idx) { - addHighlight(r, min, text, left, right) + addHighlight(r, min, text, left, right, true) continue } // cursor cell is painted separately by CopyOverlay so the cursor keeps its theme.text color - addHighlight(r, min, text, left, h.col - 1) - addHighlight(r, min, text, h.col + 1, right) + addHighlight(r, min, text, left, h.col - 1, true) + addHighlight(r, min, text, h.col + 1, right, true) } return out }) + const cursorCol = createMemo(() => { + const s = state() + if (!s.active) return 0 + const row = rows()[s.idx] + if (!row) return s.col + const min = copyMin(row) + const text = rowText(row) + const max = text.length > 0 ? min + text.length - 1 : min + return Math.max(min, Math.min(max, s.col)) + }) + const cursorText = createMemo(() => { const s = state() if (!s.active) return " " const row = rows()[s.idx] if (!row) return " " const text = copyText() + const cursor = cursorCol() let col = 0 for (const seg of segmenter.segment(text)) { - if (col >= s.col) return seg.segment + if (col >= cursor) return seg.segment col += Bun.stringWidth(seg.segment) } return " " @@ -1084,6 +1161,7 @@ export function createCopyMode(input: { unified, clamp, state, + cursorCol, cursorText, } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f3b3806de7c1..755972e8ba5d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1259,7 +1259,7 @@ export function Session() { cm.row()?.kind === "user" && cm.row()?.id === message.id ? { line: cm.row()!.line, - col: cm.state().col, + col: cm.cursorCol(), visual: !!cm.state().visual, cursorText: cm.cursorText(), } @@ -1288,7 +1288,7 @@ export function Session() { cm.row() ? { ...cm.row()!, - col: cm.state().col, + col: cm.cursorCol(), visual: !!cm.state().visual, cursorText: cm.cursorText(), } diff --git a/packages/opencode/test/cli/tui/vim-indicator.test.ts b/packages/opencode/test/cli/tui/vim-indicator.test.ts index 76f1e03c5812..1e262c3b5ac5 100644 --- a/packages/opencode/test/cli/tui/vim-indicator.test.ts +++ b/packages/opencode/test/cli/tui/vim-indicator.test.ts @@ -9,7 +9,7 @@ function label(opts?: { mode?: VimMode pending?: VimPending pendingDisplay?: string - copy?: undefined | "char" | "line" + copy?: undefined | "char" | "line" | "block" }) { return createRoot((dispose) => { const [enabled] = createSignal(opts?.enabled ?? true) @@ -56,5 +56,6 @@ describe("vim indicator", () => { test("shows visual labels in copy mode", () => { expect(label({ mode: "copy", copy: "char" })).toBe("-- VISUAL --") expect(label({ mode: "copy", copy: "line" })).toBe("-- VISUAL LINE --") + expect(label({ mode: "copy", copy: "block" })).toBe("-- VISUAL BLOCK --") }) }) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 71a6ee967056..b438800761b6 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -185,7 +185,7 @@ function createHandler( const [typed, setTyped] = createSignal(false) const [skipExitOnModeChange, setSkipExitOnModeChange] = createSignal(false) const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true) - const [copyVisual, setCopyVisual] = createSignal( + const [copyVisual, setCopyVisual] = createSignal( options?.copy?.isVisual ? "char" : undefined, ) const [meta, setMeta] = createSignal(options?.data) @@ -205,7 +205,7 @@ function createHandler( const navigateCalls: Array<"up" | "down"> = [] const copyMoves: Array<"up" | "down" | "left" | "right"> = [] const copyJumps: Array = [] - const copyVisualCalls: Array<"char" | "line"> = [] + const copyVisualCalls: Array<"char" | "line" | "block"> = [] const copyScrollCalls: Array<"center" | "top" | "bottom"> = [] let copyYanks = 0 let copyYankLines = 0 @@ -8026,6 +8026,17 @@ describe("copy mode", () => { expect(ctx.copyVisual()).toBe("line") }) + test("ctrl+v enters block visual copy mode", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + const evt = createEvent("v", { ctrl: true }) + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyVisualCalls).toEqual(["block"]) + expect(ctx.copyVisual()).toBe("block") + }) + + test("V is not consumed by plain v branch", () => { const ctx = createHandler("abc", { mode: "copy" }) @@ -8034,6 +8045,128 @@ describe("copy mode", () => { expect(ctx.copyVisualCalls).not.toContain("char") }) + test("visual line highlights empty selected rows", () => { + const cm = createRenderedCopyMode(["abcd", "", "efgh"]) + + cm.prompt.visual("line") + cm.prompt.move("down") + cm.prompt.move("down") + + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 7, right: 10, text: "abcd" }, + { line: 1, left: 7, right: 7, text: " " }, + { line: 2, left: 8, right: 10, text: "fgh" }, + ]) + }) + + test("character visual highlights empty selected rows", () => { + const cm = createRenderedCopyMode(["abcd", "", "efgh"]) + + cm.prompt.setCol(8) + cm.prompt.visual("char") + cm.prompt.move("down") + cm.prompt.move("down") + + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 1, left: 7, right: 7, text: " " }, + { line: 2, left: 7, right: 7, text: "e" }, + ]) + }) + + test("block visual yanks rectangular copy selection", () => { + const cm = createRenderedCopyMode(["abcd", "efgh", "ijkl"]) + + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("down") + cm.prompt.move("down") + cm.prompt.move("right") + + expect(cm.prompt.yank()).toEqual({ text: "bc\nfg\njk", linewise: false }) + }) + + test("block visual highlights rectangular copy selection", () => { + const cm = createRenderedCopyMode(["abcd", "efgh", "ijkl"]) + + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("down") + cm.prompt.move("right") + + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 8, right: 9, text: "bc" }, + { line: 1, left: 8, right: 8, text: "f" }, + ]) + }) + + test("block visual uses normal vertical movement on empty copy rows", () => { + const cm = createRenderedCopyMode(["abcd", "", "efgh"]) + + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("right") + cm.prompt.move("right") + cm.prompt.move("down") + + expect(cm.state().col).toBe(7) + expect(cm.cursorCol()).toBe(7) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 1, left: 8, right: 8, text: " " }, + ]) + }) + + test("block visual horizontal movement stays clamped on empty copy rows", () => { + const cm = createRenderedCopyMode(["abcd", "", "efgh"]) + + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("right") + cm.prompt.move("right") + cm.prompt.move("down") + cm.prompt.move("left") + + expect(cm.state().col).toBe(7) + expect(cm.cursorCol()).toBe(7) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 1, left: 8, right: 8, text: " " }, + ]) + }) + + test("block visual right movement stays on the current copy row", () => { + const cm = createRenderedCopyMode(["abcd", "", "ef"]) + + cm.prompt.setCol(8) + cm.prompt.visual("block") + for (let i = 0; i < 20; i++) cm.prompt.move("right") + cm.prompt.move("down") + + expect(cm.state().col).toBe(7) + expect(cm.cursorCol()).toBe(7) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 1, left: 8, right: 8, text: " " }, + ]) + }) + + test("$ in block visual moves to the current row end", () => { + const cm = createRenderedCopyMode(["abcdef", "ab"]) + + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("down") + cm.prompt.setCol(cm.prompt.text().length - 1) + cm.prompt.setStick("end") + + expect(cm.state().col).toBe(8) + expect(cm.cursorCol()).toBe(8) + expect(cm.cursorText()).toBe("b") + expect(cm.highlights().get("text-part")).toEqual([{ line: 0, left: 8, right: 12, text: "bcdef" }]) + expect(cm.prompt.yank()).toEqual({ text: "bcdef\nb", linewise: false }) + }) + test("V after characterwise visual preserves copy anchor", () => { createRoot((dispose) => { const children = [ From 592e8787b21bdf622425ef3a05f66716a0427c97 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 31 May 2026 07:59:02 +0400 Subject: [PATCH 2/5] fix: preserve visual block column when toggling selection end --- .../cli/cmd/tui/routes/session/copy-mode.ts | 2 +- .../opencode/test/cli/tui/vim-motions.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 435e0ebc7097..924639bf4749 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 @@ -654,7 +654,7 @@ export function createCopyMode(input: { idx: anchor.idx, col: anchor.col, stick: anchor.col - copyMin(rows()[anchor.idx]), - anchor: { idx: prev.idx, col: prev.col}, + anchor: { idx: prev.idx, col: prev.visual === "block" ? blockHeadCol(prev) : prev.col }, })) } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index b438800761b6..ed47db39d9c3 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -8809,6 +8809,26 @@ describe("copy mode", () => { }) }) + test("copyToggleVisualEnd preserves visual block virtual column on short rows", () => { + createRoot((dispose) => { + const cm = createRenderedCopyMode(["abcd", ""]) + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("right") + cm.prompt.move("right") + cm.prompt.move("down") + + expect(cm.prompt.yank()).toEqual({ text: "bcd\n ", linewise: false }) + + cm.prompt.copyToggleVisualEnd() + + expect(cm.state().anchor).toEqual({ idx: 1, col: 10 }) + expect(cm.prompt.yank()).toEqual({ text: "bcd\n ", linewise: false }) + + dispose() + }) + }) + test("copyToggleVisualEnd does nothing when no anchor", () => { createRoot((dispose) => { const cm = createRenderedCopyMode(["alpha", "beta"]) From c055a2a6fa759c464d52ff0cf90979703dfa3b98 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 31 May 2026 08:12:35 +0400 Subject: [PATCH 3/5] fix: clamp visual block head on emtpy copy rows --- .../src/cli/cmd/tui/routes/session/copy-mode.ts | 1 - .../opencode/test/cli/tui/vim-motions.test.ts | 17 +++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) 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 924639bf4749..a529073efc43 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 @@ -759,7 +759,6 @@ export function createCopyMode(input: { const row = list[s.idx] if (!row) return s.col if (s.stick === "end") return rowEndCol(row, cache) - if (typeof s.stick === "number") return Math.min(input.scroll().width - 2, copyMin(row, cache) + s.stick) return s.col } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index ed47db39d9c3..47e6f8cbebc7 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -8100,7 +8100,7 @@ describe("copy mode", () => { ]) }) - test("block visual uses normal vertical movement on empty copy rows", () => { + test("block visual uses clamped head column on empty copy rows", () => { const cm = createRenderedCopyMode(["abcd", "", "efgh"]) cm.prompt.setCol(8) @@ -8112,9 +8112,10 @@ describe("copy mode", () => { expect(cm.state().col).toBe(7) expect(cm.cursorCol()).toBe(7) expect(cm.highlights().get("text-part")).toEqual([ - { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 0, left: 7, right: 8, text: "ab" }, { line: 1, left: 8, right: 8, text: " " }, ]) + expect(cm.prompt.yank()).toEqual({ text: "ab\n ", linewise: false }) }) test("block visual horizontal movement stays clamped on empty copy rows", () => { @@ -8130,7 +8131,7 @@ describe("copy mode", () => { expect(cm.state().col).toBe(7) expect(cm.cursorCol()).toBe(7) expect(cm.highlights().get("text-part")).toEqual([ - { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 0, left: 7, right: 8, text: "ab" }, { line: 1, left: 8, right: 8, text: " " }, ]) }) @@ -8146,7 +8147,7 @@ describe("copy mode", () => { expect(cm.state().col).toBe(7) expect(cm.cursorCol()).toBe(7) expect(cm.highlights().get("text-part")).toEqual([ - { line: 0, left: 8, right: 10, text: "bcd" }, + { line: 0, left: 7, right: 8, text: "ab" }, { line: 1, left: 8, right: 8, text: " " }, ]) }) @@ -8809,7 +8810,7 @@ describe("copy mode", () => { }) }) - test("copyToggleVisualEnd preserves visual block virtual column on short rows", () => { + test("copyToggleVisualEnd preserves visual block selection on short rows", () => { createRoot((dispose) => { const cm = createRenderedCopyMode(["abcd", ""]) cm.prompt.setCol(8) @@ -8818,12 +8819,12 @@ describe("copy mode", () => { cm.prompt.move("right") cm.prompt.move("down") - expect(cm.prompt.yank()).toEqual({ text: "bcd\n ", linewise: false }) + expect(cm.prompt.yank()).toEqual({ text: "ab\n ", linewise: false }) cm.prompt.copyToggleVisualEnd() - expect(cm.state().anchor).toEqual({ idx: 1, col: 10 }) - expect(cm.prompt.yank()).toEqual({ text: "bcd\n ", linewise: false }) + expect(cm.state().anchor).toEqual({ idx: 1, col: 7 }) + expect(cm.prompt.yank()).toEqual({ text: "ab\n ", linewise: false }) dispose() }) From 739ac89a12936269f64c24aa1cc06cd6a5721da3 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 31 May 2026 08:27:56 +0400 Subject: [PATCH 4/5] fix: keep copy block EOL stick across endpoint swap --- .../cli/cmd/tui/routes/session/copy-mode.ts | 20 ++++++++++++------- .../opencode/test/cli/tui/vim-motions.test.ts | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) 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 a529073efc43..54574e4dee15 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 @@ -649,13 +649,19 @@ export function createCopyMode(input: { function copyToggleVisualEnd() { const anchor = state().anchor if (!anchor) return - setState((prev) => ({ - ...prev, - idx: anchor.idx, - col: anchor.col, - stick: anchor.col - copyMin(rows()[anchor.idx]), - anchor: { idx: prev.idx, col: prev.visual === "block" ? blockHeadCol(prev) : prev.col }, - })) + setState((prev) => { + const list = rows() + const row = list[anchor.idx] + const blockEnd = prev.visual === "block" && prev.stick === "end" && row + const col = blockEnd ? rowEndCol(row) : anchor.col + return { + ...prev, + idx: anchor.idx, + col, + stick: blockEnd ? "end" : col - copyMin(row), + anchor: { idx: prev.idx, col: prev.visual === "block" ? blockHeadCol(prev, list) : prev.col }, + } + }) } function wordRows(list: CopyRow[], cache: Map) { diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 47e6f8cbebc7..d90a81e42f99 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -8830,6 +8830,26 @@ describe("copy mode", () => { }) }) + test("copyToggleVisualEnd preserves visual block end selection", () => { + createRoot((dispose) => { + const cm = createRenderedCopyMode(["abcdef", "ab"]) + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("down") + cm.prompt.setCol(cm.prompt.text().length - 1) + cm.prompt.setStick("end") + + expect(cm.prompt.yank()).toEqual({ text: "bcdef\nb", linewise: false }) + + cm.prompt.copyToggleVisualEnd() + + expect(cm.state().stick).toBe("end") + expect(cm.prompt.yank()).toEqual({ text: "bcdef\nb", linewise: false }) + + dispose() + }) + }) + test("copyToggleVisualEnd does nothing when no anchor", () => { createRoot((dispose) => { const cm = createRenderedCopyMode(["alpha", "beta"]) From 2e03b662ac78cae08454732174c1ba6795a9739f Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Sun, 31 May 2026 09:44:13 +0400 Subject: [PATCH 5/5] fix: preserve block copy anchor when toggling EOL selection --- .../cli/cmd/tui/routes/session/copy-mode.ts | 2 +- .../opencode/test/cli/tui/vim-motions.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 54574e4dee15..1e1137bd5360 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 @@ -659,7 +659,7 @@ export function createCopyMode(input: { idx: anchor.idx, col, stick: blockEnd ? "end" : col - copyMin(row), - anchor: { idx: prev.idx, col: prev.visual === "block" ? blockHeadCol(prev, list) : prev.col }, + anchor: { idx: prev.idx, col: blockEnd ? anchor.col : prev.visual === "block" ? blockHeadCol(prev, list) : prev.col }, } }) } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index d90a81e42f99..a8dc7684010c 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -8850,6 +8850,26 @@ describe("copy mode", () => { }) }) + test("copyToggleVisualEnd preserves visual block end selection when head row is longer", () => { + createRoot((dispose) => { + const cm = createRenderedCopyMode(["abc", "abcdef"]) + cm.prompt.setCol(8) + cm.prompt.visual("block") + cm.prompt.move("down") + cm.prompt.setCol(cm.prompt.text().length - 1) + cm.prompt.setStick("end") + + expect(cm.prompt.yank()).toEqual({ text: "bc\nbcdef", linewise: false }) + + cm.prompt.copyToggleVisualEnd() + + expect(cm.state().stick).toBe("end") + expect(cm.prompt.yank()).toEqual({ text: "bc\nbcdef", linewise: false }) + + dispose() + }) + }) + test("copyToggleVisualEnd does nothing when no anchor", () => { createRoot((dispose) => { const cm = createRenderedCopyMode(["alpha", "beta"])