Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7434687
feat(tui): add optional vim prompt mode foundation
leohenon Feb 7, 2026
31b437d
feat(vim): add cursor motion helpers and h/j/k/l
leohenon Feb 7, 2026
a199e1e
feat(vim): add word motions (w/b/e, W/B/E)
leohenon Feb 7, 2026
e040c9e
feat(vim): add insert transitions (a/A/i/I/o/O)
leohenon Feb 7, 2026
52496b7
feat(vim): add x delete in normal mode
leohenon Feb 7, 2026
7ff3a1e
feat(vim): add delete motions dd and dw
leohenon Feb 7, 2026
ab6f0bf
feat(vim): add ctrl scroll mappings for session panels
leohenon Feb 7, 2026
407a893
feat(vim): add g/G session jump motions
leohenon Feb 7, 2026
7eafc8d
fix(vim): prioritize esc to exit insert mode over interrupt
leohenon Feb 7, 2026
3809e69
feat(vim): add mode indicator
leohenon Feb 7, 2026
37c01ed
feat(vim): add S (substitute line) command
leohenon Feb 8, 2026
fbd2267
fix(vim): reset mode and pending after submit
leohenon Feb 8, 2026
6cfc9bd
fix(vim): remove unused active state and correct exit comment
leohenon Feb 8, 2026
be3aa6d
fix(vim): correct wordEnd motion edge cases
leohenon Feb 8, 2026
3c2332e
refactor(vim): extract vim scroll override check to avoid empty if block
leohenon Feb 8, 2026
eb96c86
feat(vim): add 0 ^ $ line motions
leohenon Feb 8, 2026
7edab55
feat(vim): add cc and cw change commands
leohenon Feb 8, 2026
87b4487
docs(tui): document vim mode
leohenon Feb 8, 2026
57f6973
Merge branch 'dev' into feat/vim-prompt-input-core
leohenon Feb 8, 2026
cd3562b
Merge branch 'dev' into feat/vim-prompt-input-core
leohenon Feb 8, 2026
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
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { useVimEnabled } from "./component/vim"

async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
Expand Down Expand Up @@ -198,6 +199,7 @@ function App() {
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const vim = useVimEnabled()

// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
Expand Down Expand Up @@ -591,6 +593,15 @@ function App() {
dialog.clear()
},
},
{
title: vim() ? "Disable vim input" : "Enable vim input",
value: "input.vim.toggle",
category: "Settings",
onSelect: (dialog) => {
kv.set("input_vim_mode", !vim())
dialog.clear()
},
},
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
Expand Down
87 changes: 82 additions & 5 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { useVimEnabled } from "../vim"
import { createVimState } from "../vim/vim-state"
import { createVimHandler } from "../vim/vim-handler"
import { vimScroll } from "../vim/vim-scroll"
import { useVimIndicator } from "../vim/vim-indicator"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -74,6 +79,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const vimEnabled = useVimEnabled()

function promptModelWarning() {
toast.show({
Expand Down Expand Up @@ -110,6 +116,19 @@ export function Prompt(props: PromptProps) {
if (!props.disabled) input.cursorColor = theme.text
})

createEffect(() => {
if (!input || input.isDestroyed) return
if (vimEnabled() && store.mode === "normal") {
if (vimState.isInsert()) {
input.cursorStyle = { style: "line", blinking: true }
return
}
input.cursorStyle = { style: "block", blinking: false }
return
}
input.cursorStyle = { style: "block", blinking: true }
})

const lastUserMessage = createMemo(() => {
if (!props.sessionID) return undefined
const messages = sync.data.message[props.sessionID]
Expand All @@ -133,6 +152,32 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: new Map(),
interrupt: 0,
})
const vimState = createVimState({
enabled: vimEnabled,
})
const vimIndicator = useVimIndicator({
enabled: vimEnabled,
active: () => store.mode === "normal",
state: vimState,
})
const vim = createVimHandler({
enabled: vimEnabled,
state: vimState,
textarea: () => input,
submit,
scroll(action) {
if (action === "line-down") command.trigger("session.line.down")
if (action === "line-up") command.trigger("session.line.up")
if (action === "half-down") command.trigger("session.half.page.down")
if (action === "half-up") command.trigger("session.half.page.up")
if (action === "page-down") command.trigger("session.page.down")
if (action === "page-up") command.trigger("session.page.up")
},
jump(action) {
if (action === "top") command.trigger("session.first")
if (action === "bottom") command.trigger("session.last")
},
})

// Initialize agent/model/variant from last user message when session changes
let syncedSessionID: string | undefined
Expand Down Expand Up @@ -171,7 +216,6 @@ export function Prompt(props: PromptProps) {
{
title: "Submit prompt",
value: "prompt.submit",
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
Expand Down Expand Up @@ -207,9 +251,16 @@ export function Prompt(props: PromptProps) {
onSelect: (dialog) => {
if (autocomplete.visible) return
if (!input.focused) return
if (vimEnabled() && store.mode === "normal" && vimState.isInsert()) {
vimState.setMode("normal")
setStore("interrupt", 0)
dialog.clear()
return
}
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
vimState.reset()
return
}
if (!props.sessionID) return
Expand Down Expand Up @@ -376,9 +427,24 @@ export function Prompt(props: PromptProps) {

createEffect(() => {
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
if (props.visible === false) {
input?.blur()
vimState.reset()
}
})

function submitFromTextarea() {
if (store.mode !== "normal") {
submit()
return
}
if (vimEnabled() && vimState.isInsert()) {
input.insertText("\n")
return
}
submit()
}

function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
Expand Down Expand Up @@ -841,10 +907,11 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("app_exit", e)) {
const isVimScrollOverride =
vimEnabled() && store.mode === "normal" && vimState.mode() === "normal" && !!vimScroll(e)
if (!isVimScrollOverride && keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault()
return
}
Expand All @@ -857,11 +924,14 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") {
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
setStore("mode", "normal")
vimState.reset()
e.preventDefault()
return
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (e.defaultPrevented) return
if (store.mode === "normal" && vim.handleKey(e)) return
if (!autocomplete.visible) {
if (
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
Expand All @@ -887,7 +957,7 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = input.plainText.length
}
}}
onSubmit={submit}
onSubmit={submitFromTextarea}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
Expand Down Expand Up @@ -1021,6 +1091,13 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Show when={vimIndicator()}>
{(indicator) => (
<text fg={indicator() === "INSERT" ? local.agent.color(local.agent.current().name) : theme.textMuted}>
{indicator()}
</text>
)}
</Show>
<Show when={status().type !== "idle"} fallback={<text />}>
<box
flexDirection="row"
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/vim/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createMemo } from "solid-js"
import { useKV } from "../../context/kv"
import { useSync } from "../../context/sync"

export function useVimEnabled() {
const kv = useKV()
const sync = useSync()

return createMemo(() => {
const stored = kv.get("input_vim_mode")
if (stored !== undefined) return stored
const tui = sync.data.config.tui as { vim?: boolean } | undefined
return tui?.vim ?? false
})
}
Loading
Loading