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", }),