Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0796645
feat(tui): add optional vim prompt mode foundation
leohenon Feb 7, 2026
a4eb8a8
feat(vim): add cursor motion helpers and h/j/k/l
leohenon Feb 7, 2026
244ddd5
feat(vim): add word motions (w/b/e, W/B/E)
leohenon Feb 7, 2026
06f8655
feat(vim): add insert transitions (a/A/i/I/o/O)
leohenon Feb 7, 2026
6fc92e4
feat(vim): add x delete in normal mode
leohenon Feb 7, 2026
c2afcaa
feat(vim): add delete motions dd and dw
leohenon Feb 7, 2026
a3d18f5
feat(vim): add ctrl scroll mappings for session panels
leohenon Feb 7, 2026
c57502e
feat(vim): add g/G session jump motions
leohenon Feb 7, 2026
0349012
fix(vim): prioritize esc to exit insert mode over interrupt
leohenon Feb 7, 2026
e53e2ec
feat(vim): add mode indicator
leohenon Feb 7, 2026
911af5a
feat(vim): add S (substitute line) command
leohenon Feb 8, 2026
e7c8c3b
fix(vim): reset mode and pending after submit
leohenon Feb 8, 2026
f62a4b8
fix(vim): remove unused active state and correct exit comment
leohenon Feb 8, 2026
2e02907
fix(vim): correct wordEnd motion edge cases
leohenon Feb 8, 2026
84e8fe5
refactor(vim): extract vim scroll override check to avoid empty if block
leohenon Feb 8, 2026
87b5dc3
feat(vim): add 0 ^ $ line motions
leohenon Feb 8, 2026
e824833
feat(vim): add cc and cw change commands
leohenon Feb 8, 2026
faa9133
docs(tui): document vim mode
leohenon Feb 8, 2026
8d0d81c
fix(tui): preserve vim mode across submit and shell exits
leohenon Feb 10, 2026
e017ec7
chore(tui): bracket vim indicator
leohenon Feb 10, 2026
adaddd8
fix(tui): preserve vim mode after permission/question prompt submissions
leohenon Feb 11, 2026
054eabc
fix(tui): preserve vim mode after first new-chat prompt remount
leohenon Feb 11, 2026
6b77a31
fix(vim): handle leading/trailing empty lines for j/k line navigation
leohenon Feb 13, 2026
25a54ed
fix(vim): block / and @ in normal mode unless autocomplete triggers
leohenon Feb 16, 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 @@ -38,6 +38,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 @@ -210,6 +211,7 @@ function App() {
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const vim = useVimEnabled()

useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
Expand Down Expand Up @@ -633,6 +635,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
94 changes: 89 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, type VimMode } 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 All @@ -55,6 +60,7 @@ export type PromptRef = {

const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
let lastVimMode: VimMode = "insert"

export function Prompt(props: PromptProps) {
let input: TextareaRenderable
Expand All @@ -75,6 +81,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 @@ -111,6 +118,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 @@ -134,6 +154,37 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: new Map(),
interrupt: 0,
})
const vimState = createVimState({
enabled: vimEnabled,
initial: () => lastVimMode,
})
onCleanup(() => {
if (vimEnabled()) lastVimMode = vimState.mode()
})
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")
},
autocomplete: () => autocomplete.visible,
})

createEffect(
on(
Expand Down Expand Up @@ -182,7 +233,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 @@ -218,9 +268,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.clearPending()
return
}
if (!props.sessionID) return
Expand Down Expand Up @@ -387,9 +444,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.clearPending()
}
})

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 @@ -633,6 +705,7 @@ export function Prompt(props: PromptProps) {
})
.catch(() => {})
}
vimState.clearPending()
history.append({
...store.prompt,
mode: currentMode,
Expand Down Expand Up @@ -861,10 +934,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 @@ -878,11 +952,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.clearPending()
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 @@ -908,7 +985,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 @@ -1042,6 +1119,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