diff --git a/README.md b/README.md index 6aa672d38b12..34397c69c28b 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 e1b26cd63bbc..19eb666aa216 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -101,14 +101,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 @@ -1622,7 +1622,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"), } }) @@ -2277,7 +2277,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 d9f758afe0a1..a639cc8cf2e2 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 @@ -126,7 +126,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 @@ -1418,6 +1418,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 8cc5afab3096..2ba2c5605b31 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 copySearch?: Accessor }) { return createMemo(() => { @@ -17,6 +17,7 @@ export function useVimIndicator(input: { if (search !== undefined) return search 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 0fe29b28436b..80cd41bb9c72 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 @@ -33,12 +33,14 @@ export type CopyHighlight = { current?: boolean } +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 } } @@ -659,7 +661,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 } @@ -667,20 +670,27 @@ 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 })) } 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.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: blockEnd ? anchor.col : prev.visual === "block" ? blockHeadCol(prev, list) : prev.col }, + } + }) } function wordRows(list: CopyRow[], cache: Map) { @@ -774,6 +784,19 @@ 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) + return s.col + } + // --- search --- function childCache() { @@ -912,7 +935,7 @@ export function createCopyMode(input: { // --- visual --- - function visual(mode: "char" | "line") { + function visual(mode: CopyVisualMode) { const s = state() if (!s.active) return if (s.visual === mode) { @@ -930,7 +953,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 @@ -945,6 +968,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 "" @@ -967,7 +1009,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() { @@ -1131,14 +1173,17 @@ export function createCopyMode(input: { text: string, left: number, right: number, - options?: Pick, + options?: Pick & { placeholder?: boolean }, ) => { if (left > right) return + const selected = text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)) + if (!selected && !options?.placeholder) return + const start = Math.max(left, min) const entry: CopyHighlight = { 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 || " ", ...(options?.kind ? { kind: options.kind } : {}), ...(options?.current ? { current: true } : {}), } @@ -1193,8 +1238,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] @@ -1203,29 +1251,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, { placeholder: 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, { placeholder: true }) + addHighlight(r, min, text, h.col + 1, right, { placeholder: 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 " " @@ -1283,6 +1365,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 54837095b532..7bfa1f14e22b 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"> = [] const copySearchCalls: Array<"forward" | "backward"> = [] const copySearchAppends: string[] = [] @@ -8288,6 +8288,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" }) @@ -8296,6 +8307,129 @@ 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 clamped head column 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: 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", () => { + 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: 7, right: 8, text: "ab" }, + { 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: 7, right: 8, text: "ab" }, + { 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 = [ @@ -8963,6 +9097,66 @@ describe("copy mode", () => { }) }) + test("copyToggleVisualEnd preserves visual block selection 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: "ab\n ", linewise: false }) + + cm.prompt.copyToggleVisualEnd() + + expect(cm.state().anchor).toEqual({ idx: 1, col: 7 }) + expect(cm.prompt.yank()).toEqual({ text: "ab\n ", linewise: false }) + + dispose() + }) + }) + + 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 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"])