Skip to content

Commit c879d7e

Browse files
committed
enhance entering and exiting copy mode
1 parent d8c2f7e commit c879d7e

7 files changed

Lines changed: 236 additions & 22 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: 40 additions & 2 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
@@ -365,6 +365,32 @@ export function Prompt(props: PromptProps) {
365365
return input.plainText.length > 0
366366
}
367367

368+
function handleNavigation(action: "up" | "down"){
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()
385+
386+
if (action === "up"){
387+
props.copy.move("up")
388+
}
389+
if (action === "down"){
390+
props.copy.move("down")
391+
}
392+
}
393+
368394
function promptJump(action: "top" | "bottom" | "high" | "middle" | "low") {
369395
if (!input || input.isDestroyed) return
370396
if (action === "top") {
@@ -417,6 +443,9 @@ export function Prompt(props: PromptProps) {
417443
if (action === "top") command.trigger("session.first")
418444
if (action === "bottom") command.trigger("session.last")
419445
},
446+
navigate(action) {
447+
handleNavigation(action)
448+
},
420449
copy(action) {
421450
props.copy?.move(action)
422451
},
@@ -426,6 +455,9 @@ export function Prompt(props: PromptProps) {
426455
copyExitVisual() {
427456
props.copy?.exitVisual()
428457
},
458+
copyExit(scrollToBottom = true) {
459+
props.copy?.exit(scrollToBottom)
460+
},
429461
copyYank() {
430462
const reg = props.copy?.yank()
431463
if (reg) vimState.setRegister(reg)
@@ -1390,7 +1422,13 @@ export function Prompt(props: PromptProps) {
13901422
if (vimState.isCopy()) {
13911423
const active = vimState.isCopy()
13921424
vim.handleKey(e)
1393-
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+
}
13941432
if (!e.defaultPrevented) e.preventDefault()
13951433
return
13961434
}

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +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"
67
import {
78
appendAfterCursor,
89
appendLineEnd,
@@ -49,6 +50,7 @@ import {
4950
} from "./vim-motions"
5051

5152
export type VimEvent = {
53+
5254
name?: string
5355
shift?: boolean
5456
ctrl?: boolean
@@ -66,9 +68,11 @@ export function createVimHandler(input: {
6668
submit: () => void
6769
scroll: (action: VimScroll) => void
6870
jump: (action: VimJump) => void
71+
navigate: (action: VimWindowNavigation) => void
6972
copy?: (action: VimCopyMove) => void
7073
copyVisual?: (mode: "char" | "line") => void
7174
copyExitVisual?: () => void
75+
copyExit?: (scrollToBottom?: boolean) => void
7276
copyYank?: () => void
7377
copyCopy?: () => void
7478
copyIsVisual?: () => boolean
@@ -182,6 +186,16 @@ export function createVimHandler(input: {
182186
return true
183187
}
184188

189+
const navigation = vimWindowNavigation(event, input.state);
190+
if (navigation.handled){
191+
if (navigation.action) {
192+
input.state.clearPending();
193+
input.navigate(navigation.action);
194+
}
195+
event.preventDefault()
196+
return true
197+
}
198+
185199
if (key === "escape") {
186200
if (input.state.isVisual()) {
187201
clearSelection(input.textarea())
@@ -715,8 +729,15 @@ export function createVimHandler(input: {
715729
return true
716730
}
717731

732+
// window navigation
733+
if (key === "w" && hasModifier(event)) {
734+
input.state.setPending("w");
735+
event.preventDefault();
736+
return true;
737+
}
738+
718739
return false
719-
}
740+
}
720741

721742
function copyMotion(offset: number) {
722743
input.setCopyCol?.(offset)
@@ -740,6 +761,40 @@ export function createVimHandler(input: {
740761
return true
741762
}
742763

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+
743798
const scroll = vimScroll(event)
744799
if (scroll) {
745800
input.scroll(scroll)
@@ -964,6 +1019,7 @@ export function createVimHandler(input: {
9641019
return true
9651020
}
9661021

1022+
9671023
// repeat find
9681024
if (key === ";") {
9691025
const last = input.state.lastFind()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { VimEvent } from "./vim-handler"
2+
import type { createVimState } from "./vim-state"
3+
4+
export type VimWindowNavigation = "up" | "down";
5+
6+
export function vimWindowNavigation(event: VimEvent, state: ReturnType<typeof createVimState>) {
7+
const key = event.name ?? "";
8+
9+
if (state.pending() === "w"){
10+
if (key === "k") {
11+
state.clearPending()
12+
return { action: "up" as VimWindowNavigation, handled: true }
13+
}
14+
15+
if (key === "j") {
16+
state.clearPending()
17+
return { action: "down" as VimWindowNavigation, handled: true }
18+
}
19+
20+
return { handled: false };
21+
}
22+
23+
return { handled: false };
24+
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
22

33
export type VimMode = "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy"
4-
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y"
4+
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w"
55
export type VimFind = { char: string; forward: boolean; till: boolean } | null
66
export type VimRegister = { text: string; linewise: boolean } | null
77
export type VimSnapshot = { text: string; cursor: number; data?: unknown }
@@ -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
},

0 commit comments

Comments
 (0)