Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
25b3ffe
feat(tui): add optional vim prompt mode foundation
leohenon Feb 7, 2026
26de44e
feat(vim): add cursor motion helpers and h/j/k/l
leohenon Feb 7, 2026
e68932c
feat(vim): add word motions (w/b/e, W/B/E)
leohenon Feb 7, 2026
484f6d0
feat(vim): add insert transitions (a/A/i/I/o/O)
leohenon Feb 7, 2026
f7dfb3f
feat(vim): add x delete in normal mode
leohenon Feb 7, 2026
aefb00c
feat(vim): add delete motions dd and dw
leohenon Feb 7, 2026
c0d997c
feat(vim): add ctrl scroll mappings for session panels
leohenon Feb 7, 2026
8d139db
feat(vim): add g/G session jump motions
leohenon Feb 7, 2026
a1a6138
fix(vim): prioritize esc to exit insert mode over interrupt
leohenon Feb 7, 2026
799f310
feat(vim): add mode indicator
leohenon Feb 7, 2026
c248f2d
feat(vim): add S (substitute line) command
leohenon Feb 8, 2026
d6517fb
fix(vim): reset mode and pending after submit
leohenon Feb 8, 2026
b1b33d5
fix(vim): remove unused active state and correct exit comment
leohenon Feb 8, 2026
e8ca03e
fix(vim): correct wordEnd motion edge cases
leohenon Feb 8, 2026
317977b
refactor(vim): extract vim scroll override check to avoid empty if block
leohenon Feb 8, 2026
9084f4f
feat(vim): add 0 ^ $ line motions
leohenon Feb 8, 2026
7b217ab
feat(vim): add cc and cw change commands
leohenon Feb 8, 2026
ddb0bdd
docs(tui): document vim mode
leohenon Feb 8, 2026
d35f241
fix(tui): preserve vim mode across submit and shell exits
leohenon Feb 10, 2026
2712737
chore(tui): bracket vim indicator
leohenon Feb 10, 2026
bfbd78e
fix(tui): preserve vim mode after permission/question prompt submissions
leohenon Feb 11, 2026
0f043cd
fix(tui): preserve vim mode after first new-chat prompt remount
leohenon Feb 11, 2026
7e38af2
fix(vim): handle leading/trailing empty lines for j/k line navigation
leohenon Feb 13, 2026
f3e5840
fix(vim): block / and @ in normal mode unless autocomplete triggers
leohenon Feb 16, 2026
97db8f9
fix(vim): read config from TuiConfig instead of deprecated sync.data.…
leohenon Feb 26, 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 @@ -40,6 +40,7 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { useVimEnabled } from "./component/vim"

async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
Expand Down Expand Up @@ -215,6 +216,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 @@ -638,6 +640,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 @@ -34,6 +34,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 @@ -57,6 +62,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 @@ -77,6 +83,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 @@ -113,6 +120,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 @@ -136,6 +156,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 @@ -184,7 +235,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 @@ -220,9 +270,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 @@ -389,9 +446,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 @@ -635,6 +707,7 @@ export function Prompt(props: PromptProps) {
})
.catch(() => {})
}
vimState.clearPending()
history.append({
...store.prompt,
mode: currentMode,
Expand Down Expand Up @@ -863,10 +936,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 @@ -880,11 +954,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 @@ -910,7 +987,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 @@ -1044,6 +1121,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
14 changes: 14 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,14 @@
import { createMemo } from "solid-js"
import { useKV } from "../../context/kv"
import { useTuiConfig } from "../../context/tui-config"

export function useVimEnabled() {
const kv = useKV()
const config = useTuiConfig()

return createMemo(() => {
const stored = kv.get("input_vim_mode")
if (stored !== undefined) return stored
return config.vim ?? false
})
}
Loading
Loading