Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,16 @@ 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` or `Ctrl+W k`.
- Navigate with `h` `j` `k` `l` or arrow keys (`Left` `Down` `Up` `Right`).
- 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 to the vim register and scrolls to the bottom.
- `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.
- `Ctrl+W j` exits copy mode without scrolling; `Ctrl+W w` toggles copy mode.
- `i` focuses the prompt input in insert mode without scrolling.
- `z` `zt` `zz` `zb` adjust copy-mode scroll positioning.
- `H` / `M` / `L` jump to the top / middle / bottom of the viewport.

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") {
const skipExit = vimState.skipExitOnModeChange()
const scrollToBottom = vimState.exitScrollToBottom()
vimState.setSkipExitOnModeChange(false)
vimState.setExitScrollToBottom(true)
if (props.copy?.active() && !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 && !event.shift && !event.meta && !event.super) {
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" || key === "w") {
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 && !event.shift && !event.meta && !event.super) {
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
Expand Up @@ -10,7 +10,7 @@ export function useVimIndicator(input: {
return createMemo(() => {
if (!input.enabled() || !input.active()) return
const key = input.state.pending()
if (key) return key + ".."
if (key && key !== "w") return key + ".."
if (input.state.isCopy()) {
if (input.copyVisual?.() === "char") return "-- V-COPY --"
if (input.copyVisual?.() === "line") return "-- VL-COPY --"
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" || key === "w") {
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