feat(tui): add vim composer mode (#18595)

## Why

Codex now has configurable TUI keymaps, but the composer still behaves
like a plain text field. Users who prefer modal editing need a way to
keep Vim muscle memory while drafting prompts, and the keymap picker
needs to expose Vim-specific actions if those bindings are configurable
instead of hardcoded.

## What Changed

- Adds composer Vim mode with insert/normal state, common normal-mode
movement and editing commands, `d`/`y` operator-pending flows, and
mode-aware footer and cursor indicators.
- Adds `/vim`, an optional global `toggle_vim_mode` binding, and
`tui.vim_mode_default` so Vim mode can be toggled per session or enabled
as the default composer state.
- Extends runtime and config keymaps with `vim_normal` and
`vim_operator` contexts, exposes those contexts in `/keymap`, refreshes
the config schema, and validates Vim bindings separately.
- Integrates Vim normal mode with existing composer behavior: `/` opens
slash command entry, `!` enters shell mode, `j`/`k` navigate history at
history boundaries, successful submissions reset back to normal mode,
and paste burst handling remains insert-mode only.
- Teaches the TUI render path to apply and restore cursor style so Vim
insert mode can use a bar cursor without leaving the terminal in that
state after exit.

## Validation

- `cargo test -p codex-tui keymap -- --nocapture` on the keymap/Vim
coverage
- `cargo insta pending-snapshots`

## Docs

This introduces user-facing `/vim`, `tui.vim_mode_default`, and Vim
keymap contexts under `tui.keymap`, so the public CLI configuration and
slash-command docs should be updated before the feature ships.
This commit is contained in:
Felipe Coury authored and GitHub committed 2026-04-30 17:20:51 -07:00
1 parent a5ebedef67
commit b6f81257f8
33 files changed
+3328 -214

No files matched your search

+95
View File
@@ -102,6 +102,8 @@ pub struct TuiGlobalKeymap {
pub queue: Option<KeybindingsSpec>,
/// Toggle the composer shortcut overlay.
pub toggle_shortcuts: Option<KeybindingsSpec>,
/// Toggle Vim mode for the composer input.
pub toggle_vim_mode: Option<KeybindingsSpec>,
}
/// Chat context keybindings.
@@ -173,6 +175,95 @@ pub struct TuiEditorKeymap {
pub yank: Option<KeybindingsSpec>,
}
/// Vim normal-mode keybindings for modal editing inside text areas.
///
/// Actions that use uppercase letters (like `A` for append-line-end) should
/// be specified as `shift-a` in config; the runtime matcher handles
/// cross-terminal shift-reporting differences automatically.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimNormalKeymap {
/// Enter insert mode at cursor (`i`).
pub enter_insert: Option<KeybindingsSpec>,
/// Enter insert mode after cursor (`a`).
pub append_after_cursor: Option<KeybindingsSpec>,
/// Enter insert mode at end of line (`A`).
pub append_line_end: Option<KeybindingsSpec>,
/// Enter insert mode at first non-blank of line (`I`).
pub insert_line_start: Option<KeybindingsSpec>,
/// Open a new line below and enter insert mode (`o`).
pub open_line_below: Option<KeybindingsSpec>,
/// Open a new line above and enter insert mode (`O`).
pub open_line_above: Option<KeybindingsSpec>,
/// Move cursor left (`h`).
pub move_left: Option<KeybindingsSpec>,
/// Move cursor right (`l`).
pub move_right: Option<KeybindingsSpec>,
/// Move cursor up (`k`), or recall older composer history at history boundaries.
pub move_up: Option<KeybindingsSpec>,
/// Move cursor down (`j`), or recall newer composer history at history boundaries.
pub move_down: Option<KeybindingsSpec>,
/// Move cursor to start of next word (`w`).
pub move_word_forward: Option<KeybindingsSpec>,
/// Move cursor to start of previous word (`b`).
pub move_word_backward: Option<KeybindingsSpec>,
/// Move cursor to end of current/next word (`e`).
pub move_word_end: Option<KeybindingsSpec>,
/// Move cursor to start of line (`0`).
pub move_line_start: Option<KeybindingsSpec>,
/// Move cursor to end of line (`$`).
pub move_line_end: Option<KeybindingsSpec>,
/// Delete character under cursor (`x`).
pub delete_char: Option<KeybindingsSpec>,
/// Delete from cursor to end of line (`D`).
pub delete_to_line_end: Option<KeybindingsSpec>,
/// Yank the entire line (`Y`).
pub yank_line: Option<KeybindingsSpec>,
/// Paste after cursor (`p`).
pub paste_after: Option<KeybindingsSpec>,
/// Begin delete operator; next key selects motion (`d`).
pub start_delete_operator: Option<KeybindingsSpec>,
/// Begin yank operator; next key selects motion (`y`).
pub start_yank_operator: Option<KeybindingsSpec>,
/// Cancel a pending operator and return to normal mode.
pub cancel_operator: Option<KeybindingsSpec>,
}
/// Vim operator-pending keybindings for modal editing inside text areas.
///
/// This context is active only while waiting for a motion after `d` or `y`.
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
/// `Esc` cancels the pending operator and returns to normal mode without
/// modifying text.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimOperatorKeymap {
/// Repeat delete operator to delete the whole line (`dd`).
pub delete_line: Option<KeybindingsSpec>,
/// Repeat yank operator to yank the whole line (`yy`).
pub yank_line: Option<KeybindingsSpec>,
/// Motion: left (`h`).
pub motion_left: Option<KeybindingsSpec>,
/// Motion: right (`l`).
pub motion_right: Option<KeybindingsSpec>,
/// Motion: up one line (`k`).
pub motion_up: Option<KeybindingsSpec>,
/// Motion: down one line (`j`).
pub motion_down: Option<KeybindingsSpec>,
/// Motion: to start of next word (`w`).
pub motion_word_forward: Option<KeybindingsSpec>,
/// Motion: to start of previous word (`b`).
pub motion_word_backward: Option<KeybindingsSpec>,
/// Motion: to end of current/next word (`e`).
pub motion_word_end: Option<KeybindingsSpec>,
/// Motion: to start of line (`0`).
pub motion_line_start: Option<KeybindingsSpec>,
/// Motion: to end of line (`$`).
pub motion_line_end: Option<KeybindingsSpec>,
/// Cancel the pending operator and return to normal mode.
pub cancel: Option<KeybindingsSpec>,
}
/// Pager context keybindings for transcript and static overlays.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -261,6 +352,10 @@ pub struct TuiKeymap {
#[serde(default)]
pub editor: TuiEditorKeymap,
#[serde(default)]
pub vim_normal: TuiVimNormalKeymap,
#[serde(default)]
pub vim_operator: TuiVimOperatorKeymap,
#[serde(default)]
pub pager: TuiPagerKeymap,
#[serde(default)]
pub list: TuiListKeymap,
+7
View File
@@ -38,6 +38,8 @@ pub use crate::tui_keymap::TuiGlobalKeymap;
pub use crate::tui_keymap::TuiKeymap;
pub use crate::tui_keymap::TuiListKeymap;
pub use crate::tui_keymap::TuiPagerKeymap;
pub use crate::tui_keymap::TuiVimNormalKeymap;
pub use crate::tui_keymap::TuiVimOperatorKeymap;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2;
@@ -609,6 +611,11 @@ pub struct Tui {
#[serde(default = "default_true")]
pub show_tooltips: bool,
/// Start the composer in Vim mode (`Normal`) by default.
/// Defaults to `false`.
#[serde(default)]
pub vim_mode_default: bool,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.