diff --git a/README.md b/README.md
index 00e236e5280a..b20790341003 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,7 @@ Toggle via command palette > `Toggle minimal ui`.
| ------------------------------- | ---------------------------------------- |
| `prompt_max_height` | Set max prompt input height |
| `prompt_scrollbar` | Show the prompt scrollbar |
+| `vim_initial_mode` | Start in `insert` (default) or `normal` |
| `vim_enter_submit` | Submit with Enter from insert mode |
| `vim_insert_after_submit` | Return to insert mode after submit |
| `vim_system_clipboard_register` | Use the system clipboard as Vim register |
@@ -163,6 +164,16 @@ Toggle via command palette > `Toggle minimal ui`.
| `vim_showbreak` | Mark wrapped prompt rows |
| `vim_escape_sequence` | Use a two-key escape sequence like `jk` |
+### Initial mode
+
+Vim mode starts in insert mode by default. To start in normal mode instead:
+
+```json
+{
+ "vim_initial_mode": "normal"
+}
+```
+
### Mode-scoped keybinds
Bind existing commands only in normal mode by nesting overrides under `vim.normal`.
@@ -349,6 +360,6 @@ vim.g.opencode_opts = {
}
```
-## Contributors
+## Thanks to all contributors!
diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts
index df8ae05b2a8b..345fe3b7591c 100644
--- a/packages/opencode/src/config/tui-migrate.ts
+++ b/packages/opencode/src/config/tui-migrate.ts
@@ -13,6 +13,7 @@ const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const decodeTheme = Schema.decodeUnknownOption(Schema.String)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const decodeLangmap = Schema.decodeUnknownOption(TuiConfig.VimLangmap)
+const decodeInitialMode = Schema.decodeUnknownOption(TuiConfig.VimInitialMode)
const decodeLineMotions = Schema.decodeUnknownOption(TuiConfig.VimLineMotions)
const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed)
const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration)
@@ -77,6 +78,7 @@ function normalizeTui(data: Record):
diff_style: "auto" | "stacked" | undefined
vim_enter_submit: boolean | undefined
vim_insert_after_submit: boolean | undefined
+ vim_initial_mode: "normal" | "insert" | undefined
vim_system_clipboard_register: boolean | undefined
vim_modal_input: boolean | undefined
vim_langmap: Record | undefined
@@ -90,6 +92,7 @@ function normalizeTui(data: Record):
diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)),
vim_enter_submit: Option.getOrUndefined(decodeBoolean(data.vim_enter_submit)),
vim_insert_after_submit: Option.getOrUndefined(decodeBoolean(data.vim_insert_after_submit)),
+ vim_initial_mode: Option.getOrUndefined(decodeInitialMode(data.vim_initial_mode)),
vim_system_clipboard_register: Option.getOrUndefined(decodeBoolean(data.vim_system_clipboard_register)),
vim_modal_input: Option.getOrUndefined(decodeBoolean(data.vim_modal_input)),
vim_langmap: Option.getOrUndefined(decodeLangmap(data.vim_langmap)),
@@ -101,6 +104,7 @@ function normalizeTui(data: Record):
parsed.scroll_acceleration === undefined &&
parsed.vim_enter_submit === undefined &&
parsed.vim_insert_after_submit === undefined &&
+ parsed.vim_initial_mode === undefined &&
parsed.vim_system_clipboard_register === undefined &&
parsed.vim_modal_input === undefined &&
parsed.vim_langmap === undefined &&
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index e93c512b9af8..2e5bdc1cf35b 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -137,6 +137,7 @@ it.instance("loads tui config with the same precedence order as server config pa
diff_style: "stacked",
vim_enter_submit: true,
vim_insert_after_submit: true,
+ vim_initial_mode: "normal",
vim_modal_input: false,
},
null,
@@ -149,6 +150,7 @@ it.instance("loads tui config with the same precedence order as server config pa
expect(config.diff_style).toBe("stacked")
expect(config.vim_enter_submit).toBe(true)
expect(config.vim_insert_after_submit).toBe(true)
+ expect(config.vim_initial_mode).toBe("normal")
expect(config.vim_modal_input).toBe(false)
}),
),
@@ -216,6 +218,7 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no
scroll_speed: 5,
vim_enter_submit: true,
vim_insert_after_submit: true,
+ vim_initial_mode: "insert",
vim_modal_input: false,
vim_langmap: { д: "l" },
vim_line_motions: "display",
@@ -229,6 +232,7 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no
expect(config.scroll_speed).toBe(5)
expect(config.vim_enter_submit).toBe(true)
expect(config.vim_insert_after_submit).toBe(true)
+ expect(config.vim_initial_mode).toBe("insert")
expect(config.vim_modal_input).toBe(false)
expect(config.vim_langmap).toEqual({ д: "l" })
expect(config.vim_line_motions).toBe("display")
@@ -239,6 +243,7 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no
scroll_speed: 5,
vim_enter_submit: true,
vim_insert_after_submit: true,
+ vim_initial_mode: "insert",
vim_modal_input: false,
vim_langmap: { д: "l" },
vim_line_motions: "display",
@@ -264,6 +269,16 @@ it.instance("validates vim langmap entries are single characters", () =>
}),
)
+it.instance("validates vim initial modes", () =>
+ Effect.sync(() => {
+ const decode = Schema.decodeUnknownOption(TuiConfig.Info)
+
+ expect(Option.isSome(decode({ vim_initial_mode: "normal" }))).toBe(true)
+ expect(Option.isSome(decode({ vim_initial_mode: "insert" }))).toBe(true)
+ expect(Option.isNone(decode({ vim_initial_mode: "visual" }))).toBe(true)
+ }),
+)
+
it.instance("validates vim line motion modes", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownOption(TuiConfig.Info)
diff --git a/packages/tui/src/component/prompt/vim.ts b/packages/tui/src/component/prompt/vim.ts
index 8f172830d559..ab779222b9e8 100644
--- a/packages/tui/src/component/prompt/vim.ts
+++ b/packages/tui/src/component/prompt/vim.ts
@@ -14,7 +14,7 @@ import { clearSelection } from "../vim/vim-motions"
import { useVimIndicator } from "../vim/vim-indicator"
import type { CopyModeAdapter } from "../vim/copy-adapter"
-let lastVimMode: VimMode = "insert"
+let lastVimMode: VimMode | undefined
// Fork-owned prompt wiring kept out of the upstream prompt component.
export function usePromptVim(opts: {
@@ -43,7 +43,7 @@ export function usePromptVim(opts: {
const vimState = createVimState({
enabled: vimEnabled,
- initial: () => lastVimMode,
+ initial: () => lastVimMode ?? cfg.vim_initial_mode ?? "insert",
})
let popCopyMode: (() => void) | undefined
onCleanup(() => {
diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx
index bf56afe4bb2f..7e35cabf6233 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -39,6 +39,11 @@ export const VimLangmap = Schema.Record(VimLangmapCharacter, VimLangmapCharacter
description: "Map keyboard-layout characters to Vim command keys in normal, visual, and copy modes",
})
+export const VimInitialMode = Schema.Literals(["normal", "insert"]).annotate({
+ description: "Initial Vim mode for the prompt",
+})
+export type VimInitialMode = Schema.Schema.Type
+
export const VimLineMotions = Schema.Literals(["logical", "display_vertical", "display"]).annotate({
description: "Use logical lines, display lines for j/k only, or display lines for j/k and 0/^/$ Vim motions",
})
@@ -95,6 +100,7 @@ export const Info = Schema.Struct({
vim_insert_after_submit: Schema.optional(Schema.Boolean).annotate({
description: "Return prompt to Vim insert mode after submitting",
}),
+ vim_initial_mode: Schema.optional(VimInitialMode),
vim_system_clipboard_register: Schema.optional(Schema.Boolean).annotate({
description: "Use the system clipboard instead of Vim's internal register for yank and paste",
}),