diff --git a/README.md b/README.md index 75a683cc9260..e35ca9690bc4 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Works similarly to tmux copy mode within opencode tui. - `y/yy` yanks to the vim register. - `Enter` copies to the system clipboard. - `Escape` exits visual mode, `q` exits copy mode. +- Press `i` to exit copy mode and focus the prompt input in insert mode without moving the cursor position in copy mode. - `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 0b4e661cb0e4..b39034d6c028 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -85,6 +85,7 @@ export type PromptProps = { copy?: { enter: () => void exit: () => void + focusInput: () => void visual: (mode: "char" | "line") => void yank: () => { text: string; linewise: boolean } | null yankLine: () => { text: string; linewise: boolean } | null @@ -643,6 +644,9 @@ export function Prompt(props: PromptProps) { copyExit() { props.copy?.exit() }, + copyFocusInput() { + props.copy?.focusInput() + }, copyYank() { const reg = props.copy?.yank() if (reg) setVimRegister(reg, true) @@ -1761,7 +1765,7 @@ 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.mode() === "normal") props.copy?.exit() 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 e59b1aea7e28..20d75f51c674 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 @@ -86,6 +86,7 @@ export function createVimHandler(input: { copyVisual?: (mode: "char" | "line") => void copyExitVisual?: () => void copyExit?: () => void + copyFocusInput?: () => void copyYank?: () => void copyYankLine?: () => void copyCopy?: () => void @@ -1083,6 +1084,13 @@ export function createVimHandler(input: { event.preventDefault() return true } + if (pending === "" && key === "i" && !event.shift && !hasModifier(event)) { + begin() + input.copyFocusInput?.() + input.state.setMode("insert") + event.preventDefault() + return true + } if (key === "escape") { if (input.copyIsVisual?.()) { 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 39a615467d21..f08ae38f3356 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 @@ -321,15 +321,42 @@ export function createCopyMode(input: { } } + function enterTarget(list: CopyRow[]) { + const previous = state() + if (previous.idx < 0) { + const idx = list.findLastIndex((x) => x.role === "assistant") + const target = idx >= 0 ? idx : list.length - 1 + const row = list[target] + if (!row) return + return { idx: target, col: copyMin(row), stick: "first" as const } + } + + const idx = Math.max(0, Math.min(previous.idx, list.length - 1)) + const row = list[idx] + if (!row) return + const min = copyMin(row) + const text = rowPadded(row) + return { + idx, + col: Math.max(min, Math.min(previous.col, text.length > 0 ? Math.min(input.scroll().width - 2, text.length - 1) : min)), + stick: previous.stick, + } + } + function enter() { const init = () => { const list = rows() if (!list.length) return false - const idx = list.findLastIndex((x) => x.role === "assistant") - const target = idx >= 0 ? idx : list.length - 1 - const row = list[target] - setState((s) => ({ ...s, col: copyMin(row), stick: "first" as const })) - sync(target) + 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 } if (init()) return @@ -343,6 +370,10 @@ export function createCopyMode(input: { input.toBottom() } + function focusInput() { + setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined })) + } + function move(action: "up" | "down" | "left" | "right") { const scroll = input.scroll() const s = state() @@ -720,6 +751,7 @@ export function createCopyMode(input: { prompt: { enter, exit, + focusInput, visual, yank, yankLine, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 808f5c092532..6b8f28899c87 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -192,6 +192,7 @@ function createHandler( let copyYankLines = 0 let copyCopies = 0 let copyExitVisuals = 0 + let copyFocusInputs = 0 function clearPending() { setPending("") @@ -306,6 +307,9 @@ function createHandler( copyExitVisuals++ setCopyVisual(undefined) }, + copyFocusInput() { + copyFocusInputs++ + }, copyYank() { copyYanks++ state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false }) @@ -407,6 +411,7 @@ function createHandler( copyYankLines: () => copyYankLines, copyCopies: () => copyCopies, copyExitVisuals: () => copyExitVisuals, + copyFocusInputs: () => copyFocusInputs, copyCol, copyIdx, meta, @@ -4898,6 +4903,45 @@ describe("copy mode", () => { expect(ctx.handler.handleKey(evt.event)).toBe(true) expect(evt.prevented()).toBe(true) expect(ctx.state.mode()).toBe("normal") + expect(ctx.copyFocusInputs()).toBe(0) + }) + + test("i exits copy mode to insert without resetting scroll", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + const evt = createEvent("i") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.copyFocusInputs()).toBe(1) + }) + + test("i remains a copy find target when f is pending", () => { + const ctx = createHandler("abc", { mode: "copy", copy: { text: "alpha iris", col: 0 } }) + + ctx.handler.handleKey(createEvent("f").event) + const evt = createEvent("i") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyCol()).toBe(6) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.mode()).toBe("copy") + expect(ctx.copyFocusInputs()).toBe(0) + }) + + test("i from copy mode starts undoable insert session", () => { + const ctx = createHandler("ab", { mode: "copy" }) + ctx.textarea.cursorOffset = 1 + + ctx.handler.handleKey(createEvent("i").event) + ctx.textarea.insertText("X") + ctx.handler.handleKey(createEvent("escape").event) + + expect(ctx.textarea.plainText).toBe("aXb") + + ctx.handler.handleKey(createEvent("u").event) + expect(ctx.textarea.plainText).toBe("ab") + expect(ctx.textarea.cursorOffset).toBe(1) }) test("escape exits copy mode when not visual", () => { @@ -4987,6 +5031,66 @@ describe("copy mode", () => { }) }) + test("re-entering copy mode after focusing input restores previous position", () => { + const cm = createRenderedCopyMode(["one", "two", "three"]) + cm.prompt.setCol(8) + + cm.prompt.focusInput() + expect(cm.active()).toBe(false) + + cm.prompt.enter() + expect(cm.active()).toBe(true) + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(8) + }) + + test("re-entering copy mode clamps restored position to shortened row", () => { + let line = "abcdefghijk" + const child = { + id: "text-part", + y: 0, + height: 1, + gutter: { calculateWidth: () => 4 }, + getChildren: () => [ + { + _y: 0, + plainText: line, + lineInfo: { + lineSources: [0], + lineStartCols: [0], + lineWidthCols: [Bun.stringWidth(line)], + lineWraps: [0], + }, + }, + ], + } + const scroll = { + y: 0, + height: 10, + width: 120, + scrollHeight: 1, + getChildren: () => [child], + scrollBy() {}, + } as unknown as ScrollBoxRenderable + const cm = createCopyMode({ + scroll: () => scroll, + messages: () => [{ id: "message", role: "assistant" }], + parts: () => [{ id: "part", type: "text", text: line }] as Part[], + thinking: () => false, + details: () => false, + session: () => "session", + toBottom() {}, + }) + + cm.prompt.enter() + cm.prompt.setCol(17) + cm.prompt.focusInput() + line = "x" + + cm.prompt.enter() + expect(cm.state().col).toBe(7) + }) + test("y yanks copy selection and exits copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked text", isVisual: true } })