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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,15 @@ Works similarly to tmux copy mode within opencode tui.

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

- Enter copy mode with `<leader>v`.
- Enter copy mode with `<leader>v` (scrolls to latest message) or `Ctrl+W k` (stays in place).
- Exit with `q` or `Escape` (scrolls to latest message), `Ctrl+W j` or `i` (stays in place, `i` returns to insert mode).
- Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`).
- `H` / `M` / `L` jump to the top / middle / bottom of the viewport.
- Press `v` / `V` to start character-wise or line-wise selection.
- `y` yanks to the vim register.
- `y` yanks visual selection to the vim register (stays in copy mode).
- `yy` yanks the current line to the vim register with a brief highlight flash.
- `Enter` copies to the system clipboard.
- `Escape` exits visual mode, `q` exits copy mode.
- `z` `zt` `zz` `zb` adjust copy-mode scroll positioning.
- `H` / `M` / `L` jump to the top / middle / bottom of the viewport.

> [!TIP]
> Configure the entry key with `keybinds.copy_mode` in your config if you want something other than `<leader>v`.
Expand Down
39 changes: 37 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ export type PromptProps = {
}
copy?: {
enter: () => void
exit: () => void
exit: (scrollToBottom?: boolean) => 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 @@ -396,6 +397,24 @@ export function Prompt(props: PromptProps) {
return input.plainText.length > 0
}

function handleNavigation(action: "up" | "down") {
if (!props.copy) return
if (action === "up" && !vimState.isCopy()) {
vimState.setMode("copy")
props.copy.enter()
}
if (action === "down" && vimState.isCopy()) {
const skipExit = vimState.skipExitOnModeChange()
const scrollToBottom = vimState.exitScrollToBottom()
vimState.setSkipExitOnModeChange(false)
vimState.setExitScrollToBottom(true)
vimState.setMode("normal")
if (!skipExit) {
props.copy.exit(scrollToBottom)
}
}
}

function promptSelectionText() {
if (!input || input.isDestroyed) return
const text = input.editorView.getSelectedText()
Expand Down Expand Up @@ -505,6 +524,9 @@ export function Prompt(props: PromptProps) {
if (action === "top") command.trigger("session.first")
if (action === "bottom") command.trigger("session.last")
},
navigate(action) {
handleNavigation(action)
},
copy(action) {
props.copy?.move(action)
},
Expand All @@ -514,10 +536,17 @@ export function Prompt(props: PromptProps) {
copyExitVisual() {
props.copy?.exitVisual()
},
copyExit(scrollToBottom) {
props.copy?.exit(scrollToBottom)
},
copyYank() {
const reg = props.copy?.yank()
if (reg) setVimRegister(reg, true)
},
copyYankLine() {
const reg = props.copy?.yankLine()
if (reg) vimState.setRegister(reg)
},
copyCopy() {
return props.copy?.copy()
},
Expand Down Expand Up @@ -1564,7 +1593,13 @@ 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.isCopy()) {
const skipExit = vimState.skipExitOnModeChange()
const scrollToBottom = vimState.exitScrollToBottom()
vimState.setSkipExitOnModeChange(false)
vimState.setExitScrollToBottom(true)
if (!skipExit) props.copy?.exit(scrollToBottom)
}
if (!e.defaultPrevented) e.preventDefault()
return
}
Expand Down
56 changes: 54 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { createVimState, VimRegister, VimSnapshot } from "./vim-state"
import type { TextareaRenderable } from "@opentui/core"
import { vimScroll, type VimScroll } from "./vim-scroll"
import { vimJump, type VimJump } from "./vim-motion-jump"
import { vimWindowNavigation, type VimWindowNavigation } from "./vim-motion-window-navigation"
import {
appendAfterCursor,
appendLineEnd,
Expand Down Expand Up @@ -80,9 +81,11 @@ export function createVimHandler(input: {
submit: () => void
scroll: (action: VimScroll) => void
jump: (action: VimJump) => void
navigate: (action: VimWindowNavigation) => void
copy?: (action: VimCopyMove) => void
copyVisual?: (mode: "char" | "line") => void
copyExitVisual?: () => void
copyExit?: (scrollToBottom?: boolean) => void
copyYank?: () => void
copyCopy?: () => void
copyIsVisual?: () => boolean
Expand Down Expand Up @@ -252,6 +255,16 @@ export function createVimHandler(input: {
return true
}

const navigation = vimWindowNavigation(event, input.state)
if (navigation.handled) {
if (navigation.action) {
input.state.clearPending()
input.navigate(navigation.action)
}
event.preventDefault()
return true
}

if (key === "escape") {
if (input.state.isVisual()) {
clearSelection(input.textarea())
Expand Down Expand Up @@ -929,6 +942,12 @@ export function createVimHandler(input: {
return true
}

if (key === "w" && hasModifier(event)) {
input.state.setPending("w")
event.preventDefault()
return true
}

if (key === "backspace" || key === "delete") {
event.preventDefault()
return true
Expand Down Expand Up @@ -964,6 +983,36 @@ export function createVimHandler(input: {
return true
}

if (key === "i") {
if (input.copyIsVisual?.()) input.copyExitVisual?.()
input.state.setSkipExitOnModeChange(true)
input.state.setExitScrollToBottom(false)
input.state.setMode("insert")
input.copyExit?.(false)
event.preventDefault()
return true
}

if (input.state.pending() === "w" && key === "j") {
if (input.copyIsVisual?.()) {
input.copyExitVisual?.()
event.preventDefault()
return true
}
input.state.setSkipExitOnModeChange(true)
input.state.setExitScrollToBottom(false)
input.state.setMode("normal")
input.copyExit?.(false)
event.preventDefault()
return true
}

if (key === "w" && hasModifier(event)) {
input.state.setPending("w")
event.preventDefault()
return true
}

const scroll = vimScroll(event)
if (scroll) {
input.scroll(scroll)
Expand Down Expand Up @@ -1011,8 +1060,11 @@ export function createVimHandler(input: {
}

if (key === "y") {
input.copyYank?.()
input.state.setMode("normal")
if (input.copyIsVisual?.()) {
input.copyYank?.()
input.copyExitVisual?.()
}
input.copyExit?.(true)
event.preventDefault()
return true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { VimEvent } from "./vim-handler"
import type { createVimState } from "./vim-state"

export type VimWindowNavigation = "up" | "down"

export function vimWindowNavigation(event: VimEvent, state: ReturnType<typeof createVimState>) {
const key = event.name ?? ""

if (state.pending() === "w") {
if (key === "k") {
state.clearPending()
return { action: "up" as VimWindowNavigation, handled: true }
}

if (key === "j") {
state.clearPending()
return { action: "down" as VimWindowNavigation, handled: true }
}

return { handled: false }
}

return { handled: false }
}
8 changes: 7 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"

export type VimMode = "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy"
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y"
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w"
export type VimFind = { char: string; forward: boolean; till: boolean } | null
export type VimRegister = { text: string; linewise: boolean } | null
export type VimSnapshot = { text: string; cursor: number; data?: unknown }
Expand All @@ -22,6 +22,8 @@ export function createVimState(input: { enabled: Accessor<boolean>; initial?: Ac
const [undos, setUndos] = createSignal<VimHistory[]>([])
const [redos, setRedos] = createSignal<VimSnapshot[]>([])
const [edit, setEdit] = createSignal<VimSnapshot | null>(null)
const [skipExitOnModeChange, setSkipExitOnModeChange] = createSignal(false)
const [exitScrollToBottom, setExitScrollToBottom] = createSignal(true)

function clearPending() {
if (pending()) setPending("")
Expand Down Expand Up @@ -126,5 +128,9 @@ export function createVimState(input: { enabled: Accessor<boolean>; initial?: Ac
isVisual: createMemo(() => mode() === "visual" || mode() === "visual-line"),
isVisualLine: createMemo(() => mode() === "visual-line"),
isCopy: createMemo(() => mode() === "copy"),
skipExitOnModeChange,
setSkipExitOnModeChange,
exitScrollToBottom,
setExitScrollToBottom,
}
}
Loading
Loading