Skip to content
Closed
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 @@ -94,6 +94,7 @@ Works similarly to tmux copy mode within opencode tui.
- 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` yanks to the vim register.
- `yy` yanks the current line and exits copy mode.
- `Enter` copies to the system clipboard.
- `Escape` exits visual mode, `q` exits copy mode.
- `z` `zt` `zz` `zb` adjust copy-mode scroll positioning.
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export type PromptProps = {
exit: () => void
visual: (mode: "char" | "line") => void
yank: () => { text: string; linewise: boolean } | null
yankLine: () => { text: string; linewise: boolean } | null
copy: () => Promise<void> | void
isVisual: () => boolean
exitVisual: () => void
Expand Down Expand Up @@ -548,10 +549,17 @@ export function Prompt(props: PromptProps) {
copyExitVisual() {
props.copy?.exitVisual()
},
copyExit() {
props.copy?.exit()
},
copyYank() {
const reg = props.copy?.yank()
if (reg) setVimRegister(reg, true)
},
copyYankLine() {
const reg = props.copy?.yankLine()
if (reg) setVimRegister(reg, true)
},
copyCopy() {
return props.copy?.copy()
},
Expand Down
58 changes: 43 additions & 15 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ export function createVimHandler(input: {
copy?: (action: VimCopyMove) => void
copyVisual?: (mode: "char" | "line") => void
copyExitVisual?: () => void
copyExit?: () => void
copyYank?: () => void
copyYankLine?: () => void
copyCopy?: () => void
copyIsVisual?: () => boolean
copyJump?: (action: VimJump) => void
Expand Down Expand Up @@ -971,6 +973,42 @@ export function createVimHandler(input: {
}

function copy(event: VimEvent, key: string): boolean {

if (key === "y") {
// If in visual mode, yank selection and exit (original y behavior)
if (input.copyIsVisual?.()) {
input.copyYank?.()
input.state.setMode("normal")
event.preventDefault()
return true
}
// Not in visual mode - handle yy for yanking current line
if (input.state.pending() === "y") {
input.state.clearPending()
input.copyYankLine?.()
setTimeout(() => {
input.state.setMode("normal")
input.copyExit?.()
}, 150)
event.preventDefault()
return true
}
input.state.setPending("y")
event.preventDefault()
return true
}

const pending = input.state.pending()
if (pending === "y") {
input.state.clearPending()
}

if (key === "return") {
input.copyCopy?.()
input.state.setMode("normal")
event.preventDefault()
return true
}
if (key === "q") {
input.state.setMode("normal")
event.preventDefault()
Expand Down Expand Up @@ -1034,21 +1072,6 @@ export function createVimHandler(input: {
return true
}

if (key === "y") {
input.copyYank?.()
input.state.setMode("normal")
event.preventDefault()
return true
}

if (key === "return") {
input.copyCopy?.()
input.state.setMode("normal")
event.preventDefault()
return true
}

const pending = input.state.pending()
if (pending === "f" || pending === "F" || pending === "t" || pending === "T") {
if (key.length === 1) {
const forward = pending === "f" || pending === "t"
Expand Down Expand Up @@ -1251,6 +1274,11 @@ export function createVimHandler(input: {
return true
}

// Clear y pending if no handler matched
if (input.state.pending() === "y") {
input.state.clearPending()
}

return false
}

Expand Down
53 changes: 51 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function createCopyMode(input: {
toBottom: () => void
}) {
const [state, setState] = createSignal<CopyState>({ ...empty })
const [yankLineFlash, setYankLineFlash] = createSignal<number | undefined>(undefined)

// --- row building ---

Expand Down Expand Up @@ -488,6 +489,23 @@ export function createCopyMode(input: {
return { text, linewise: false }
}

function yankLine() {
const list = rows()
const s = state()
const row = list[s.idx]
if (!row) return null
const cache = new Map(
input
.scroll()
.getChildren()
.map((c) => [c.id, c]),
)
const text = signedText(row, cache)
setYankLineFlash(s.idx)
setTimeout(() => setYankLineFlash(undefined), 70)
return { text, linewise: true }
}

async function copy() {
const text = selectionText()
if (!text) return
Expand Down Expand Up @@ -597,7 +615,36 @@ export function createCopyMode(input: {

const highlights = createMemo(() => {
const s = state()
if (!s.visual || !s.anchor) return new Map<string, CopyHighlight[]>()
const flashIdx = yankLineFlash()
const out = new Map<string, CopyHighlight[]>()

// Handle yy flash highlight
if (flashIdx !== undefined) {
const list = rows()
const row = list[flashIdx]
if (row) {
const cache = new Map(
input
.scroll()
.getChildren()
.map((c) => [c.id, c]),
)
const text = rowText(row, cache) || ""
const min = copyMin(row, cache)
const max = text.length > 0 ? min + text.length - 1 : min
const cur = out.get(row.id) ?? []
cur.push({
line: row.line,
left: min,
right: max,
text: text.slice(Math.max(0, min - min), Math.max(0, max - min + 1)),
})
out.set(row.id, cur)
}
}

// Handle visual mode highlights
if (!s.visual || !s.anchor) return out
const list = rows()
const cache = new Map(
input
Expand All @@ -607,7 +654,6 @@ export function createCopyMode(input: {
)
const h = { idx: s.idx, col: s.col }
const { start, end } = orderEndpoints(s.anchor, h)
const out = new Map<string, CopyHighlight[]>()
const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => {
if (left > right) return
const entry = {
Expand All @@ -620,6 +666,7 @@ export function createCopyMode(input: {
if (arr) arr.push(entry)
else out.set(row.id, [entry])
}

for (let i = start.idx; i <= end.idx; i++) {
const r = list[i]
if (!r) continue
Expand Down Expand Up @@ -661,6 +708,7 @@ export function createCopyMode(input: {
exit,
visual,
yank,
yankLine,
copy,
isVisual: () => !!state().visual,
exitVisual,
Expand All @@ -680,6 +728,7 @@ export function createCopyMode(input: {
},
row,
highlights,
yankLineFlash: () => yankLineFlash(),
active: () => state().active,
clamp,
state,
Expand Down
46 changes: 46 additions & 0 deletions packages/opencode/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function createHandler(
const copyVisualCalls: Array<"char" | "line"> = []
const copyScrollCalls: Array<"center" | "top" | "bottom"> = []
let copyYanks = 0
let copyYankLines = 0
let copyCopies = 0
let copyExitVisuals = 0

Expand Down Expand Up @@ -306,6 +307,10 @@ function createHandler(
copyYanks++
state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false })
},
copyYankLine() {
copyYankLines++
state.setRegister({ text: options?.copy?.text ?? "picked line", linewise: true })
},
copyCopy() {
copyCopies++
},
Expand Down Expand Up @@ -388,6 +393,7 @@ function createHandler(
copyVisualCalls,
copyScrollCalls,
copyYanks: () => copyYanks,
copyYankLines: () => copyYankLines,
copyCopies: () => copyCopies,
copyExitVisuals: () => copyExitVisuals,
copyCol,
Expand Down Expand Up @@ -4519,6 +4525,46 @@ describe("copy mode", () => {
expect(ctx.state.mode()).toBe("normal")
})

test("yy yanks current line and exits copy mode", async () => {
const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked line" } })

const first = createEvent("y")
expect(ctx.handler.handleKey(first.event)).toBe(true)
expect(first.prevented()).toBe(true)
expect(ctx.copyYankLines()).toBe(0)
expect(ctx.state.pending()).toBe("y")

const second = createEvent("y")
expect(ctx.handler.handleKey(second.event)).toBe(true)
expect(second.prevented()).toBe(true)
expect(ctx.copyYankLines()).toBe(1)
expect(ctx.copyYanks()).toBe(0)
expect(ctx.copyCopies()).toBe(0)
expect(ctx.state.register()).toEqual({ text: "picked line", linewise: true })
expect(ctx.state.pending()).toBe("")

// Wait for setTimeout to complete
await new Promise((resolve) => setTimeout(resolve, 200))
expect(ctx.state.mode()).toBe("normal")
})

test("y H y in copy mode should not trigger yy ", () => {
const ctx = createHandler("abc", { mode: "copy"})

ctx.handler.handleKey(createEvent("y").event)
expect(ctx.state.pending()).toBe("y")

// H should clear pending y
ctx.handler.handleKey(createEvent("H").event)
expect(ctx.state.pending()).toBe("")
expect(ctx.copyJumps).toContain("high")

// second y starts pending y and does not trigger yy
ctx.handler.handleKey(createEvent("y").event)
expect(ctx.state.pending()).toBe("y")
expect(ctx.copyYankLines()).toBe(0)
})

test("return copies selection to clipboard path and exits copy mode", () => {
const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } })

Expand Down
Loading