Skip to content

Commit 4e3d888

Browse files
committed
add yy to copymode
1 parent 5de695d commit 4e3d888

5 files changed

Lines changed: 115 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Works similarly to tmux copy mode within opencode tui.
9494
- Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`).
9595
- Press `v` / `V` to start character-wise or line-wise selection.
9696
- `y` yanks to the vim register.
97+
- `yy` yanks the current line and exits copy mode.
9798
- `Enter` copies to the system clipboard.
9899
- `Escape` exits visual mode, `q` exits copy mode.
99100
- `z` `zt` `zz` `zb` adjust copy-mode scroll positioning.

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export type PromptProps = {
9393
exit: () => void
9494
visual: (mode: "char" | "line") => void
9595
yank: () => { text: string; linewise: boolean } | null
96+
yankLine: () => { text: string; linewise: boolean } | null
9697
copy: () => Promise<void> | void
9798
isVisual: () => boolean
9899
exitVisual: () => void
@@ -514,10 +515,17 @@ export function Prompt(props: PromptProps) {
514515
copyExitVisual() {
515516
props.copy?.exitVisual()
516517
},
518+
copyExit() {
519+
props.copy?.exit()
520+
},
517521
copyYank() {
518522
const reg = props.copy?.yank()
519523
if (reg) setVimRegister(reg, true)
520524
},
525+
copyYankLine() {
526+
const reg = props.copy?.yankLine()
527+
if (reg) setVimRegister(reg, true)
528+
},
521529
copyCopy() {
522530
return props.copy?.copy()
523531
},

packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ export function createVimHandler(input: {
8383
copy?: (action: VimCopyMove) => void
8484
copyVisual?: (mode: "char" | "line") => void
8585
copyExitVisual?: () => void
86+
copyExit?: () => void
8687
copyYank?: () => void
88+
copyYankLine?: () => void
8789
copyCopy?: () => void
8890
copyIsVisual?: () => boolean
8991
copyJump?: (action: VimJump) => void
@@ -1011,8 +1013,25 @@ export function createVimHandler(input: {
10111013
}
10121014

10131015
if (key === "y") {
1014-
input.copyYank?.()
1015-
input.state.setMode("normal")
1016+
// If in visual mode, yank selection and exit (original y behavior)
1017+
if (input.copyIsVisual?.()) {
1018+
input.copyYank?.()
1019+
input.state.setMode("normal")
1020+
event.preventDefault()
1021+
return true
1022+
}
1023+
// Not in visual mode - handle yy for yanking current line
1024+
if (input.state.pending() === "y") {
1025+
input.state.clearPending()
1026+
input.copyYankLine?.()
1027+
setTimeout(() => {
1028+
input.state.setMode("normal")
1029+
input.copyExit?.()
1030+
}, 150)
1031+
event.preventDefault()
1032+
return true
1033+
}
1034+
input.state.setPending("y")
10161035
event.preventDefault()
10171036
return true
10181037
}
@@ -1227,6 +1246,11 @@ export function createVimHandler(input: {
12271246
return true
12281247
}
12291248

1249+
// Clear y pending if no handler matched
1250+
if (input.state.pending() === "y") {
1251+
input.state.clearPending()
1252+
}
1253+
12301254
return false
12311255
}
12321256

packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function createCopyMode(input: {
5959
toBottom: () => void
6060
}) {
6161
const [state, setState] = createSignal<CopyState>({ ...empty })
62+
const [yankLineFlash, setYankLineFlash] = createSignal<number | undefined>(undefined)
6263

6364
// --- row building ---
6465

@@ -329,6 +330,7 @@ export function createCopyMode(input: {
329330
}
330331

331332
function exit() {
333+
setYankLineFlash(undefined)
332334
setState({ ...empty })
333335
input.toBottom()
334336
}
@@ -484,6 +486,23 @@ export function createCopyMode(input: {
484486
return { text, linewise: false }
485487
}
486488

489+
function yankLine() {
490+
const list = rows()
491+
const s = state()
492+
const row = list[s.idx]
493+
if (!row) return null
494+
const cache = new Map(
495+
input
496+
.scroll()
497+
.getChildren()
498+
.map((c) => [c.id, c]),
499+
)
500+
const text = signedText(row, cache)
501+
setYankLineFlash(s.idx)
502+
setTimeout(() => setYankLineFlash(undefined), 150)
503+
return { text, linewise: true }
504+
}
505+
487506
async function copy() {
488507
const text = selectionText()
489508
if (!text) return
@@ -593,7 +612,36 @@ export function createCopyMode(input: {
593612

594613
const highlights = createMemo(() => {
595614
const s = state()
596-
if (!s.visual || !s.anchor) return new Map<string, CopyHighlight[]>()
615+
const flashIdx = yankLineFlash()
616+
const out = new Map<string, CopyHighlight[]>()
617+
618+
// Handle yy flash highlight
619+
if (flashIdx !== undefined) {
620+
const list = rows()
621+
const row = list[flashIdx]
622+
if (row) {
623+
const cache = new Map(
624+
input
625+
.scroll()
626+
.getChildren()
627+
.map((c) => [c.id, c]),
628+
)
629+
const text = rowText(row, cache) || ""
630+
const min = copyMin(row, cache)
631+
const max = text.length > 0 ? min + text.length - 1 : min
632+
const cur = out.get(row.id) ?? []
633+
cur.push({
634+
line: row.line,
635+
left: min,
636+
right: max,
637+
text: text.slice(Math.max(0, min - min), Math.max(0, max - min + 1)),
638+
})
639+
out.set(row.id, cur)
640+
}
641+
}
642+
643+
// Handle visual mode highlights
644+
if (!s.visual || !s.anchor) return out
597645
const list = rows()
598646
const cache = new Map(
599647
input
@@ -605,7 +653,6 @@ export function createCopyMode(input: {
605653
const h = { idx: s.idx, col: s.col }
606654
const start = a.idx <= h.idx ? a : h
607655
const end = a.idx <= h.idx ? h : a
608-
const out = new Map<string, CopyHighlight[]>()
609656
for (let i = start.idx; i <= end.idx; i++) {
610657
const r = list[i]
611658
if (!r) continue
@@ -634,6 +681,7 @@ export function createCopyMode(input: {
634681
exit,
635682
visual,
636683
yank,
684+
yankLine,
637685
copy,
638686
isVisual: () => !!state().visual,
639687
exitVisual,
@@ -653,6 +701,7 @@ export function createCopyMode(input: {
653701
},
654702
row,
655703
highlights,
704+
yankLineFlash: () => yankLineFlash(),
656705
active: () => state().active,
657706
clamp,
658707
state,

packages/opencode/test/cli/tui/vim-motions.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ function createHandler(
186186
const copyVisualCalls: Array<"char" | "line"> = []
187187
const copyScrollCalls: Array<"center" | "top" | "bottom"> = []
188188
let copyYanks = 0
189+
let copyYankLines = 0
189190
let copyCopies = 0
190191
let copyExitVisuals = 0
191192

@@ -306,6 +307,10 @@ function createHandler(
306307
copyYanks++
307308
state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false })
308309
},
310+
copyYankLine() {
311+
copyYankLines++
312+
state.setRegister({ text: options?.copy?.text ?? "picked line", linewise: true })
313+
},
309314
copyCopy() {
310315
copyCopies++
311316
},
@@ -388,6 +393,7 @@ function createHandler(
388393
copyVisualCalls,
389394
copyScrollCalls,
390395
copyYanks: () => copyYanks,
396+
copyYankLines: () => copyYankLines,
391397
copyCopies: () => copyCopies,
392398
copyExitVisuals: () => copyExitVisuals,
393399
copyCol,
@@ -4362,6 +4368,29 @@ describe("copy mode", () => {
43624368
expect(ctx.state.mode()).toBe("normal")
43634369
})
43644370

4371+
test("yy yanks current line and exits copy mode", async () => {
4372+
const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked line" } })
4373+
4374+
const first = createEvent("y")
4375+
expect(ctx.handler.handleKey(first.event)).toBe(true)
4376+
expect(first.prevented()).toBe(true)
4377+
expect(ctx.copyYankLines()).toBe(0)
4378+
expect(ctx.state.pending()).toBe("y")
4379+
4380+
const second = createEvent("y")
4381+
expect(ctx.handler.handleKey(second.event)).toBe(true)
4382+
expect(second.prevented()).toBe(true)
4383+
expect(ctx.copyYankLines()).toBe(1)
4384+
expect(ctx.copyYanks()).toBe(0)
4385+
expect(ctx.copyCopies()).toBe(0)
4386+
expect(ctx.state.register()).toEqual({ text: "picked line", linewise: true })
4387+
expect(ctx.state.pending()).toBe("")
4388+
4389+
// Wait for setTimeout to complete
4390+
await new Promise((resolve) => setTimeout(resolve, 200))
4391+
expect(ctx.state.mode()).toBe("normal")
4392+
})
4393+
43654394
test("return copies selection to clipboard path and exits copy mode", () => {
43664395
const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } })
43674396

0 commit comments

Comments
 (0)