Skip to content

Commit 1636dc6

Browse files
committed
enhance entering and exiting copy mode
1 parent 151f7a1 commit 1636dc6

6 files changed

Lines changed: 175 additions & 24 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,18 @@ Works similarly to tmux copy mode within opencode tui.
8383

8484
<img src=".github/demo-copy-mode.gif" style="border: 1px solid #555; border-radius: 4px;" />
8585

86-
- Enter copy mode with `<leader>v`.
86+
- Enter copy mode with `<leader>v` or `Ctr + w + k` to enter copy mode without
87+
scrolling the chat history to the bottom.
8788
- Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`).
8889
- Press `v` / `V` to start character-wise or line-wise selection.
8990
- `y` yanks to the vim register.
9091
- `Enter` copies to the system clipboard.
9192
- `Escape` exits visual mode, `q` exits copy mode.
93+
- `i` exits copy mode without scrolling the chat history to the bottom and
94+
enters insert mode
95+
- `Ctrl + w + j` exits copy mode without scrolling the chat history to the
96+
bottom
97+
-
9298
- `z` `zt` `zz` `zb` adjust copy-mode scroll positioning.
9399
- `H` / `M` / `L` jump to the top / middle / bottom of the viewport.
94100

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export type PromptProps = {
8989
}
9090
copy?: {
9191
enter: () => void
92-
exit: () => void
92+
exit: (scrollToBottom?: boolean) => void
9393
visual: (mode: "char" | "line") => void
9494
yank: () => { text: string; linewise: boolean } | null
9595
copy: () => Promise<void> | void
@@ -366,12 +366,28 @@ export function Prompt(props: PromptProps) {
366366
}
367367

368368
function handleNavigation(action: "up" | "down"){
369-
if (action === "up"){
370-
// TODO
369+
if (!props.copy) return
370+
371+
if (vimState.isCopy()) {
372+
const skipExit = vimState.skipExitOnModeChange()
373+
const scrollToBottom = vimState.exitScrollToBottom()
374+
vimState.setSkipExitOnModeChange(false)
375+
vimState.setExitScrollToBottom(true)
376+
vimState.setMode("normal")
377+
if (!skipExit) {
378+
props.copy.exit(scrollToBottom)
379+
}
380+
return
381+
}
382+
383+
vimState.setMode("copy")
384+
props.copy.enter()
371385

386+
if (action === "up"){
387+
props.copy.move("up")
372388
}
373389
if (action === "down"){
374-
// TODO
390+
props.copy.move("down")
375391
}
376392
}
377393

@@ -439,6 +455,9 @@ export function Prompt(props: PromptProps) {
439455
copyExitVisual() {
440456
props.copy?.exitVisual()
441457
},
458+
copyExit(scrollToBottom = true) {
459+
props.copy?.exit(scrollToBottom)
460+
},
442461
copyYank() {
443462
const reg = props.copy?.yank()
444463
if (reg) vimState.setRegister(reg)
@@ -1403,7 +1422,13 @@ export function Prompt(props: PromptProps) {
14031422
if (vimState.isCopy()) {
14041423
const active = vimState.isCopy()
14051424
vim.handleKey(e)
1406-
if (active && !vimState.isCopy()) props.copy?.exit()
1425+
if (active && !vimState.isCopy()) {
1426+
const skipExit = vimState.skipExitOnModeChange()
1427+
const scrollToBottom = vimState.exitScrollToBottom()
1428+
vimState.setSkipExitOnModeChange(false)
1429+
vimState.setExitScrollToBottom(true)
1430+
if (!skipExit) props.copy?.exit(scrollToBottom)
1431+
}
14071432
if (!e.defaultPrevented) e.preventDefault()
14081433
return
14091434
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { createVimState, VimSnapshot } from "./vim-state"
33
import type { TextareaRenderable } from "@opentui/core"
44
import { vimScroll, type VimScroll } from "./vim-scroll"
55
import { vimJump, type VimJump } from "./vim-motion-jump"
6-
import { vimWindowNavigation, type VimWindowNavigation } from "./vim-motion-window-navigation.ts";
6+
import { vimWindowNavigation, type VimWindowNavigation } from "./vim-motion-window-navigation.ts"
77
import {
88
appendAfterCursor,
99
appendLineEnd,
@@ -72,6 +72,7 @@ export function createVimHandler(input: {
7272
copy?: (action: VimCopyMove) => void
7373
copyVisual?: (mode: "char" | "line") => void
7474
copyExitVisual?: () => void
75+
copyExit?: (scrollToBottom?: boolean) => void
7576
copyYank?: () => void
7677
copyCopy?: () => void
7778
copyIsVisual?: () => boolean
@@ -760,6 +761,40 @@ export function createVimHandler(input: {
760761
return true
761762
}
762763

764+
if (key === "i") {
765+
if (input.copyIsVisual?.()) {
766+
input.copyExitVisual?.()
767+
event.preventDefault()
768+
return true
769+
}
770+
input.state.setSkipExitOnModeChange(true)
771+
input.state.setExitScrollToBottom(false)
772+
input.state.setMode("insert")
773+
event.preventDefault()
774+
input.copyExit?.(false)
775+
return true
776+
}
777+
778+
if (event.ctrl && input.state.pending() === "w" && key === "j") {
779+
if (input.copyIsVisual?.()) {
780+
input.copyExitVisual?.()
781+
event.preventDefault()
782+
return true
783+
}
784+
input.state.setSkipExitOnModeChange(true)
785+
input.state.setExitScrollToBottom(false)
786+
input.state.setMode("normal")
787+
event.preventDefault()
788+
input.copyExit?.(false)
789+
return true
790+
}
791+
792+
if (key === "w" && hasModifier(event)) {
793+
input.state.setPending("w")
794+
event.preventDefault()
795+
return true
796+
}
797+
763798
const scroll = vimScroll(event)
764799
if (scroll) {
765800
input.scroll(scroll)

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export function createVimState(input: { enabled: Accessor<boolean>; initial?: Ac
2222
const [undos, setUndos] = createSignal<VimHistory[]>([])
2323
const [redos, setRedos] = createSignal<VimSnapshot[]>([])
2424
const [edit, setEdit] = createSignal<VimSnapshot | null>(null)
25+
const [skipExitOnModeChange, setSkipExitOnModeChange] = createSignal(false)
26+
const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true)
2527

2628
function clearPending() {
2729
if (pending()) setPending("")
@@ -81,6 +83,10 @@ export function createVimState(input: { enabled: Accessor<boolean>; initial?: Ac
8183
setReplace,
8284
typed,
8385
setTyped,
86+
skipExitOnModeChange,
87+
setSkipExitOnModeChange,
88+
exitScrollToBottom,
89+
setExitScrollToBottom,
8490
beginEdit(snapshot: VimSnapshot) {
8591
setEdit(snapshot)
8692
},

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

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export function createCopyMode(input: {
5353
toBottom: () => void
5454
}) {
5555
const [state, setState] = createSignal<CopyState>({ ...empty })
56+
let skipSyncOnce = false
57+
let lastIdx = -1
5658

5759
// --- row building ---
5860

@@ -282,7 +284,18 @@ export function createCopyMode(input: {
282284

283285
// --- navigation ---
284286

285-
function sync(next: number) {
287+
function sync(next: number, scrollToVisible = true) {
288+
if (skipSyncOnce) {
289+
skipSyncOnce = false
290+
return
291+
}
292+
const s = state()
293+
if (s.idx === next && !s.active) {
294+
return
295+
}
296+
if (s.idx === next && s.active) {
297+
return
298+
}
286299
const scroll = input.scroll()
287300
const list = rows()
288301
if (!list.length) {
@@ -292,10 +305,10 @@ export function createCopyMode(input: {
292305
const idx = Math.max(0, Math.min(next, list.length - 1))
293306
setState((s) => ({ ...s, active: true, idx }))
294307
const row = list[idx]
295-
if (!row) return
308+
if (!row || !scrollToVisible) return
296309
const y = row.y
297310
const top = scroll.y
298-
const bottom = scroll.y + scroll.height - 1
311+
const bottom = scroll.y + scroll.height + 1
299312
if (y < top) {
300313
scroll.scrollBy(y - top)
301314
return
@@ -307,13 +320,33 @@ export function createCopyMode(input: {
307320

308321
function enter() {
309322
const init = () => {
323+
const scroll = input.scroll()
310324
const list = rows()
311325
if (!list.length) return false
312-
const idx = list.findLastIndex((x) => x.role === "assistant")
313-
const target = idx >= 0 ? idx : list.length - 1
314-
const row = list[target]
315-
setState((s) => ({ ...s, col: copyMin(row), stick: "first" as const }))
316-
sync(target)
326+
327+
const top = scroll.y
328+
const bottom = scroll.y + scroll.height - 1
329+
const visibleRows = list.filter((x) => x.role === "assistant" && x.y >= top && x.y <= bottom)
330+
331+
const savedIdx = lastIdx >= 0 ? lastIdx : state().idx
332+
const savedRow = savedIdx >= 0 && savedIdx < list.length ? list[savedIdx] : undefined
333+
const savedVisible = savedRow && savedRow.y >= top && savedRow.y <= bottom
334+
335+
const visibleIdx = list.findLastIndex((x) => x.role === "assistant" && x.y >= top && x.y <= bottom)
336+
const lastAssistantIdx = list.findLastIndex((x) => x.role === "assistant")
337+
338+
let target: number
339+
if (savedIdx >= 0) {
340+
target = savedIdx
341+
} else if (visibleIdx >= 0) {
342+
target = visibleIdx
343+
} else {
344+
target = lastAssistantIdx
345+
}
346+
const finalTarget = target >= 0 ? target : 0
347+
const row = list[finalTarget]
348+
setState((s) => ({ ...s, active: true, idx: finalTarget, col: copyMin(row), stick: "first" as const }))
349+
skipSyncOnce = true
317350
return true
318351
}
319352
if (init()) return
@@ -322,15 +355,23 @@ export function createCopyMode(input: {
322355
}, 0)
323356
}
324357

325-
function exit() {
326-
setState({ ...empty })
327-
input.toBottom()
358+
function exit(scrollToBottom?: boolean) {
359+
if (scrollToBottom) {
360+
lastIdx = -1
361+
input.toBottom()
362+
setState({ ...empty })
363+
} else{
364+
lastIdx = state().idx
365+
setState(s => ({ ...s, active: false, visual: undefined, anchor: undefined }))
366+
}
328367
}
329368

330369
function move(action: "up" | "down" | "left" | "right") {
331370
const scroll = input.scroll()
332371
const s = state()
333372
if (!s.active) return
373+
// want to detect if we just entered copy mode and put cursor into a message
374+
// that is currently visible in the chat
334375
if (action === "up" || action === "down") {
335376
sync(s.idx + (action === "up" ? -1 : 1))
336377
const row = rows()[state().idx]
@@ -525,21 +566,28 @@ export function createCopyMode(input: {
525566

526567
createEffect((prev: string | undefined) => {
527568
const id = input.session()
528-
if (prev !== undefined && prev !== id) exit()
569+
if (prev !== undefined && prev !== id) {
570+
exit()
571+
}
529572
return id
530573
})
531574

532-
createEffect(() => {
575+
576+
createEffect((prev: { active: boolean; idx: number } | undefined) => {
533577
const s = state()
578+
579+
if (prev && prev.active === s.active) return prev
580+
534581
const list = rows()
535-
if (!s.active) return
582+
if (!s.active) return { active: s.active, idx: s.idx }
536583
if (!list.length) {
537584
exit()
538-
return
585+
return { active: s.active, idx: s.idx }
539586
}
540587
if (s.idx >= list.length) {
541588
sync(list.length - 1)
542589
}
590+
return { active: s.active, idx: s.idx }
543591
})
544592

545593
// --- derived ---
@@ -552,7 +600,7 @@ export function createCopyMode(input: {
552600

553601
const highlights = createMemo(() => {
554602
const s = state()
555-
if (!s.visual || !s.anchor) return new Map<string, CopyHighlight[]>()
603+
if (!s.active || !s.visual || !s.anchor) return new Map<string, CopyHighlight[]>()
556604
const list = rows()
557605
const cache = new Map(
558606
input
@@ -590,7 +638,7 @@ export function createCopyMode(input: {
590638
return {
591639
prompt: {
592640
enter,
593-
exit,
641+
exit: (scrollToBottom = true) => exit(scrollToBottom),
594642
visual,
595643
yank,
596644
copy,

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ function createHandler(
168168
const [editState, setEditState] = createSignal<{ text: string; cursor: number } | null>(null)
169169
const [copyCol, setCopyCol] = createSignal(options?.copy?.col ?? 0)
170170
const [copyIdx, setCopyIdx] = createSignal(options?.copy?.idx ?? 0)
171+
const [skipExitOnModeChange, setSkipExitOnModeChange] = createSignal(false)
172+
const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true)
171173
const copyRows = options?.copy?.rows
172174
const scrollCalls: VimScroll[] = []
173175
const jumpCalls: VimJump[] = []
@@ -179,6 +181,7 @@ function createHandler(
179181
let copyYanks = 0
180182
let copyCopies = 0
181183
let copyExitVisuals = 0
184+
let copyExitCalls: boolean[] = []
182185

183186
function clearPending() {
184187
setPending("")
@@ -268,6 +271,10 @@ function createHandler(
268271
isVisual: () => mode() === "visual" || mode() === "visual-line",
269272
isVisualLine: () => mode() === "visual-line",
270273
isCopy: () => mode() === "copy",
274+
skipExitOnModeChange,
275+
setSkipExitOnModeChange,
276+
exitScrollToBottom,
277+
setExitScrollToBottom,
271278
} as ReturnType<typeof createVimState>
272279
const handler = createVimHandler({
273280
enabled,
@@ -294,6 +301,9 @@ function createHandler(
294301
copyExitVisuals++
295302
setCopyVisual(undefined)
296303
},
304+
copyExit(scrollToBottom) {
305+
copyExitCalls.push(scrollToBottom ?? true)
306+
},
297307
copyYank() {
298308
copyYanks++
299309
state.setRegister({ text: options?.copy?.text ?? "picked", linewise: false })
@@ -364,6 +374,7 @@ function createHandler(
364374
copyYanks: () => copyYanks,
365375
copyCopies: () => copyCopies,
366376
copyExitVisuals: () => copyExitVisuals,
377+
copyExitCalls: () => copyExitCalls,
367378
copyCol,
368379
copyIdx,
369380
meta,
@@ -2659,6 +2670,26 @@ describe("copy mode", () => {
26592670
expect(ctx.state.mode()).toBe("normal")
26602671
})
26612672

2673+
test("i in copy mode exits copy mode to insert mode without scrolling", () => {
2674+
const ctx = createHandler("abc", { mode: "copy" })
2675+
2676+
const evt = createEvent("i")
2677+
expect(ctx.handler.handleKey(evt.event)).toBe(true)
2678+
expect(evt.prevented()).toBe(true)
2679+
expect(ctx.state.mode()).toBe("insert")
2680+
expect(ctx.copyExitCalls()).toEqual([false])
2681+
})
2682+
2683+
test("i in copy mode visual exits visual and stays in copy mode", () => {
2684+
const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } })
2685+
2686+
const evt = createEvent("i")
2687+
expect(ctx.handler.handleKey(evt.event)).toBe(true)
2688+
expect(evt.prevented()).toBe(true)
2689+
expect(ctx.copyExitVisuals()).toBe(1)
2690+
expect(ctx.state.mode()).toBe("copy")
2691+
})
2692+
26622693
test("hjkl route to copy movement callbacks", () => {
26632694
const ctx = createHandler("abc", { mode: "copy" })
26642695

0 commit comments

Comments
 (0)