From 4e3d8885ed17b987587b341b30ae8ab7c760f065 Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Tue, 28 Apr 2026 07:38:34 -0500 Subject: [PATCH 1/6] add yy to copymode --- README.md | 1 + .../cli/cmd/tui/component/prompt/index.tsx | 8 +++ .../cli/cmd/tui/component/vim/vim-handler.ts | 28 +++++++++- .../cli/cmd/tui/routes/session/copy-mode.ts | 53 ++++++++++++++++++- .../opencode/test/cli/tui/vim-motions.test.ts | 29 ++++++++++ 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6938956b4493..ba2f0ce3bbad 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Works similarly to tmux copy mode within opencode tui. - Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`). - Press `v` / `V` to start character-wise or line-wise selection. - `y` yanks to the vim register. +- `yy` yanks the current line and exits copy mode. - `Enter` copies to the system clipboard. - `Escape` exits visual mode, `q` exits copy mode. - `z` `zt` `zz` `zb` adjust copy-mode scroll positioning. 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 61235dff7b4a..a8bbee2b1a99 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -93,6 +93,7 @@ export type PromptProps = { exit: () => void visual: (mode: "char" | "line") => void yank: () => { text: string; linewise: boolean } | null + yankLine: () => { text: string; linewise: boolean } | null copy: () => Promise | void isVisual: () => boolean exitVisual: () => void @@ -514,10 +515,17 @@ export function Prompt(props: PromptProps) { copyExitVisual() { props.copy?.exitVisual() }, + copyExit() { + props.copy?.exit() + }, copyYank() { const reg = props.copy?.yank() if (reg) setVimRegister(reg, true) }, + copyYankLine() { + const reg = props.copy?.yankLine() + if (reg) setVimRegister(reg, true) + }, copyCopy() { return props.copy?.copy() }, 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 a0ccb9324837..ac3ece5b1d17 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 @@ -83,7 +83,9 @@ export function createVimHandler(input: { copy?: (action: VimCopyMove) => void copyVisual?: (mode: "char" | "line") => void copyExitVisual?: () => void + copyExit?: () => void copyYank?: () => void + copyYankLine?: () => void copyCopy?: () => void copyIsVisual?: () => boolean copyJump?: (action: VimJump) => void @@ -1011,8 +1013,25 @@ export function createVimHandler(input: { } if (key === "y") { - input.copyYank?.() - input.state.setMode("normal") + // If in visual mode, yank selection and exit (original y behavior) + if (input.copyIsVisual?.()) { + input.copyYank?.() + input.state.setMode("normal") + event.preventDefault() + return true + } + // Not in visual mode - handle yy for yanking current line + if (input.state.pending() === "y") { + input.state.clearPending() + input.copyYankLine?.() + setTimeout(() => { + input.state.setMode("normal") + input.copyExit?.() + }, 150) + event.preventDefault() + return true + } + input.state.setPending("y") event.preventDefault() return true } @@ -1227,6 +1246,11 @@ export function createVimHandler(input: { return true } + // Clear y pending if no handler matched + if (input.state.pending() === "y") { + input.state.clearPending() + } + return false } 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 d6832e79588e..ba10e7cc84ee 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 @@ -59,6 +59,7 @@ export function createCopyMode(input: { toBottom: () => void }) { const [state, setState] = createSignal({ ...empty }) + const [yankLineFlash, setYankLineFlash] = createSignal(undefined) // --- row building --- @@ -329,6 +330,7 @@ export function createCopyMode(input: { } function exit() { + setYankLineFlash(undefined) setState({ ...empty }) input.toBottom() } @@ -484,6 +486,23 @@ export function createCopyMode(input: { return { text, linewise: false } } + function yankLine() { + const list = rows() + const s = state() + const row = list[s.idx] + if (!row) return null + const cache = new Map( + input + .scroll() + .getChildren() + .map((c) => [c.id, c]), + ) + const text = signedText(row, cache) + setYankLineFlash(s.idx) + setTimeout(() => setYankLineFlash(undefined), 150) + return { text, linewise: true } + } + async function copy() { const text = selectionText() if (!text) return @@ -593,7 +612,36 @@ export function createCopyMode(input: { const highlights = createMemo(() => { const s = state() - if (!s.visual || !s.anchor) return new Map() + const flashIdx = yankLineFlash() + const out = new Map() + + // Handle yy flash highlight + if (flashIdx !== undefined) { + const list = rows() + const row = list[flashIdx] + if (row) { + const cache = new Map( + input + .scroll() + .getChildren() + .map((c) => [c.id, c]), + ) + const text = rowText(row, cache) || "" + const min = copyMin(row, cache) + const max = text.length > 0 ? min + text.length - 1 : min + const cur = out.get(row.id) ?? [] + cur.push({ + line: row.line, + left: min, + right: max, + text: text.slice(Math.max(0, min - min), Math.max(0, max - min + 1)), + }) + out.set(row.id, cur) + } + } + + // Handle visual mode highlights + if (!s.visual || !s.anchor) return out const list = rows() const cache = new Map( input @@ -605,7 +653,6 @@ export function createCopyMode(input: { const h = { idx: s.idx, col: s.col } const start = a.idx <= h.idx ? a : h const end = a.idx <= h.idx ? h : a - const out = new Map() for (let i = start.idx; i <= end.idx; i++) { const r = list[i] if (!r) continue @@ -634,6 +681,7 @@ export function createCopyMode(input: { exit, visual, yank, + yankLine, copy, isVisual: () => !!state().visual, exitVisual, @@ -653,6 +701,7 @@ export function createCopyMode(input: { }, row, highlights, + yankLineFlash: () => yankLineFlash(), active: () => state().active, clamp, state, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index b3ae52fbda11..270fa4e943fe 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -186,6 +186,7 @@ function createHandler( const copyVisualCalls: Array<"char" | "line"> = [] const copyScrollCalls: Array<"center" | "top" | "bottom"> = [] let copyYanks = 0 + let copyYankLines = 0 let copyCopies = 0 let copyExitVisuals = 0 @@ -306,6 +307,10 @@ function createHandler( copyYanks++ state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false }) }, + copyYankLine() { + copyYankLines++ + state.setRegister({ text: options?.copy?.text ?? "picked line", linewise: true }) + }, copyCopy() { copyCopies++ }, @@ -388,6 +393,7 @@ function createHandler( copyVisualCalls, copyScrollCalls, copyYanks: () => copyYanks, + copyYankLines: () => copyYankLines, copyCopies: () => copyCopies, copyExitVisuals: () => copyExitVisuals, copyCol, @@ -4362,6 +4368,29 @@ describe("copy mode", () => { expect(ctx.state.mode()).toBe("normal") }) + test("yy yanks current line and exits copy mode", async () => { + const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked line" } }) + + const first = createEvent("y") + expect(ctx.handler.handleKey(first.event)).toBe(true) + expect(first.prevented()).toBe(true) + expect(ctx.copyYankLines()).toBe(0) + expect(ctx.state.pending()).toBe("y") + + const second = createEvent("y") + expect(ctx.handler.handleKey(second.event)).toBe(true) + expect(second.prevented()).toBe(true) + expect(ctx.copyYankLines()).toBe(1) + expect(ctx.copyYanks()).toBe(0) + expect(ctx.copyCopies()).toBe(0) + expect(ctx.state.register()).toEqual({ text: "picked line", linewise: true }) + expect(ctx.state.pending()).toBe("") + + // Wait for setTimeout to complete + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(ctx.state.mode()).toBe("normal") + }) + test("return copies selection to clipboard path and exits copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } }) From 6e930c3d1b6e884346d779037ec28114b11f99e6 Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Tue, 28 Apr 2026 21:46:27 -0500 Subject: [PATCH 2/6] fix y pending not getting cleared in copy mode and lower delay --- .../src/cli/cmd/tui/component/vim/vim-handler.ts | 11 +++++++---- .../src/cli/cmd/tui/routes/session/copy-mode.ts | 3 +-- 2 files changed, 8 insertions(+), 6 deletions(-) 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 ac3ece5b1d17..b0d3efb099d6 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 @@ -950,6 +950,7 @@ export function createVimHandler(input: { function copy(event: VimEvent, key: string): boolean { if (key === "q") { + input.state.clearPending() input.state.setMode("normal") event.preventDefault() return true @@ -961,6 +962,7 @@ export function createVimHandler(input: { event.preventDefault() return true } + input.state.clearPending() input.state.setMode("normal") event.preventDefault() return true @@ -1024,10 +1026,8 @@ export function createVimHandler(input: { if (input.state.pending() === "y") { input.state.clearPending() input.copyYankLine?.() - setTimeout(() => { - input.state.setMode("normal") - input.copyExit?.() - }, 150) + input.state.setMode("normal") + input.copyExit?.() event.preventDefault() return true } @@ -1044,6 +1044,9 @@ export function createVimHandler(input: { } const pending = input.state.pending() + if (pending === "y") { + input.state.clearPending() + } if (pending === "f" || pending === "F" || pending === "t" || pending === "T") { if (key.length === 1) { const forward = pending === "f" || pending === "t" 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 ba10e7cc84ee..8b5d5ea342c0 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 @@ -330,7 +330,6 @@ export function createCopyMode(input: { } function exit() { - setYankLineFlash(undefined) setState({ ...empty }) input.toBottom() } @@ -499,7 +498,7 @@ export function createCopyMode(input: { ) const text = signedText(row, cache) setYankLineFlash(s.idx) - setTimeout(() => setYankLineFlash(undefined), 150) + setTimeout(() => setYankLineFlash(undefined), 70) return { text, linewise: true } } From 1e9e9617dadeb308e68e24b9270d5b034ad311a1 Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Wed, 29 Apr 2026 06:24:31 -0500 Subject: [PATCH 3/6] Fix pending y surving other copymode actions --- .../cli/cmd/tui/component/vim/vim-handler.ts | 69 +++++++++---------- .../opencode/test/cli/tui/vim-motions.test.ts | 17 +++++ 2 files changed, 51 insertions(+), 35 deletions(-) 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 b0d3efb099d6..4b18582052b8 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 @@ -949,8 +949,41 @@ export function createVimHandler(input: { } function copy(event: VimEvent, key: string): boolean { - if (key === "q") { + + if (key === "y") { + // If in visual mode, yank selection and exit (original y behavior) + if (input.copyIsVisual?.()) { + input.copyYank?.() + input.state.setMode("normal") + event.preventDefault() + return true + } + // Not in visual mode - handle yy for yanking current line + if (input.state.pending() === "y") { + input.state.clearPending() + input.copyYankLine?.() + input.state.setMode("normal") + input.copyExit?.() + event.preventDefault() + return true + } + input.state.setPending("y") + event.preventDefault() + return true + } + + const pending = input.state.pending() + if (pending === "y") { input.state.clearPending() + } + + if (key === "return") { + input.copyCopy?.() + input.state.setMode("normal") + event.preventDefault() + return true + } + if (key === "q") { input.state.setMode("normal") event.preventDefault() return true @@ -962,7 +995,6 @@ export function createVimHandler(input: { event.preventDefault() return true } - input.state.clearPending() input.state.setMode("normal") event.preventDefault() return true @@ -1014,39 +1046,6 @@ export function createVimHandler(input: { return true } - if (key === "y") { - // If in visual mode, yank selection and exit (original y behavior) - if (input.copyIsVisual?.()) { - input.copyYank?.() - input.state.setMode("normal") - event.preventDefault() - return true - } - // Not in visual mode - handle yy for yanking current line - if (input.state.pending() === "y") { - input.state.clearPending() - input.copyYankLine?.() - input.state.setMode("normal") - input.copyExit?.() - event.preventDefault() - return true - } - input.state.setPending("y") - event.preventDefault() - return true - } - - if (key === "return") { - input.copyCopy?.() - input.state.setMode("normal") - event.preventDefault() - return true - } - - const pending = input.state.pending() - if (pending === "y") { - input.state.clearPending() - } if (pending === "f" || pending === "F" || pending === "t" || pending === "T") { if (key.length === 1) { const forward = pending === "f" || pending === "t" diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 270fa4e943fe..6bec2086b06d 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -4391,6 +4391,23 @@ describe("copy mode", () => { expect(ctx.state.mode()).toBe("normal") }) + test("y H y in copy mode should not trigger yy ", () => { + const ctx = createHandler("abc", { mode: "copy"}) + + ctx.handler.handleKey(createEvent("y").event) + expect(ctx.state.pending()).toBe("y") + + // H should clear pending y + ctx.handler.handleKey(createEvent("H").event) + expect(ctx.state.pending()).toBe("") + expect(ctx.copyJumps).toContain("high") + + // second y starts pending y and does not trigger yy + ctx.handler.handleKey(createEvent("y").event) + expect(ctx.state.pending()).toBe("y") + expect(ctx.copyYankLines()).toBe(0) + }) + test("return copies selection to clipboard path and exits copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } }) From f95f411d6542db75d1249c15d46469a88e899d9f Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Fri, 1 May 2026 13:17:05 -0500 Subject: [PATCH 4/6] fix yy highlighting the wrong line --- .../opencode/src/cli/cmd/tui/component/vim/vim-handler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 4b18582052b8..3dafee130325 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 @@ -962,8 +962,10 @@ export function createVimHandler(input: { if (input.state.pending() === "y") { input.state.clearPending() input.copyYankLine?.() - input.state.setMode("normal") - input.copyExit?.() + setTimeout(() => { + input.state.setMode("normal") + input.copyExit?.() + }, 150) event.preventDefault() return true } From af1324d1a14065dc3a4388c6655dd05276842226 Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Fri, 1 May 2026 17:04:11 -0500 Subject: [PATCH 5/6] fix whitespace --- packages/opencode/test/cli/tui/vim-motions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 426302c31624..f936b5f954b7 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -4554,7 +4554,7 @@ describe("copy mode", () => { ctx.handler.handleKey(createEvent("y").event) expect(ctx.state.pending()).toBe("y") - // H should clear pending y + // H should clear pending y ctx.handler.handleKey(createEvent("H").event) expect(ctx.state.pending()).toBe("") expect(ctx.copyJumps).toContain("high") From 3195a00221d811fe144594086bf6d8596e8c47cd Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Fri, 1 May 2026 17:08:37 -0500 Subject: [PATCH 6/6] fix merge issues --- .../opencode/src/cli/cmd/tui/routes/session/copy-mode.ts | 5 +---- 1 file changed, 1 insertion(+), 4 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 93d671b5062f..50f0669e77b8 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 @@ -653,10 +653,7 @@ export function createCopyMode(input: { .map((c) => [c.id, c]), ) const h = { idx: s.idx, col: s.col } - const start = a.idx <= h.idx ? a : h - const end = a.idx <= h.idx ? h : a const { start, end } = orderEndpoints(s.anchor, h) - const out = new Map() const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => { if (left > right) return const entry = { @@ -669,7 +666,7 @@ export function createCopyMode(input: { if (arr) arr.push(entry) else out.set(row.id, [entry]) } - + for (let i = start.idx; i <= end.idx; i++) { const r = list[i] if (!r) continue