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