diff --git a/README.md b/README.md index 6938956b4493..f81e17900798 100644 --- a/README.md +++ b/README.md @@ -90,14 +90,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 61235dff7b4a..2588c43dcb0c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -90,9 +90,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 @@ -396,6 +397,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 promptSelectionText() { if (!input || input.isDestroyed) return const text = input.editorView.getSelectedText() @@ -505,6 +524,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) }, @@ -514,10 +536,17 @@ export function Prompt(props: PromptProps) { copyExitVisual() { props.copy?.exitVisual() }, + copyExit(scrollToBottom) { + props.copy?.exit(scrollToBottom) + }, copyYank() { const reg = props.copy?.yank() if (reg) setVimRegister(reg, true) }, + copyYankLine() { + const reg = props.copy?.yankLine() + if (reg) vimState.setRegister(reg) + }, copyCopy() { return props.copy?.copy() }, @@ -1564,7 +1593,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 a0ccb9324837..8ca9640a481c 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, VimRegister, 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, @@ -80,9 +81,11 @@ 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 copyCopy?: () => void copyIsVisual?: () => boolean @@ -252,6 +255,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()) @@ -929,6 +942,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 @@ -964,6 +983,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) @@ -1011,8 +1060,11 @@ export function createVimHandler(input: { } if (key === "y") { - input.copyYank?.() - input.state.setMode("normal") + if (input.copyIsVisual?.()) { + input.copyYank?.() + input.copyExitVisual?.() + } + input.copyExit?.(true) 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 d6832e79588e..6fea22d8b3a6 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 @@ -286,6 +286,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) { @@ -311,26 +368,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") { @@ -570,17 +662,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 --- @@ -593,7 +688,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 @@ -631,7 +726,7 @@ export function createCopyMode(input: { return { prompt: { enter, - exit, + exit: (scrollToBottom?: boolean) => exit(scrollToBottom), visual, yank, copy, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index b3ae52fbda11..08c67bf2fae0 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -188,6 +188,7 @@ function createHandler( let copyYanks = 0 let copyCopies = 0 let copyExitVisuals = 0 + const copyExits: Array = [] function clearPending() { setPending("") @@ -277,6 +278,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, @@ -291,6 +296,8 @@ function createHandler( jump(action) { jumpCalls.push(action) }, + navigate() { + }, copy(action) { copyMoves.push(action) }, @@ -306,6 +313,9 @@ function createHandler( copyYanks++ state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false }) }, + copyExit(scrollToBottom) { + copyExits.push(scrollToBottom) + }, copyCopy() { copyCopies++ }, @@ -390,6 +400,7 @@ function createHandler( copyYanks: () => copyYanks, copyCopies: () => copyCopies, copyExitVisuals: () => copyExitVisuals, + copyExits, copyCol, copyIdx, meta, @@ -4350,7 +4361,7 @@ describe("copy mode", () => { expect(ctx.copyVisualCalls).not.toContain("char") }) - test("y yanks copy selection and exits 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") @@ -4359,7 +4370,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.copyExits).toEqual([true]) }) test("return copies selection to clipboard path and exits copy mode", () => { @@ -4548,6 +4559,63 @@ 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 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.copyExits).toEqual([true]) + }) + + 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", () => { @@ -4555,8 +4623,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) @@ -4679,6 +4747,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({ @@ -4688,6 +4760,7 @@ describe("copy mode cursor state", () => { submit: () => {}, scroll() {}, jump() {}, + navigate() {}, copy(action) { if (action === "up" || action === "down") { const next = idx + (action === "up" ? -1 : 1) @@ -4709,6 +4782,8 @@ describe("copy mode cursor state", () => { copyVisual() {}, copyExitVisual() {}, copyYank() {}, + copyYankLine() {}, + copyExit() {}, copyCopy() {}, copyIsVisual() { return false