Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?.()) {
Expand Down
42 changes: 37 additions & 5 deletions packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -720,6 +751,7 @@ export function createCopyMode(input: {
prompt: {
enter,
exit,
focusInput,
visual,
yank,
yankLine,
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ function createHandler(
let copyYankLines = 0
let copyCopies = 0
let copyExitVisuals = 0
let copyFocusInputs = 0

function clearPending() {
setPending("")
Expand Down Expand Up @@ -306,6 +307,9 @@ function createHandler(
copyExitVisuals++
setCopyVisual(undefined)
},
copyFocusInput() {
copyFocusInputs++
},
copyYank() {
copyYanks++
state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false })
Expand Down Expand Up @@ -407,6 +411,7 @@ function createHandler(
copyYankLines: () => copyYankLines,
copyCopies: () => copyCopies,
copyExitVisuals: () => copyExitVisuals,
copyFocusInputs: () => copyFocusInputs,
copyCol,
copyIdx,
meta,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 } })

Expand Down
Loading