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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,15 @@ Text selection from the chat session view.
| `i` | Focus 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, or bottom of the viewport |
| `/`, `?` | Search forward or backward in chat history |
| `n`, `N` | Repeat search in the same or opposite direction |

When in search mode, `Enter` submits the search, and `Escape` clears search highlights before exiting copy mode.

> Search uses smartcase, lowercase queries are case-insensitive, and queries containing uppercase letters are case-sensitive.

> [!TIP]
> Configure the entry key with `keybinds.copy_mode`.
> Configure the copy mode entry key with `keybinds.copy_mode`.

### Minimal UI

Expand Down
107 changes: 107 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import {
VIM_WINDOW_TOKEN,
} from "../../keymap"

type CopySearchDirection = "forward" | "backward"

export type PromptProps = {
sessionID?: string
workspaceID?: string
Expand Down Expand Up @@ -115,6 +117,18 @@ export type PromptProps = {
matchingBracket: () => boolean
nextParagraph: () => boolean
previousParagraph: () => boolean
searchStart: (direction: CopySearchDirection) => void
searchAppend: (value: string) => boolean
searchBackspace: () => boolean
searchSubmit: () => boolean
searchCancel: () => void
searchClear: () => boolean
searchActive: () => boolean
searchHighlighted: () => boolean
searchMatchCount: () => number
searchDisplay: () => string | undefined
searchNext: () => boolean
searchPrevious: () => boolean
text: () => string
col: () => number
setCol: (offset: number) => void
Expand Down Expand Up @@ -547,6 +561,7 @@ export function Prompt(props: PromptProps) {
active: () => store.mode === "normal",
state: vimState,
copyVisual: () => props.copy?.visualMode(),
copySearch: () => props.copy?.searchDisplay(),
})
let flash = 0
let timer: ReturnType<typeof setTimeout> | undefined
Expand Down Expand Up @@ -773,6 +788,36 @@ export function Prompt(props: PromptProps) {
copyPreviousParagraph() {
return props.copy?.previousParagraph() ?? false
},
copySearchStart(direction) {
props.copy?.searchStart(direction)
},
copySearchAppend(value) {
return props.copy?.searchAppend(value) ?? false
},
copySearchBackspace() {
return props.copy?.searchBackspace() ?? false
},
copySearchSubmit() {
return props.copy?.searchSubmit() ?? true
},
copySearchCancel() {
props.copy?.searchCancel()
},
copySearchClear() {
return props.copy?.searchClear() ?? false
},
copySearchActive() {
return props.copy?.searchActive() ?? false
},
copySearchHighlighted() {
return props.copy?.searchHighlighted() ?? false
},
copySearchNext() {
return props.copy?.searchNext() ?? false
},
copySearchPrevious() {
return props.copy?.searchPrevious() ?? false
},
copyText() {
return props.copy?.text() ?? ""
},
Expand Down Expand Up @@ -1189,6 +1234,68 @@ export function Prompt(props: PromptProps) {
})),
}))

useBindings(() => ({
target: inputTarget,
priority: 200,
enabled:
inputTarget() !== undefined &&
!props.disabled &&
vimEnabled() &&
store.mode === "normal" &&
vimState.isCopy() &&
!!props.copy?.searchActive(),
bindings: [
...["backspace", "shift+backspace", "delete", "ctrl+h"].map((key) => ({
key,
desc: "Delete search character",
group: "Copy mode",
cmd: () => {
props.copy?.searchBackspace()
return true
},
})),
{
key: "return",
desc: "Submit search",
group: "Copy mode",
cmd: () => {
if (props.copy?.searchSubmit() === false) {
toast.show({ message: "Pattern not found", variant: "warning" })
return true
}
const count = props.copy?.searchMatchCount() ?? 0
if (count > 0) toast.show({ message: `${count} ${count === 1 ? "match" : "matches"}`, variant: "info" })
return true
},
},
],
}))

useBindings(() => ({
target: inputTarget,
priority: 200,
enabled:
inputTarget() !== undefined &&
!props.disabled &&
vimEnabled() &&
store.mode === "normal" &&
vimState.isCopy() &&
!props.copy?.isVisual() &&
!!props.copy?.searchHighlighted(),
bindings: [
{
key: "escape",
desc: "Clear search highlights",
group: "Copy mode",
cmd: () => {
if (props.copy?.searchActive()) props.copy.searchCancel()
else props.copy?.searchClear()
return true
},
},
],
}))

useBindings(() => ({
target: inputTarget,
priority: 100,
Expand Down
75 changes: 72 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type VimEvent = {

export type VimCopyMove = "up" | "down" | "left" | "right"
type VimFindOperator = "f" | "F" | "t" | "T"
type VimSearchDirection = "forward" | "backward"
type VimTextObjectScope = "inner" | "around"

type VimKeyLike = { name?: string; shift?: boolean; sequence?: string; raw?: string }
Expand All @@ -97,13 +98,14 @@ function vimEventText(event: VimKeyLike) {
}

function normalizedKeyName(event: VimKeyLike) {
if (event.name === "slash") return "/"
if (event.name === "backspace" || event.sequence === "\b" || event.sequence === "\x7f" || event.raw === "\b" || event.raw === "\x7f") return "backspace"
if (event.name === "slash") return event.shift ? "?" : "/"
if (event.name === "at") return "@"
if (event.name === "quote") return '"'
if (event.name === "apostrophe") return "'"
if (event.name === "backtick") return "`"
const text = vimEventText(event)
if (text && (text === "/" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text
if (text && (text === "/" || text === "?" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text
if (event.shift) {
if (event.name === "9") return "("
if (event.name === "0") return ")"
Expand Down Expand Up @@ -142,6 +144,16 @@ export function createVimHandler(input: {
copyMatchingBracket?: () => boolean
copyNextParagraph?: () => boolean
copyPreviousParagraph?: () => boolean
copySearchStart?: (direction: VimSearchDirection) => void
copySearchAppend?: (value: string) => boolean
copySearchBackspace?: () => boolean
copySearchSubmit?: () => boolean
copySearchCancel?: () => void
copySearchClear?: () => boolean
copySearchActive?: () => boolean
copySearchHighlighted?: () => boolean
copySearchNext?: () => boolean
copySearchPrevious?: () => boolean
copyText?: () => string
copyCol?: () => number
setCopyCol?: (offset: number) => void
Expand Down Expand Up @@ -1320,6 +1332,31 @@ export function createVimHandler(input: {
}

function copy(event: VimEvent, key: string): boolean {
if (input.copySearchActive?.()) {
if (key === "return") {
input.copySearchSubmit?.()
event.preventDefault()
return true
}
if (key === "escape") {
input.copySearchCancel?.()
event.preventDefault()
return true
}
if (key === "backspace" || key === "delete" || (key === "h" && event.ctrl && !event.meta && !event.super)) {
input.copySearchBackspace?.()
event.preventDefault()
return true
}
if (!hasModifier(event) && isPrintable(event)) {
input.copySearchAppend?.(value(event))
event.preventDefault()
return true
}
event.preventDefault()
return true
}

if (input.state.pending() === "" && isShifted(event, "y") && !hasModifier(event)) {
if (input.copyIsVisual?.()) {
input.copyYank?.()
Expand Down Expand Up @@ -1400,6 +1437,10 @@ export function createVimHandler(input: {
event.preventDefault()
return true
}
if (input.copySearchHighlighted?.() && input.copySearchClear?.()) {
event.preventDefault()
return true
}
input.state.setMode("normal")
event.preventDefault()
return true
Expand Down Expand Up @@ -1448,6 +1489,34 @@ export function createVimHandler(input: {
return false
}

if (pending === "" && key === "/") {
clearCopyPending()
input.copySearchStart?.("forward")
event.preventDefault()
return true
}

if (pending === "" && key === "?") {
clearCopyPending()
input.copySearchStart?.("backward")
event.preventDefault()
return true
}

if (pending === "" && key === "n" && !event.shift) {
clearCopyPending()
input.copySearchNext?.()
event.preventDefault()
return true
}

if (pending === "" && isShifted(event, "n")) {
clearCopyPending()
input.copySearchPrevious?.()
event.preventDefault()
return true
}

if (isShifted(event, "h")) {
clearCopyPending()
input.copyJump?.("high")
Expand Down Expand Up @@ -1760,7 +1829,7 @@ export function createVimHandler(input: {
}

if (input.state.isCopy()) {
const mapped = langmapped(event)
const mapped = input.copySearchActive?.() ? event : langmapped(event)
return copy(mapped, normalizedKeyName(mapped))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ export function useVimIndicator(input: {
active: Accessor<boolean>
state: ReturnType<typeof createVimState>
copyVisual?: Accessor<undefined | "char" | "line">
copySearch?: Accessor<string | undefined>
}) {
return createMemo(() => {
if (!input.enabled() || !input.active()) return
const key = input.state.pending()
if (key && key !== "w") return (input.state.pendingDisplay() || key) + ".."
if (input.state.isCopy()) {
const search = input.copySearch?.()
if (search !== undefined) return search
if (input.copyVisual?.() === "char") return "-- VISUAL --"
if (input.copyVisual?.() === "line") return "-- VISUAL LINE --"
return "-- COPY --"
Expand Down
Loading
Loading