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)