From ad9b21adc4867bca5555155087cc9b902119d947 Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Mon, 27 Apr 2026 09:07:01 -0500 Subject: [PATCH 1/2] enhance entering, exitings, and copying in copy mode --- README.md | 9 +- .../cli/cmd/tui/component/prompt/index.tsx | 39 ++++- .../cli/cmd/tui/component/vim/vim-handler.ts | 68 ++++++++- .../vim/vim-motion-window-navigation.ts | 24 +++ .../cli/cmd/tui/component/vim/vim-state.ts | 8 +- .../cli/cmd/tui/routes/session/copy-mode.ts | 139 ++++++++++++++++-- .../opencode/test/cli/tui/vim-motions.test.ts | 107 +++++++++++++- 7 files changed, 366 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/vim/vim-motion-window-navigation.ts diff --git a/README.md b/README.md index 7ef7a72371cf..112ccf7b3148 100644 --- a/README.md +++ b/README.md @@ -83,14 +83,15 @@ Works similarly to tmux copy mode within opencode tui. -- Enter copy mode with `v`. +- Enter copy mode with `v` (scrolls to latest message) or `Ctrl+W k` (stays in place). +- Exit with `q` or `Escape` (scrolls to latest message), `Ctrl+W j` or `i` (stays in place, `i` returns to insert mode). - Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`). +- `H` / `M` / `L` jump to the top / middle / bottom of the viewport. - Press `v` / `V` to start character-wise or line-wise selection. -- `y` yanks to the vim register. +- `y` yanks visual selection to the vim register (stays in copy mode). +- `yy` yanks the current line to the vim register with a brief highlight flash. - `Enter` copies to the system clipboard. -- `Escape` exits visual mode, `q` exits copy mode. - `z` `zt` `zz` `zb` adjust copy-mode scroll positioning. -- `H` / `M` / `L` jump to the top / middle / bottom of the viewport. > [!TIP] > Configure the entry key with `keybinds.copy_mode` in your config if you want something other than `v`. 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 f10f645c8a27..64defd06542d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -89,9 +89,10 @@ export type PromptProps = { } copy?: { enter: () => void - exit: () => void + exit: (scrollToBottom?: boolean) => 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 @@ -365,6 +366,24 @@ export function Prompt(props: PromptProps) { return input.plainText.length > 0 } + function handleNavigation(action: "up" | "down") { + if (!props.copy) return + if (action === "up" && !vimState.isCopy()) { + vimState.setMode("copy") + props.copy.enter() + } + if (action === "down" && vimState.isCopy()) { + const skipExit = vimState.skipExitOnModeChange() + const scrollToBottom = vimState.exitScrollToBottom() + vimState.setSkipExitOnModeChange(false) + vimState.setExitScrollToBottom(true) + vimState.setMode("normal") + if (!skipExit) { + props.copy.exit(scrollToBottom) + } + } + } + function promptJump(action: "top" | "bottom" | "high" | "middle" | "low") { if (!input || input.isDestroyed) return if (action === "top") { @@ -417,6 +436,9 @@ export function Prompt(props: PromptProps) { if (action === "top") command.trigger("session.first") if (action === "bottom") command.trigger("session.last") }, + navigate(action) { + handleNavigation(action) + }, copy(action) { props.copy?.move(action) }, @@ -426,10 +448,17 @@ export function Prompt(props: PromptProps) { copyExitVisual() { props.copy?.exitVisual() }, + copyExit(scrollToBottom) { + props.copy?.exit(scrollToBottom) + }, copyYank() { const reg = props.copy?.yank() if (reg) vimState.setRegister(reg) }, + copyYankLine() { + const reg = props.copy?.yankLine() + if (reg) vimState.setRegister(reg) + }, copyCopy() { return props.copy?.copy() }, @@ -1390,7 +1419,13 @@ export function Prompt(props: PromptProps) { if (vimState.isCopy()) { const active = vimState.isCopy() vim.handleKey(e) - if (active && !vimState.isCopy()) props.copy?.exit() + if (active && !vimState.isCopy()) { + const skipExit = vimState.skipExitOnModeChange() + const scrollToBottom = vimState.exitScrollToBottom() + vimState.setSkipExitOnModeChange(false) + vimState.setExitScrollToBottom(true) + if (!skipExit) props.copy?.exit(scrollToBottom) + } if (!e.defaultPrevented) e.preventDefault() return } 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 4f4fd721ea32..0673c2ca5616 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 @@ -3,6 +3,7 @@ import type { createVimState, VimSnapshot } from "./vim-state" import type { TextareaRenderable } from "@opentui/core" import { vimScroll, type VimScroll } from "./vim-scroll" import { vimJump, type VimJump } from "./vim-motion-jump" +import { vimWindowNavigation, type VimWindowNavigation } from "./vim-motion-window-navigation" import { appendAfterCursor, appendLineEnd, @@ -66,10 +67,13 @@ export function createVimHandler(input: { submit: () => void scroll: (action: VimScroll) => void jump: (action: VimJump) => void + navigate: (action: VimWindowNavigation) => void copy?: (action: VimCopyMove) => void copyVisual?: (mode: "char" | "line") => void copyExitVisual?: () => void + copyExit?: (scrollToBottom?: boolean) => void copyYank?: () => void + copyYankLine?: () => void copyCopy?: () => void copyIsVisual?: () => boolean copyJump?: (action: VimJump) => void @@ -182,6 +186,16 @@ export function createVimHandler(input: { return true } + const navigation = vimWindowNavigation(event, input.state) + if (navigation.handled) { + if (navigation.action) { + input.state.clearPending() + input.navigate(navigation.action) + } + event.preventDefault() + return true + } + if (key === "escape") { if (input.state.isVisual()) { clearSelection(input.textarea()) @@ -705,6 +719,12 @@ export function createVimHandler(input: { return true } + if (key === "w" && hasModifier(event)) { + input.state.setPending("w") + event.preventDefault() + return true + } + if (key === "backspace" || key === "delete") { event.preventDefault() return true @@ -740,6 +760,36 @@ export function createVimHandler(input: { return true } + if (key === "i") { + if (input.copyIsVisual?.()) input.copyExitVisual?.() + input.state.setSkipExitOnModeChange(true) + input.state.setExitScrollToBottom(false) + input.state.setMode("insert") + input.copyExit?.(false) + event.preventDefault() + return true + } + + if (input.state.pending() === "w" && key === "j") { + if (input.copyIsVisual?.()) { + input.copyExitVisual?.() + event.preventDefault() + return true + } + input.state.setSkipExitOnModeChange(true) + input.state.setExitScrollToBottom(false) + input.state.setMode("normal") + input.copyExit?.(false) + event.preventDefault() + return true + } + + if (key === "w" && hasModifier(event)) { + input.state.setPending("w") + event.preventDefault() + return true + } + const scroll = vimScroll(event) if (scroll) { input.scroll(scroll) @@ -786,9 +836,23 @@ export function createVimHandler(input: { return true } + if (key === "y" && input.state.pending() === "y") { + // yy — yank current line with flash highlight + input.state.clearPending() + input.copyYankLine?.() + event.preventDefault() + return true + } + if (key === "y") { - input.copyYank?.() - input.state.setMode("normal") + if (!input.copyIsVisual?.()) { + // first y — set pending for yy + input.state.setPending("y") + } else { + // y in visual — yank selection, flash highlight then clear + input.copyYank?.() + setTimeout(() => input.copyExitVisual?.(), 150) + } event.preventDefault() return true } diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motion-window-navigation.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motion-window-navigation.ts new file mode 100644 index 000000000000..845e36c4774b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motion-window-navigation.ts @@ -0,0 +1,24 @@ +import type { VimEvent } from "./vim-handler" +import type { createVimState } from "./vim-state" + +export type VimWindowNavigation = "up" | "down" + +export function vimWindowNavigation(event: VimEvent, state: ReturnType) { + const key = event.name ?? "" + + if (state.pending() === "w") { + if (key === "k") { + state.clearPending() + return { action: "up" as VimWindowNavigation, handled: true } + } + + if (key === "j") { + state.clearPending() + return { action: "down" as VimWindowNavigation, handled: true } + } + + return { handled: false } + } + + return { handled: false } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts index 6e6c5642c24d..e3267e997b38 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts @@ -1,7 +1,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" export type VimMode = "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy" -export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" +export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" export type VimFind = { char: string; forward: boolean; till: boolean } | null export type VimRegister = { text: string; linewise: boolean } | null export type VimSnapshot = { text: string; cursor: number; data?: unknown } @@ -22,6 +22,8 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac const [undos, setUndos] = createSignal([]) const [redos, setRedos] = createSignal([]) const [edit, setEdit] = createSignal(null) + const [skipExitOnModeChange, setSkipExitOnModeChange] = createSignal(false) + const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true) function clearPending() { if (pending()) setPending("") @@ -126,5 +128,9 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac isVisual: createMemo(() => mode() === "visual" || mode() === "visual-line"), isVisualLine: createMemo(() => mode() === "visual-line"), isCopy: createMemo(() => mode() === "copy"), + skipExitOnModeChange, + setSkipExitOnModeChange, + exitScrollToBottom, + setExitScrollToBottom, } } 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 9378d5b9e5f1..fb36d23f5b54 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 @@ -280,6 +280,63 @@ export function createCopyMode(input: { setState((s) => ({ ...s, stick })) } + // --- scroll compensation --- + // When entering/exiting copy mode, diffs switch between split/unified view, + // changing content heights. We snapshot a reference child before the toggle, + // then poll until layout has actually changed and compensate the scroll delta. + + let compensateTimer: ReturnType | undefined + + function snapshotScroll() { + const scr = input.scroll() + if (!scr) return undefined + const scrollY = scr.scrollTop + const atBottom = scrollY + scr.height >= scr.scrollHeight - 1 + // Children .y values are viewport-relative, so use scr.y (≈0) to find visible ones + const children = scr.getChildren().toSorted((a, b) => a.y - b.y) + const ref = children.find(c => c.id && c.y + c.height > scr.y) + if (!ref?.id) return undefined + return { id: ref.id, childY: ref.y, scrollY, atBottom } + } + + function compensateScroll(snap: ReturnType, afterSettle?: () => void) { + if (compensateTimer) clearTimeout(compensateTimer) + if (!snap) { afterSettle?.(); return } + + const tryCompensate = () => { + const scr = input.scroll() + if (!scr || scr.isDestroyed) return false + const child = scr.getChildren().find(c => c.id === snap.id) + if (!child) return false + const oldAbsolute = snap.scrollY + snap.childY + const newAbsolute = scr.scrollTop + child.y + const contentDelta = newAbsolute - oldAbsolute + if (contentDelta === 0) return false + const targetY = snap.atBottom ? scr.scrollHeight : snap.scrollY + contentDelta + scr.scrollTo(targetY) + return true + } + + // Try synchronously first — Solid renders are synchronous so layout + // may already reflect the new state + if (tryCompensate()) { + afterSettle?.() + return + } + + // Fall back to polling if layout hasn't updated yet + let attempts = 0 + const poll = () => { + attempts++ + if (tryCompensate() || attempts >= 10) { + afterSettle?.() + return + } + compensateTimer = setTimeout(poll, 16) + } + compensateTimer = setTimeout(poll, 0) + } + // --- navigation --- function sync(next: number) { @@ -305,26 +362,61 @@ export function createCopyMode(input: { } } + function pickVisibleTarget(list: CopyRow[], scr: { y: number; height: number }) { + const top = scr.y + const bottom = scr.y + scr.height - 1 + const visible = list.filter(x => x.y >= top && x.y <= bottom) + if (visible.length) { + const midY = top + (bottom - top) / 2 + const best = visible.reduce((a, b) => + Math.abs(a.y - midY) < Math.abs(b.y - midY) ? a : b + ) + return list.indexOf(best) + } + const idx = list.findLastIndex(x => x.role === "assistant") + return idx >= 0 ? idx : list.length - 1 + } + function enter() { const init = () => { + const scr = input.scroll() const list = rows() if (!list.length) return false - const idx = list.findLastIndex((x) => x.role === "assistant") - const target = idx >= 0 ? idx : list.length - 1 + + // Pick initial target from currently visible rows BEFORE activating + const target = pickVisibleTarget(list, scr) const row = list[target] - setState((s) => ({ ...s, col: copyMin(row), stick: "first" as const })) - sync(target) + + const snap = snapshotScroll() + setState((s) => ({ ...s, active: true, idx: target, col: copyMin(row), stick: "first" as const })) + compensateScroll(snap, () => { + // After compensation, re-pick in case layout shifted + const postScr = input.scroll() + if (!postScr || postScr.isDestroyed) return + const postList = rows() + if (!postList.length) return + const newTarget = pickVisibleTarget(postList, postScr) + const newRow = postList[newTarget] + if (newRow) { + setState((s) => ({ ...s, idx: newTarget, col: copyMin(newRow), stick: "first" as const })) + } + }) return true } if (init()) return - setTimeout(() => { - init() - }, 0) + setTimeout(() => init(), 0) } - function exit() { - setState({ ...empty }) - input.toBottom() + function exit(scrollToBottom?: boolean) { + if (scrollToBottom === undefined || scrollToBottom) { + setState({ ...empty }) + input.toBottom() + return + } + // Exit without scrolling — keep current scroll position + const snap = snapshotScroll() + setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined })) + compensateScroll(snap) } function move(action: "up" | "down" | "left" | "right") { @@ -398,6 +490,19 @@ export function createCopyMode(input: { setState((s) => ({ ...s, visual: undefined, anchor: undefined })) } + let yankFlashTimer: ReturnType | undefined + function yankLine() { + visual("line") + const reg = yank() + // Keep the highlight visible briefly, then clear + if (yankFlashTimer) clearTimeout(yankFlashTimer) + yankFlashTimer = setTimeout(() => { + yankFlashTimer = undefined + exitVisual() + }, 150) + return reg + } + function selectionText(): string { const s = state() if (!s.visual || !s.anchor) return "" @@ -529,17 +634,20 @@ export function createCopyMode(input: { return id }) - createEffect(() => { + createEffect((prev: boolean | undefined) => { const s = state() const list = rows() - if (!s.active) return + if (!s.active) return s.active + // Skip the initial activation — enter() already set the correct idx + if (prev === false || prev === undefined) return s.active if (!list.length) { exit() - return + return s.active } if (s.idx >= list.length) { sync(list.length - 1) } + return s.active }) // --- derived --- @@ -552,7 +660,7 @@ export function createCopyMode(input: { const highlights = createMemo(() => { const s = state() - if (!s.visual || !s.anchor) return new Map() + if (!s.active || !s.visual || !s.anchor) return new Map() const list = rows() const cache = new Map( input @@ -590,9 +698,10 @@ export function createCopyMode(input: { return { prompt: { enter, - exit, + exit: (scrollToBottom?: boolean) => exit(scrollToBottom), visual, yank, + yankLine, copy, isVisual: () => !!state().visual, exitVisual, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 1f495d25fa26..cc28d08c5461 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -178,6 +178,7 @@ function createHandler( let copyYanks = 0 let copyCopies = 0 let copyExitVisuals = 0 + const copyExits: Array = [] function clearPending() { setPending("") @@ -267,6 +268,10 @@ function createHandler( isVisual: () => mode() === "visual" || mode() === "visual-line", isVisualLine: () => mode() === "visual-line", isCopy: () => mode() === "copy", + skipExitOnModeChange: () => false, + setSkipExitOnModeChange() {}, + exitScrollToBottom: () => true, + setExitScrollToBottom() {}, } as ReturnType const handler = createVimHandler({ enabled, @@ -279,6 +284,8 @@ function createHandler( jump(action) { jumpCalls.push(action) }, + navigate() { + }, copy(action) { copyMoves.push(action) }, @@ -294,6 +301,13 @@ function createHandler( copyYanks++ state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false }) }, + copyYankLine() { + copyYanks++ + state.setRegister({ text: options?.copy?.text ?? "picked line", linewise: true }) + }, + copyExit(scrollToBottom) { + copyExits.push(scrollToBottom) + }, copyCopy() { copyCopies++ }, @@ -360,6 +374,7 @@ function createHandler( copyYanks: () => copyYanks, copyCopies: () => copyCopies, copyExitVisuals: () => copyExitVisuals, + copyExits, copyCol, copyIdx, meta, @@ -2632,7 +2647,7 @@ describe("copy mode", () => { expect(ctx.copyVisualCalls).not.toContain("char") }) - test("y yanks copy selection and exits copy mode", () => { + test("y yanks copy selection and stays in copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked text", isVisual: true } }) const evt = createEvent("y") @@ -2641,7 +2656,7 @@ describe("copy mode", () => { expect(ctx.copyYanks()).toBe(1) expect(ctx.copyCopies()).toBe(0) expect(ctx.state.register()).toEqual({ text: "picked text", linewise: false }) - expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.mode()).toBe("copy") }) test("return copies selection to clipboard path and exits copy mode", () => { @@ -2830,6 +2845,83 @@ describe("copy mode", () => { expect(ctx.copyCopies()).toBe(0) expect(ctx.state.mode()).toBe("copy") }) + + test("Ctrl+W k enters copy mode from normal mode via navigate", () => { + const ctx = createHandler("abc", { mode: "normal" }) + ctx.handler.handleKey(createEvent("w", { ctrl: true }).event) + expect(ctx.state.pending()).toBe("w") + ctx.handler.handleKey(createEvent("k").event) + expect(ctx.state.pending()).toBe("") + }) + + test("Ctrl+W j in copy mode exits without scrolling to bottom", () => { + const ctx = createHandler("abc", { mode: "copy" }) + ctx.handler.handleKey(createEvent("w", { ctrl: true }).event) + expect(ctx.state.pending()).toBe("w") + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.copyExits).toEqual([false]) + }) + + test("i exits copy mode to insert without scrolling", () => { + const ctx = createHandler("abc", { mode: "copy" }) + const evt = createEvent("i") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.copyExits).toEqual([false]) + }) + + test("i exits copy mode from visual mode to insert", () => { + const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } }) + const evt = createEvent("i") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.copyExitVisuals()).toBe(1) + expect(ctx.copyExits).toEqual([false]) + }) + + test("y in visual mode yanks and exits visual but stays in copy mode", () => { + const ctx = createHandler("abc", { mode: "copy", copy: { text: "selected", isVisual: true } }) + const evt = createEvent("y") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(ctx.copyYanks()).toBe(1) + expect(ctx.state.register()).toEqual({ text: "selected", linewise: false }) + expect(ctx.state.mode()).toBe("copy") + }) + + test("y without visual sets pending y for yy", () => { + const ctx = createHandler("abc", { mode: "copy" }) + const evt = createEvent("y") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(ctx.state.pending()).toBe("y") + expect(ctx.copyYanks()).toBe(0) + expect(ctx.state.mode()).toBe("copy") + }) + + test("yy yanks current line and stays in copy mode", () => { + const ctx = createHandler("abc", { mode: "copy", copy: { text: "whole line" } }) + ctx.handler.handleKey(createEvent("y").event) + expect(ctx.state.pending()).toBe("y") + ctx.handler.handleKey(createEvent("y").event) + expect(ctx.state.pending()).toBe("") + expect(ctx.copyYanks()).toBe(1) + expect(ctx.state.register()).toEqual({ text: "whole line", linewise: true }) + expect(ctx.state.mode()).toBe("copy") + }) + + test("Ctrl+W j from visual in copy mode exits visual not copy", () => { + const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } }) + ctx.handler.handleKey(createEvent("w", { ctrl: true }).event) + ctx.handler.handleKey(createEvent("j").event) + expect(ctx.copyExitVisuals()).toBe(1) + expect(ctx.state.mode()).toBe("copy") + }) + + test("Ctrl+W sets pending w in copy mode", () => { + const ctx = createHandler("abc", { mode: "copy" }) + ctx.handler.handleKey(createEvent("w", { ctrl: true }).event) + expect(ctx.state.pending()).toBe("w") + }) }) describe("copy mode cursor state", () => { @@ -2837,8 +2929,8 @@ describe("copy mode cursor state", () => { const textarea = createTextarea("") const [enabled] = createSignal(true) const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">("copy") - const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y">("") - const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null) + const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w">("") + const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null) const [register, setRegister] = createSignal<{ text: string; linewise: boolean } | null>(null) const [anchor, setAnchor] = createSignal(null) const [replace, setReplace] = createSignal(null) @@ -2961,6 +3053,10 @@ describe("copy mode cursor state", () => { isVisual: () => mode() === "visual" || mode() === "visual-line", isVisualLine: () => mode() === "visual-line", isCopy: () => mode() === "copy", + skipExitOnModeChange: () => false, + setSkipExitOnModeChange() {}, + exitScrollToBottom: () => true, + setExitScrollToBottom() {}, } as ReturnType const handler = createVimHandler({ @@ -2970,6 +3066,7 @@ describe("copy mode cursor state", () => { submit: () => {}, scroll() {}, jump() {}, + navigate() {}, copy(action) { if (action === "up" || action === "down") { const next = idx + (action === "up" ? -1 : 1) @@ -2991,6 +3088,8 @@ describe("copy mode cursor state", () => { copyVisual() {}, copyExitVisual() {}, copyYank() {}, + copyYankLine() {}, + copyExit() {}, copyCopy() {}, copyIsVisual() { return false From 2d9b0b2d38bf42fec3bd5a73e4db1f2fd9b5d134 Mon Sep 17 00:00:00 2001 From: Brett Kulp Date: Wed, 29 Apr 2026 07:02:31 -0500 Subject: [PATCH 2/2] remove yy copy mode implementation --- .../cli/cmd/tui/component/vim/vim-handler.ts | 18 ++--------- .../cli/cmd/tui/routes/session/copy-mode.ts | 14 -------- .../opencode/test/cli/tui/vim-motions.test.ts | 32 +++---------------- 3 files changed, 7 insertions(+), 57 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 0673c2ca5616..0ac4cd59f0cb 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 @@ -73,7 +73,6 @@ export function createVimHandler(input: { copyExitVisual?: () => void copyExit?: (scrollToBottom?: boolean) => void copyYank?: () => void - copyYankLine?: () => void copyCopy?: () => void copyIsVisual?: () => boolean copyJump?: (action: VimJump) => void @@ -836,23 +835,12 @@ export function createVimHandler(input: { return true } - if (key === "y" && input.state.pending() === "y") { - // yy — yank current line with flash highlight - input.state.clearPending() - input.copyYankLine?.() - event.preventDefault() - return true - } - if (key === "y") { - if (!input.copyIsVisual?.()) { - // first y — set pending for yy - input.state.setPending("y") - } else { - // y in visual — yank selection, flash highlight then clear + if (input.copyIsVisual?.()) { input.copyYank?.() - setTimeout(() => input.copyExitVisual?.(), 150) + input.copyExitVisual?.() } + input.copyExit?.(true) event.preventDefault() return true } 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 fb36d23f5b54..442f901b652d 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 @@ -490,19 +490,6 @@ export function createCopyMode(input: { setState((s) => ({ ...s, visual: undefined, anchor: undefined })) } - let yankFlashTimer: ReturnType | undefined - function yankLine() { - visual("line") - const reg = yank() - // Keep the highlight visible briefly, then clear - if (yankFlashTimer) clearTimeout(yankFlashTimer) - yankFlashTimer = setTimeout(() => { - yankFlashTimer = undefined - exitVisual() - }, 150) - return reg - } - function selectionText(): string { const s = state() if (!s.visual || !s.anchor) return "" @@ -701,7 +688,6 @@ export function createCopyMode(input: { exit: (scrollToBottom?: boolean) => exit(scrollToBottom), visual, yank, - yankLine, copy, isVisual: () => !!state().visual, exitVisual, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index cc28d08c5461..e849b9dec417 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -301,10 +301,6 @@ function createHandler( copyYanks++ state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false }) }, - copyYankLine() { - copyYanks++ - state.setRegister({ text: options?.copy?.text ?? "picked line", linewise: true }) - }, copyExit(scrollToBottom) { copyExits.push(scrollToBottom) }, @@ -2647,7 +2643,7 @@ describe("copy mode", () => { expect(ctx.copyVisualCalls).not.toContain("char") }) - test("y yanks copy selection and stays in copy mode", () => { + test("y yanks copy selection in copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked text", isVisual: true } }) const evt = createEvent("y") @@ -2656,7 +2652,7 @@ describe("copy mode", () => { expect(ctx.copyYanks()).toBe(1) expect(ctx.copyCopies()).toBe(0) expect(ctx.state.register()).toEqual({ text: "picked text", linewise: false }) - expect(ctx.state.mode()).toBe("copy") + expect(ctx.copyExits).toEqual([true]) }) test("return copies selection to clipboard path and exits copy mode", () => { @@ -2880,33 +2876,13 @@ describe("copy mode", () => { expect(ctx.copyExits).toEqual([false]) }) - test("y in visual mode yanks and exits visual but stays in copy mode", () => { + test("y in visual mode in copy mode yanks and exits copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { text: "selected", isVisual: true } }) const evt = createEvent("y") expect(ctx.handler.handleKey(evt.event)).toBe(true) expect(ctx.copyYanks()).toBe(1) expect(ctx.state.register()).toEqual({ text: "selected", linewise: false }) - expect(ctx.state.mode()).toBe("copy") - }) - - test("y without visual sets pending y for yy", () => { - const ctx = createHandler("abc", { mode: "copy" }) - const evt = createEvent("y") - expect(ctx.handler.handleKey(evt.event)).toBe(true) - expect(ctx.state.pending()).toBe("y") - expect(ctx.copyYanks()).toBe(0) - expect(ctx.state.mode()).toBe("copy") - }) - - test("yy yanks current line and stays in copy mode", () => { - const ctx = createHandler("abc", { mode: "copy", copy: { text: "whole line" } }) - ctx.handler.handleKey(createEvent("y").event) - expect(ctx.state.pending()).toBe("y") - ctx.handler.handleKey(createEvent("y").event) - expect(ctx.state.pending()).toBe("") - expect(ctx.copyYanks()).toBe(1) - expect(ctx.state.register()).toEqual({ text: "whole line", linewise: true }) - expect(ctx.state.mode()).toBe("copy") + expect(ctx.copyExits).toEqual([true]) }) test("Ctrl+W j from visual in copy mode exits visual not copy", () => {