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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,17 @@ 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/yy` yanks to the vim register.
- `Enter` copies to the system clipboard.
- `y` yanks visual selection to the vim register and exits copy mode.
- `yy` yanks the current line to the vim register with a brief highlight flash.
- `Y` yanks to the vim register and scrolls to the bottom.
- `Enter` copies to the system clipboard.
- `Shift+Enter` copies to the system clipboard and scrolls to the bottom.
- `Escape` exits visual mode, `q` exits copy mode and scrolls to the bottom.
- `i` focuses the prompt input.
- `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
35 changes: 31 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export type PromptProps = {
}
copy?: {
enter: () => void
exit: () => void
exit: (scrollToBottom?: boolean) => void
exitPreserveScroll: () => void
focusInput: () => void
visual: (mode: "char" | "line") => void
Expand Down Expand Up @@ -534,6 +534,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 @@ -643,6 +661,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 @@ -652,8 +673,8 @@ export function Prompt(props: PromptProps) {
copyExitVisual() {
props.copy?.exitVisual()
},
copyExit() {
props.copy?.exit()
copyExit(scrollToBottom) {
props.copy?.exit(scrollToBottom)
},
copyExitPreserveScroll() {
props.copy?.exitPreserveScroll()
Expand Down Expand Up @@ -1779,7 +1800,13 @@ export function Prompt(props: PromptProps) {
if (vimState.isCopy()) {
const active = vimState.isCopy()
vim.handleKey(e)
if (active && vimState.mode() === "normal" && props.copy?.active()) props.copy.exit()
if (active && vimState.mode() === "normal" && props.copy?.active()) {
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
43 changes: 42 additions & 1 deletion 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 @@ -82,10 +83,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?: () => void
copyExit?: (scrollToBottom?: boolean) => void
copyExitPreserveScroll?: () => void
copyFocusInput?: () => void
copyYank?: () => void
Expand Down Expand Up @@ -336,6 +338,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 @@ -1026,6 +1038,12 @@ export function createVimHandler(input: {
return true
}

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

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

if (input.state.pending() === "w") {
if (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
}
input.state.clearPending()
}

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

const scroll = vimScroll(event)
if (scroll) {
clearCopyPending()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 }
}

state.clearPending()
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" | "r" | "vr"
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr"
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