diff --git a/README.md b/README.md
index 830a0b06d465..8c5afa83aa10 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ Works similarly to tmux copy mode within opencode tui.
-- Enter copy mode with `v`.
+- Enter copy mode with `v` or `Ctrl+W k`.
- Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`).
- Press `v` / `V` to start character-wise or line-wise selection.
- `y/yy` yanks to the vim register.
@@ -98,7 +98,8 @@ Works similarly to tmux copy mode within opencode tui.
- `Y` yanks to the vim register and scrolls to the bottom.
- `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.
+- `Ctrl+W j` exits copy mode without scrolling; `Ctrl+W w` toggles copy mode.
+- `i` focuses the prompt input in insert mode without scrolling.
- `z` `zt` `zz` `zb` adjust copy-mode scroll positioning.
- `H` / `M` / `L` jump to the top / middle / bottom of the viewport.
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..067b45e9c279 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") {
+ const skipExit = vimState.skipExitOnModeChange()
+ const scrollToBottom = vimState.exitScrollToBottom()
+ vimState.setSkipExitOnModeChange(false)
+ vimState.setExitScrollToBottom(true)
+ if (props.copy?.active() && !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..e62ddef38a02 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 && !event.shift && !event.meta && !event.super) {
+ 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" || key === "w") {
+ 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 && !event.shift && !event.meta && !event.super) {
+ 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-indicator.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts
index af14b0ec62a2..f7159072f0b5 100644
--- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts
+++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts
@@ -10,7 +10,7 @@ export function useVimIndicator(input: {
return createMemo(() => {
if (!input.enabled() || !input.active()) return
const key = input.state.pending()
- if (key) return key + ".."
+ if (key && key !== "w") return key + ".."
if (input.state.isCopy()) {
if (input.copyVisual?.() === "char") return "-- V-COPY --"
if (input.copyVisual?.() === "line") return "-- VL-COPY --"
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..453344169592
--- /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" || key === "w") {
+ 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..a961587c0484 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,7 +66,9 @@ export function createCopyMode(input: {
toBottom: () => void
}) {
const [state, setState] = createSignal({ ...empty })
+ const [unified, setUnified] = createSignal(false)
const [yankLineFlash, setYankLineFlash] = createSignal(undefined)
+ let lastCursor: CopyRow | undefined
// --- row building ---
@@ -296,6 +298,65 @@ export function createCopyMode(input: {
setState((s) => ({ ...s, stick }))
}
+ // --- scroll compensation ---
+
+ 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
+ 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, fast = false) {
+ 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
+ }
+
+ let attempts = 0
+ let settled = false
+ const poll = () => {
+ attempts++
+ const compensated = tryCompensate()
+ if (compensated && !settled && attempts >= 2) {
+ settled = true
+ afterSettle?.()
+ }
+ if (attempts >= 10) {
+ if (!settled) afterSettle?.()
+ return
+ }
+ compensateTimer = setTimeout(poll, fast && attempts < 4 ? 4 : 16)
+ }
+ compensateTimer = setTimeout(poll, 0)
+ }
+
// --- navigation ---
function sync(next: number) {
@@ -321,11 +382,44 @@ export function createCopyMode(input: {
}
}
- function enterTarget(list: CopyRow[]) {
+ 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(row?: CopyRow) {
+ 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 +439,84 @@ 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 beforeRows = rows()
+ const previousTarget = lastCursor
+ const initial = state().idx < 0 && !previousTarget
+ const previousVisible = previousTarget
+ ? hasVisibleRow(beforeRows[matchingTarget(beforeRows, previousTarget)])
+ : 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)
+ : !preferVisible
+ ? previousTarget
+ : 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)
+ setState((s) => ({ ...s, active: true }))
+ })
+ const preferBottom = initial && snap?.atBottom
+ selectTarget(initial || !previousVisible, preferBottom, true, false)
+ compensateScroll(snap, () => {
+ if (!selectTarget(initial || !previousVisible, preferBottom, true)) setTimeout(() => init(), 0)
+ })
+ return true
+ }
+
+ setState((s) => ({ ...s, active: true }))
+ return selectTarget(initial || !previousVisible, false, true, true)
}
if (init()) return
- setTimeout(() => {
- init()
- }, 0)
+ setTimeout(() => init(), 0)
}
- function exit() {
- setState({ ...empty })
+ function exit(scrollToBottom?: boolean) {
+ if (scrollToBottom === false) {
+ exitPreserveScroll()
+ return
+ }
+ lastCursor = undefined
+ batch(() => {
+ setState({ ...empty })
+ setUnified(false)
+ })
input.toBottom()
}
function exitPreserveScroll() {
- setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined }))
+ lastCursor = row()
+ const snap = snapshotScroll()
+ batch(() => {
+ setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined }))
+ setUnified(false)
+ })
+ compensateScroll(snap, undefined, true)
}
function focusInput() {
@@ -781,6 +926,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..e0c325e93864 100644
--- a/packages/opencode/test/cli/tui/vim-motions.test.ts
+++ b/packages/opencode/test/cli/tui/vim-motions.test.ts
@@ -166,12 +166,14 @@ 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)
const [replace, setReplace] = createSignal(null)
const [typed, setTyped] = createSignal(false)
+ const [skipExitOnModeChange, setSkipExitOnModeChange] = createSignal(false)
+ const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true)
const [copyVisual, setCopyVisual] = createSignal(
options?.copy?.isVisual ? "char" : undefined,
)
@@ -184,6 +186,7 @@ function createHandler(
const copyRows = options?.copy?.rows
const scrollCalls: VimScroll[] = []
const jumpCalls: VimJump[] = []
+ const navigateCalls: Array<"up" | "down"> = []
const copyMoves: Array<"up" | "down" | "left" | "right"> = []
const copyJumps: Array = []
const copyVisualCalls: Array<"char" | "line"> = []
@@ -193,6 +196,7 @@ function createHandler(
let copyCopies = 0
let copyExitVisuals = 0
let copyExits = 0
+ const copyExitArgs: Array = []
let copyExitPreserveScrolls = 0
let copyFocusInputs = 0
@@ -284,6 +288,10 @@ function createHandler(
isVisual: () => mode() === "visual" || mode() === "visual-line",
isVisualLine: () => mode() === "visual-line",
isCopy: () => mode() === "copy",
+ skipExitOnModeChange,
+ setSkipExitOnModeChange,
+ exitScrollToBottom,
+ setExitScrollToBottom,
} as ReturnType
const handler = createVimHandler({
enabled,
@@ -298,6 +306,9 @@ function createHandler(
jump(action) {
jumpCalls.push(action)
},
+ navigate(action) {
+ navigateCalls.push(action)
+ },
copy(action) {
copyMoves.push(action)
},
@@ -309,8 +320,9 @@ function createHandler(
copyExitVisuals++
setCopyVisual(undefined)
},
- copyExit() {
+ copyExit(scrollToBottom) {
copyExits++
+ copyExitArgs.push(scrollToBottom)
setCopyVisual(undefined)
},
copyExitPreserveScroll() {
@@ -412,6 +424,7 @@ function createHandler(
state,
scrollCalls,
jumpCalls,
+ navigateCalls,
copyMoves,
copyJumps,
copyVisual,
@@ -422,6 +435,7 @@ function createHandler(
copyCopies: () => copyCopies,
copyExitVisuals: () => copyExitVisuals,
copyExits: () => copyExits,
+ copyExitArgs,
copyExitPreserveScrolls: () => copyExitPreserveScrolls,
copyFocusInputs: () => copyFocusInputs,
copyCol,
@@ -1522,6 +1536,38 @@ describe("vim motion handler", () => {
expect(ctx.textarea.cursorOffset).toBe(1)
})
+ test("ctrl+w k navigates to copy mode", () => {
+ const ctx = createHandler("abc")
+
+ expect(ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)).toBe(true)
+ expect(ctx.state.pending()).toBe("w")
+
+ const k = createEvent("k")
+ expect(ctx.handler.handleKey(k.event)).toBe(true)
+ expect(k.prevented()).toBe(true)
+ expect(ctx.state.pending()).toBe("")
+ expect(ctx.navigateCalls).toEqual(["up"])
+ })
+
+ test("ctrl+w invalid key clears pending in normal mode", () => {
+ const ctx = createHandler("abc")
+
+ expect(ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)).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.navigateCalls).toEqual([])
+ })
+
+ test("meta+w does not set ctrl-w pending", () => {
+ const ctx = createHandler("abc")
+
+ expect(ctx.handler.handleKey(createEvent("w", { meta: true }).event)).toBe(false)
+ expect(ctx.state.pending()).toBe("")
+ })
+
test("escape in normal mode with no pending returns false", () => {
const ctx = createHandler("abc")
const esc = createEvent("escape")
@@ -4503,6 +4549,132 @@ describe("copy mode", () => {
return cm
}
+ test("entering copy mode keeps visible row when unified layout changes", async () => {
+ let offset = 20
+ let cm: ReturnType | undefined
+ const child = (id: string, absoluteY: number) => ({
+ id: `text-${id}`,
+ y: absoluteY - offset,
+ height: 1,
+ getChildren: () => [{ _y: 0, plainText: id }],
+ })
+ const scroll = {
+ y: 0,
+ height: 10,
+ width: 120,
+ 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()
+ await new Promise((resolve) => setTimeout(resolve, 40))
+
+ expect(cm.row()?.id).toBe("text-visible")
+ })
+
+ test("reentering copy mode restores previous row by identity", async () => {
+ let children = [
+ { id: "text-a", y: 0, height: 1, getChildren: () => [{ _y: 0, plainText: "a" }] },
+ { id: "text-b", y: 1, height: 1, getChildren: () => [{ _y: 0, plainText: "b" }] },
+ ]
+ const scroll = {
+ y: 0,
+ height: 5,
+ width: 120,
+ scrollHeight: 5,
+ scrollTop: 0,
+ getChildren: () => children,
+ scrollBy() {},
+ scrollTo() {},
+ } as unknown as ScrollBoxRenderable
+ const cm = createCopyMode({
+ scroll: () => scroll,
+ messages: () => [{ id: "message", role: "assistant" }],
+ parts: () =>
+ [
+ { id: "a", type: "text", text: "a" },
+ { id: "b", type: "text", text: "b" },
+ ] as Part[],
+ thinking: () => false,
+ details: () => false,
+ session: () => "session",
+ toBottom() {},
+ })
+
+ cm.prompt.enter()
+ cm.prompt.jump("low")
+ expect(cm.row()?.id).toBe("text-b")
+ cm.prompt.exitPreserveScroll()
+ children = [children[1]!, { ...children[0]!, y: 1 }]
+
+ cm.prompt.enter()
+ await new Promise((resolve) => setTimeout(resolve, 40))
+
+ expect(cm.row()?.id).toBe("text-b")
+ })
+
+ test("reentering copy mode targets viewport when previous row is offscreen", async () => {
+ const target = { id: "text-target", y: 1, height: 1, getChildren: () => [{ _y: 0, plainText: "target" }] }
+ const visible = { id: "text-visible", y: 0, height: 1, getChildren: () => [{ _y: 0, plainText: "visible" }] }
+ const scroll = {
+ y: 0,
+ height: 5,
+ width: 120,
+ scrollHeight: 30,
+ scrollTop: 0,
+ getChildren: () => [visible, target],
+ scrollBy() {},
+ scrollTo() {},
+ } as unknown as ScrollBoxRenderable
+ const cm = createCopyMode({
+ scroll: () => scroll,
+ messages: () => [{ id: "message", role: "assistant" }],
+ parts: () =>
+ [
+ { id: "visible", type: "text", text: "visible" },
+ { id: "target", type: "text", text: "target" },
+ ] as Part[],
+ thinking: () => false,
+ details: () => false,
+ session: () => "session",
+ toBottom() {},
+ })
+
+ cm.prompt.enter()
+ cm.prompt.jump("low")
+ expect(cm.row()?.id).toBe("text-target")
+ cm.prompt.exitPreserveScroll()
+ target.y = 20
+
+ cm.prompt.enter()
+ await new Promise((resolve) => setTimeout(resolve, 40))
+
+ expect(cm.row()?.id).toBe("text-visible")
+ })
+
test("highlights final wrapped row using its visual slice", () => {
const child = {
id: "text-part",
@@ -5037,13 +5209,13 @@ 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()
})
})
- test("re-entering copy mode after focusing input restores previous position", () => {
+ test("re-entering copy mode after focusing input restores previous row", () => {
const cm = createRenderedCopyMode(["one", "two", "three"])
cm.prompt.setCol(8)
@@ -5053,7 +5225,7 @@ describe("copy mode", () => {
cm.prompt.enter()
expect(cm.active()).toBe(true)
expect(cm.state().idx).toBe(0)
- expect(cm.state().col).toBe(8)
+ expect(cm.prompt.text().trim()).toBe("one")
})
test("re-entering copy mode clamps restored position to shortened row", () => {
@@ -5428,6 +5600,41 @@ describe("copy mode", () => {
expect(ctx.copyCopies()).toBe(0)
expect(ctx.state.mode()).toBe("copy")
})
+
+ test("ctrl+w j exits copy mode without scrolling to bottom", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ expect(ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)).toBe(true)
+ expect(ctx.state.pending()).toBe("w")
+
+ const j = createEvent("j")
+ expect(ctx.handler.handleKey(j.event)).toBe(true)
+ expect(j.prevented()).toBe(true)
+ expect(ctx.state.mode()).toBe("normal")
+ expect(ctx.copyExitArgs).toEqual([false])
+ })
+
+ test("ctrl+w w exits copy mode without scrolling to bottom", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)
+ ctx.handler.handleKey(createEvent("w").event)
+
+ expect(ctx.state.mode()).toBe("normal")
+ expect(ctx.copyExitArgs).toEqual([false])
+ })
+
+ test("ctrl+w invalid key clears pending in copy mode", () => {
+ const ctx = createHandler("abc", { mode: "copy" })
+
+ ctx.handler.handleKey(createEvent("w", { ctrl: true }).event)
+ 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")
+ })
})
describe("copy mode cursor state", () => {
@@ -5435,7 +5642,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)