diff --git a/README.md b/README.md
index 830a0b06d465..6a792490db0c 100644
--- a/README.md
+++ b/README.md
@@ -90,17 +90,17 @@ 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/yy` yanks to the vim register.
-- `Enter` copies to the system clipboard.
+- `y` yanks visual selection to the vim register and exits copy mode.
+- `yy` yanks the current line to the vim register with a brief highlight flash.
- `Y` yanks to the vim register and scrolls to the bottom.
+- `Enter` copies to the system clipboard.
- `Shift+Enter` copies to the system clipboard and scrolls to the bottom.
-- `Escape` exits visual mode, `q` exits copy mode and scrolls to the bottom.
-- `i` focuses the prompt input.
- `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 c7ed9bdb13e8..79b2b7a5b9cd 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -89,7 +89,7 @@ export type PromptProps = {
}
copy?: {
enter: () => void
- exit: () => void
+ exit: (scrollToBottom?: boolean) => void
exitPreserveScroll: () => void
focusInput: () => void
visual: (mode: "char" | "line") => void
@@ -534,6 +534,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()
@@ -643,6 +661,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)
},
@@ -652,8 +673,8 @@ export function Prompt(props: PromptProps) {
copyExitVisual() {
props.copy?.exitVisual()
},
- copyExit() {
- props.copy?.exit()
+ copyExit(scrollToBottom) {
+ props.copy?.exit(scrollToBottom)
},
copyExitPreserveScroll() {
props.copy?.exitPreserveScroll()
@@ -1779,7 +1800,13 @@ export function Prompt(props: PromptProps) {
if (vimState.isCopy()) {
const active = vimState.isCopy()
vim.handleKey(e)
- if (active && vimState.mode() === "normal" && props.copy?.active()) props.copy.exit()
+ if (active && vimState.mode() === "normal" && props.copy?.active()) {
+ 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 925662a1e557..07b76b3a223d 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,
@@ -82,10 +83,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?: () => void
+ copyExit?: (scrollToBottom?: boolean) => void
copyExitPreserveScroll?: () => void
copyFocusInput?: () => void
copyYank?: () => void
@@ -336,6 +338,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())
@@ -1026,6 +1038,12 @@ export function createVimHandler(input: {
return true
}
+ if (key === "w" && event.ctrl) {
+ input.state.setPending("w")
+ event.preventDefault()
+ return true
+ }
+
if (key === "backspace" || key === "delete") {
event.preventDefault()
return true
@@ -1129,6 +1147,29 @@ export function createVimHandler(input: {
return true
}
+ if (input.state.pending() === "w") {
+ if (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
+ }
+ input.state.clearPending()
+ }
+
+ if (key === "w" && event.ctrl) {
+ input.state.setPending("w")
+ event.preventDefault()
+ return true
+ }
+
const scroll = vimScroll(event)
if (scroll) {
clearCopyPending()
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..6de0d25c8759
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motion-window-navigation.ts
@@ -0,0 +1,25 @@
+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 }
+ }
+
+ state.clearPending()
+ 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 891706e35bd9..fa00a5d60979 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" | "r" | "vr"
+export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr"
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 96657296478c..f215fbe22a41 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
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
+import { batch, createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Part } from "@opencode-ai/sdk/v2"
import {
@@ -66,6 +66,7 @@ export function createCopyMode(input: {
toBottom: () => void
}) {
const [state, setState] = createSignal({ ...empty })
+ const [unified, setUnified] = createSignal(false)
const [yankLineFlash, setYankLineFlash] = createSignal(undefined)
// --- row building ---
@@ -296,6 +297,67 @@ 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 compensate the scroll delta.
+
+ let compensateTimer: ReturnType | undefined
+
+ function snapshotScroll() {
+ const scr = input.scroll()
+ if (!scr) return undefined
+ const scrollY = scr.scrollTop ?? scr.y ?? 0
+ const atBottom = scr.scrollHeight > scr.height && 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 ?? scr.y ?? 0) + child.y
+ const contentDelta = newAbsolute - oldAbsolute
+ const cappedDelta = Math.max(-scr.height, Math.min(scr.height, contentDelta))
+ if (contentDelta !== 0) {
+ if (snap.atBottom) {
+ if (typeof scr.scrollTo === "function") scr.scrollTo(scr.scrollHeight)
+ else scr.scrollBy(scr.scrollHeight - (scr.scrollTop ?? scr.y ?? 0))
+ } else if (typeof scr.scrollTo === "function") scr.scrollTo(snap.scrollY + cappedDelta)
+ else scr.scrollBy(cappedDelta)
+ }
+ return true
+ }
+
+ // Try synchronously first; Solid renders may already have updated layout.
+ if (tryCompensate()) {
+ afterSettle?.()
+ return
+ }
+
+ // Fall back to polling if child not found yet (destroyed/recreated)
+ 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) {
@@ -321,11 +383,59 @@ export function createCopyMode(input: {
}
}
- function enterTarget(list: CopyRow[]) {
+ function keepCursorVisible() {
+ const scr = input.scroll()
+ if (!scr || scr.isDestroyed) return
+ const current = row()
+ if (!current) return
+ const top = scr.y
+ const bottom = scr.y + scr.height - 1
+ if (current.y < top) {
+ scr.scrollBy(current.y - top)
+ return
+ }
+ if (current.y > bottom) scr.scrollBy(current.y - bottom)
+ }
+
+ function pickVisibleTarget(list: CopyRow[], preferBottom = false) {
+ const scr = input.scroll()
+ 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) return 0
+ if (preferBottom) return list.indexOf(visible[visible.length - 1]!)
+ const midY = top + (bottom - top) / 2
+ return list.indexOf(visible.reduce((a, b) => (Math.abs(a.y - midY) < Math.abs(b.y - midY) ? a : b)))
+ }
+
+ function hasVisibleRow(list: CopyRow[], idx: number) {
+ const row = list[idx]
+ if (!row) return false
+ const scr = input.scroll()
+ const top = scr.y
+ const bottom = scr.y + scr.height - 1
+ return row.y >= top && row.y <= bottom
+ }
+
+ function matchingTarget(list: CopyRow[], target: CopyRow) {
+ const exact = list.map((row, idx) => ({ row, idx })).filter((x) => x.row.key === target.key)
+ if (exact.length) return exact.reduce((a, b) => (Math.abs(a.row.y - target.y) < Math.abs(b.row.y - target.y) ? a : b)).idx
+ const candidates = list
+ .map((row, idx) => ({ row, idx }))
+ .filter((x) => x.row.id === target.id && x.row.kind === target.kind && x.row.role === target.role)
+ if (!candidates.length) return -1
+ return candidates.reduce((a, b) => (Math.abs(a.row.line - target.line) < Math.abs(b.row.line - target.line) ? a : b)).idx
+ }
+
+ function enterTarget(list: CopyRow[], preferVisible = false, preferBottom = false, visibleTarget?: CopyRow) {
const previous = state()
- if (previous.idx < 0) {
- const idx = list.findLastIndex((x) => x.role === "assistant")
- const target = idx >= 0 ? idx : list.length - 1
+ if (visibleTarget) {
+ const idx = matchingTarget(list, visibleTarget)
+ const row = list[idx]
+ if (row) return { idx, col: copyMin(row), stick: "first" as const }
+ }
+ if (preferVisible || previous.idx < 0) {
+ const target = pickVisibleTarget(list, preferBottom)
const row = list[target]
if (!row) return
return { idx: target, col: copyMin(row), stick: "first" as const }
@@ -345,33 +455,81 @@ export function createCopyMode(input: {
function enter() {
const init = () => {
- const list = rows()
- if (!list.length) return false
- const target = enterTarget(list)
- if (!target) return false
- setState((s) => ({
- ...s,
- col: target.col,
- stick: target.stick,
- visual: undefined,
- anchor: undefined,
- }))
- sync(target.idx)
- return true
+ const initial = state().idx < 0
+ const beforeRows = rows()
+ const previousVisible = hasVisibleRow(beforeRows, state().idx)
+ const preEnterTarget = (preferBottom = false) => beforeRows[pickVisibleTarget(beforeRows, preferBottom)]
+ const selectTarget = (preferVisible = false, preferBottom = false, usePreEnterTarget = false, ensureVisible = true) => {
+ const list = rows()
+ if (!list.length) {
+ setState({ ...empty })
+ return false
+ }
+ const target = enterTarget(
+ list,
+ preferVisible,
+ preferBottom,
+ usePreEnterTarget && (initial || !previousVisible) ? preEnterTarget(preferBottom) : undefined,
+ )
+ if (!target) return false
+ setState((s) => ({
+ ...s,
+ col: target.col,
+ stick: target.stick,
+ visual: undefined,
+ anchor: undefined,
+ }))
+ if (ensureVisible) sync(target.idx)
+ else setState((s) => ({ ...s, active: true, idx: target.idx }))
+ return true
+ }
+
+ if (!unified()) {
+ const snap = snapshotScroll()
+ batch(() => {
+ setUnified(true)
+ // Activate first so rows() excludes reasoning (matches what row memo will see)
+ setState((s) => ({ ...s, active: true }))
+ })
+ selectTarget(initial || !previousVisible, snap?.atBottom, true, false)
+ compensateScroll(snap, () => {
+ if (!selectTarget(initial || !previousVisible, snap?.atBottom, true)) setTimeout(() => init(), 0)
+ })
+ return true
+ } else {
+ setState((s) => ({ ...s, active: true }))
+ }
+
+ // Pick target from rows() AFTER active is true (reasoning excluded)
+ return selectTarget(initial, false, true)
}
if (init()) return
- setTimeout(() => {
- init()
- }, 0)
+ setTimeout(() => init(), 0)
}
- function exit() {
- setState({ ...empty })
- input.toBottom()
+ function exit(scrollToBottom?: boolean) {
+ if (scrollToBottom === undefined || scrollToBottom) {
+ batch(() => {
+ setState({ ...empty })
+ setUnified(false)
+ })
+ input.toBottom()
+ return
+ }
+ // Exit without scrolling — keep current scroll position
+ const snap = snapshotScroll()
+ batch(() => {
+ setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined }))
+ setUnified(false)
+ })
+ compensateScroll(snap)
}
function exitPreserveScroll() {
- setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined }))
+ batch(() => {
+ setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined }))
+ setUnified(false)
+ })
}
function focusInput() {
@@ -649,17 +807,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 ---
@@ -781,6 +942,7 @@ export function createCopyMode(input: {
row,
highlights,
active: () => state().active,
+ unified,
clamp,
state,
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 dc87d2e89f34..614da8593c3b 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1074,7 +1074,7 @@ export function Session() {
return contentWidth()
},
sessionID: route.sessionID,
- copyActive: cm.active,
+ copyActive: cm.unified,
conceal,
showThinking,
showTimestamps,
diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts
index 672fef822415..f62a56c0d4db 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -166,7 +166,7 @@ function createHandler(
const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">(
options?.mode ?? "normal",
)
- const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "r" | "vr">("")
+ const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr">("")
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)
@@ -192,7 +192,7 @@ function createHandler(
let copyYankLines = 0
let copyCopies = 0
let copyExitVisuals = 0
- let copyExits = 0
+ const copyExits: Array = []
let copyExitPreserveScrolls = 0
let copyFocusInputs = 0
@@ -284,6 +284,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,
@@ -298,6 +302,8 @@ function createHandler(
jump(action) {
jumpCalls.push(action)
},
+ navigate() {
+ },
copy(action) {
copyMoves.push(action)
},
@@ -309,8 +315,8 @@ function createHandler(
copyExitVisuals++
setCopyVisual(undefined)
},
- copyExit() {
- copyExits++
+ copyExit(scrollToBottom) {
+ copyExits.push(scrollToBottom)
setCopyVisual(undefined)
},
copyExitPreserveScroll() {
@@ -421,7 +427,8 @@ function createHandler(
copyYankLines: () => copyYankLines,
copyCopies: () => copyCopies,
copyExitVisuals: () => copyExitVisuals,
- copyExits: () => copyExits,
+ copyExits: () => copyExits.length,
+ copyExitArgs: copyExits,
copyExitPreserveScrolls: () => copyExitPreserveScrolls,
copyFocusInputs: () => copyFocusInputs,
copyCol,
@@ -2984,6 +2991,32 @@ describe("vim motion handler", () => {
expect(ctx.state.pending()).toBe("")
})
+ test("ctrl+w non-window key clears pending w and key is handled in normal mode", () => {
+ const ctx = createHandler("abc")
+ ctx.textarea.cursorOffset = 1
+
+ const ctrlW = createEvent("w", { ctrl: true })
+ expect(ctx.handler.handleKey(ctrlW.event)).toBe(true)
+ expect(ctrlW.prevented()).toBe(true)
+ expect(ctx.state.pending()).toBe("w")
+
+ const x = createEvent("x")
+ expect(ctx.handler.handleKey(x.event)).toBe(true)
+ expect(x.prevented()).toBe(true)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.textarea.plainText).toBe("ac")
+ })
+
+ test("alt/meta/super+w does not set pending w in normal mode", () => {
+ const ctx = createHandler("abc")
+
+ expect(ctx.handler.handleKey(createEvent("w", { meta: true }).event)).toBe(false)
+ expect(ctx.state.pending()).toBe("")
+
+ expect(ctx.handler.handleKey(createEvent("w", { super: true }).event)).toBe(false)
+ expect(ctx.state.pending()).toBe("")
+ })
+
test("ctrl scroll keys trigger actions", () => {
const ctx = createHandler("abc")
const keys: Array<[string, VimScroll]> = [
@@ -4503,6 +4536,64 @@ describe("copy mode", () => {
return cm
}
+ test("entering copy mode keeps a target from rows visible before diff unification", () => {
+ let offset = 20
+ let cm: ReturnType | undefined
+ const child = (id: string, absoluteY: number) => ({
+ id: `text-${id}`,
+ y: absoluteY - offset,
+ height: 1,
+ gutter: { calculateWidth: () => 4 },
+ getChildren: () => [
+ {
+ _y: 0,
+ plainText: id,
+ lineInfo: {
+ lineSources: [0],
+ lineStartCols: [0],
+ lineWidthCols: [Bun.stringWidth(id)],
+ lineWraps: [0],
+ },
+ },
+ ],
+ })
+ const scroll = {
+ y: 0,
+ height: 3,
+ width: 80,
+ scrollHeight: 80,
+ get scrollTop() {
+ return offset
+ },
+ getChildren: () =>
+ cm?.unified() ? [child("hidden", 24), child("visible", 40)] : [child("visible", 20), child("hidden", 50)],
+ scrollBy(delta: number) {
+ offset += delta
+ },
+ scrollTo(next: number) {
+ offset = next
+ },
+ } as unknown as ScrollBoxRenderable
+ cm = createCopyMode({
+ scroll: () => scroll,
+ messages: () => [{ id: "message", role: "assistant" }],
+ parts: () =>
+ [
+ { id: "visible", type: "text", text: "visible" },
+ { id: "hidden", type: "text", text: "hidden" },
+ ] as Part[],
+ thinking: () => false,
+ details: () => false,
+ session: () => "session",
+ toBottom() {},
+ })
+
+ cm.prompt.enter()
+
+ expect(cm.row()?.id).toBe("text-visible")
+ expect(offset).toBe(38)
+ })
+
test("highlights final wrapped row using its visual slice", () => {
const child = {
id: "text-part",
@@ -5037,8 +5128,8 @@ describe("copy mode", () => {
cm.prompt.visual("line")
expect(cm.state().visual).toBe("line")
- expect(cm.state().anchor).toEqual({ idx: 2, col: 3 })
- expect(cm.state().idx).toBe(1)
+ expect(cm.state().anchor).toEqual({ idx: 1, col: 3 })
+ expect(cm.state().idx).toBe(0)
dispose()
})
})
@@ -5113,7 +5204,7 @@ describe("copy mode", () => {
expect(ctx.copyCopies()).toBe(0)
expect(ctx.copyExitPreserveScrolls()).toBe(1)
expect(ctx.state.register()).toEqual({ text: "picked text", linewise: false })
- expect(ctx.state.mode()).toBe("normal")
+ expect(ctx.copyExits()).toBe(0)
})
test("yy yanks current line and exits copy mode", async () => {
@@ -5428,6 +5519,128 @@ 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.copyExitArgs).toEqual([false])
+ })
+
+ test("i focuses input 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.copyFocusInputs()).toBe(1)
+ expect(ctx.copyExitPreserveScrolls()).toBe(0)
+ expect(ctx.copyExits()).toBe(0)
+ })
+
+ test("i focuses input from visual copy mode", () => {
+ 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.copyFocusInputs()).toBe(1)
+ expect(ctx.copyExitPreserveScrolls()).toBe(0)
+ expect(ctx.copyExits()).toBe(0)
+ })
+
+ 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.copyExitPreserveScrolls()).toBe(1)
+ })
+
+ 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")
+ })
+
+ test("ctrl+w non-window key clears pending w in copy mode", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ const ctrlW = createEvent("w", { ctrl: true })
+ expect(ctx.handler.handleKey(ctrlW.event)).toBe(true)
+ expect(ctrlW.prevented()).toBe(true)
+ expect(ctx.state.pending()).toBe("w")
+
+ const x = createEvent("x")
+ expect(ctx.handler.handleKey(x.event)).toBe(true)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.state.mode()).toBe("copy")
+ })
+
+ test("ctrl+w then H clears pending and jumps to high in copy mode", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)
+ expect(ctx.state.pending()).toBe("w")
+
+ ctx.handler.handleKey(createEvent("H").event)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.copyJumps).toContain("high")
+ expect(ctx.state.mode()).toBe("copy")
+ })
+
+ test("ctrl+w then M clears pending and jumps to middle in copy mode", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)
+ expect(ctx.state.pending()).toBe("w")
+
+ ctx.handler.handleKey(createEvent("M").event)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.copyJumps).toContain("middle")
+ expect(ctx.state.mode()).toBe("copy")
+ })
+
+ test("ctrl+w then L clears pending and jumps to low in copy mode", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)
+ expect(ctx.state.pending()).toBe("w")
+
+ ctx.handler.handleKey(createEvent("L").event)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.copyJumps).toContain("low")
+ expect(ctx.state.mode()).toBe("copy")
+ })
+
+ test("meta/super+w does not set pending w in copy mode", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ expect(ctx.handler.handleKey(createEvent("w", { meta: true }).event)).toBe(false)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.state.mode()).toBe("copy")
+
+ expect(ctx.handler.handleKey(createEvent("w", { super: true }).event)).toBe(false)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.state.mode()).toBe("copy")
+ })
})
describe("copy mode cursor state", () => {
@@ -5435,7 +5648,7 @@ 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" | "r" | "vr">("")
+ const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr">("")
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)
@@ -5559,6 +5772,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({
@@ -5568,6 +5785,7 @@ describe("copy mode cursor state", () => {
submit: () => {},
scroll() {},
jump() {},
+ navigate() {},
copy(action) {
if (action === "up" || action === "down") {
const next = idx + (action === "up" ? -1 : 1)
@@ -5589,6 +5807,8 @@ describe("copy mode cursor state", () => {
copyVisual() {},
copyExitVisual() {},
copyYank() {},
+ copyYankLine() {},
+ copyExit() {},
copyCopy() {},
copyIsVisual() {
return false