feat(tui): add configurable Vim keymap and startup default

Expose Vim bindings through the TUI keymap config and add a startup
toggle so Vim mode can be enabled by default.

Wire runtime keymap handling for Vim actions, add
`tui.vim_mode_default` to config parsing/runtime config, apply the
default in ChatWidget startup paths, and update docs/default
keymap/schema accordingly.
This commit is contained in:
Felipe Coury
2026-04-23 10:35:17 -03:00
committed by Felipe Coury
parent d49e77a7c2
commit cb3e5baa73
17 changed files with 1450 additions and 243 deletions
+224 -3
View File
@@ -2236,7 +2236,8 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_shortcuts": null
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
"list": {
"accept": null,
@@ -2270,7 +2271,45 @@
"scroll_down": null,
"scroll_up": null
},
"preset": "latest"
"preset": "latest",
"vim_normal": {
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
"move_line_start": null,
"move_right": null,
"move_up": null,
"move_word_backward": null,
"move_word_end": null,
"move_word_forward": null,
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
},
"vim_operator": {
"cancel": null,
"delete_line": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
"motion_right": null,
"motion_up": null,
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"yank_line": null
}
},
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
},
@@ -2335,6 +2374,11 @@
"default": null,
"description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
"type": "string"
},
"vim_mode_default": {
"default": false,
"description": "Start the composer in Vim mode (`Normal`) by default. Defaults to `false`.",
"type": "boolean"
}
},
"type": "object"
@@ -2642,6 +2686,14 @@
}
],
"description": "Toggle the composer shortcut overlay."
},
"toggle_vim_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle Vim mode for the composer input."
}
},
"type": "object"
@@ -2726,7 +2778,8 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_shortcuts": null
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
},
"list": {
@@ -2789,6 +2842,58 @@
}
],
"default": "latest"
},
"vim_normal": {
"allOf": [
{
"$ref": "#/definitions/TuiVimNormalKeymap"
}
],
"default": {
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
"move_line_start": null,
"move_right": null,
"move_up": null,
"move_word_backward": null,
"move_word_end": null,
"move_word_forward": null,
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
}
},
"vim_operator": {
"allOf": [
{
"$ref": "#/definitions/TuiVimOperatorKeymap"
}
],
"default": {
"cancel": null,
"delete_line": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
"motion_right": null,
"motion_up": null,
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"yank_line": null
}
}
},
"type": "object"
@@ -3041,6 +3146,122 @@
},
"type": "object"
},
"TuiVimNormalKeymap": {
"additionalProperties": false,
"description": "Vim normal-mode keybindings for modal editing inside text areas.",
"properties": {
"append_after_cursor": {
"$ref": "#/definitions/KeybindingsSpec"
},
"append_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"cancel_operator": {
"$ref": "#/definitions/KeybindingsSpec"
},
"delete_char": {
"$ref": "#/definitions/KeybindingsSpec"
},
"delete_to_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"enter_insert": {
"$ref": "#/definitions/KeybindingsSpec"
},
"insert_line_start": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_down": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_left": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_line_start": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_right": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_up": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_word_backward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_word_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_word_forward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"open_line_above": {
"$ref": "#/definitions/KeybindingsSpec"
},
"open_line_below": {
"$ref": "#/definitions/KeybindingsSpec"
},
"paste_after": {
"$ref": "#/definitions/KeybindingsSpec"
},
"start_delete_operator": {
"$ref": "#/definitions/KeybindingsSpec"
},
"start_yank_operator": {
"$ref": "#/definitions/KeybindingsSpec"
},
"yank_line": {
"$ref": "#/definitions/KeybindingsSpec"
}
},
"type": "object"
},
"TuiVimOperatorKeymap": {
"additionalProperties": false,
"description": "Vim operator-pending keybindings for modal editing inside text areas.",
"properties": {
"cancel": {
"$ref": "#/definitions/KeybindingsSpec"
},
"delete_line": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_down": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_left": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_line_start": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_right": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_up": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_word_backward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_word_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_word_forward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"yank_line": {
"$ref": "#/definitions/KeybindingsSpec"
}
},
"type": "object"
},
"UriBasedFileOpener": {
"oneOf": [
{
+34
View File
@@ -543,6 +543,35 @@ async fn runtime_config_defaults_model_availability_nux() {
);
}
#[test]
fn test_tui_vim_mode_default_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_vim_mode_default_true() {
let toml = r#"
[tui]
vim_mode_default = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize vim_mode_default=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
@@ -1425,6 +1454,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
terminal_title: None,
@@ -5269,6 +5299,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
@@ -5465,6 +5496,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
@@ -5615,6 +5647,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(false),
feedback_enabled: true,
@@ -5750,6 +5783,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
+8
View File
@@ -356,6 +356,9 @@ pub struct Config {
/// Persisted startup availability NUX state for model tooltips.
pub model_availability_nux: ModelAvailabilityNuxConfig,
/// Start the composer in Vim mode (`Normal`) by default.
pub tui_vim_mode_default: bool,
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -2470,6 +2473,11 @@ impl Config {
.as_ref()
.map(|t| t.model_availability_nux.clone())
.unwrap_or_default(),
tui_vim_mode_default: cfg
.tui
.as_ref()
.map(|t| t.vim_mode_default)
.unwrap_or(false),
tui_alternate_screen: cfg
.tui
.as_ref()