From cb3e5baa730a955a26458bb401b6effd3e0cd888 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 21 Feb 2026 16:00:48 -0300 Subject: [PATCH] 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. --- codex-rs/config/src/tui_keymap.rs | 52 ++ codex-rs/config/src/types.rs | 7 + codex-rs/core/config.schema.json | 227 ++++++++- codex-rs/core/src/config/config_tests.rs | 34 ++ codex-rs/core/src/config/mod.rs | 8 + codex-rs/tui/src/app/input.rs | 5 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 67 ++- codex-rs/tui/src/bottom_pane/mod.rs | 14 + codex-rs/tui/src/bottom_pane/textarea.rs | 387 ++++++-------- codex-rs/tui/src/chatwidget.rs | 476 ++++++++++++++++++ .../tui/src/chatwidget/tests/plan_mode.rs | 42 +- codex-rs/tui/src/keymap.rs | 264 ++++++++++ docs/config.md | 24 +- docs/default-keymap.toml | 43 ++ docs/example-config.md | 3 + docs/keymap-action-matrix.md | 27 + docs/tui-keymap.md | 13 +- 17 files changed, 1450 insertions(+), 243 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 169bf3f1c1..af8cd388b7 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -108,6 +108,8 @@ pub struct TuiGlobalKeymap { pub queue: Option, /// Toggle the composer shortcut overlay. pub toggle_shortcuts: Option, + /// Toggle Vim mode for the composer input. + pub toggle_vim_mode: Option, } /// Chat context keybindings. These override corresponding `global` actions. @@ -170,6 +172,52 @@ pub struct TuiEditorKeymap { pub yank: Option, } +/// Vim normal-mode keybindings for modal editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimNormalKeymap { + pub enter_insert: Option, + pub append_after_cursor: Option, + pub append_line_end: Option, + pub insert_line_start: Option, + pub open_line_below: Option, + pub open_line_above: Option, + pub move_left: Option, + pub move_right: Option, + pub move_up: Option, + pub move_down: Option, + pub move_word_forward: Option, + pub move_word_backward: Option, + pub move_word_end: Option, + pub move_line_start: Option, + pub move_line_end: Option, + pub delete_char: Option, + pub delete_to_line_end: Option, + pub yank_line: Option, + pub paste_after: Option, + pub start_delete_operator: Option, + pub start_yank_operator: Option, + pub cancel_operator: Option, +} + +/// Vim operator-pending keybindings for modal editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimOperatorKeymap { + pub delete_line: Option, + pub yank_line: Option, + pub motion_left: Option, + pub motion_right: Option, + pub motion_up: Option, + pub motion_down: Option, + pub motion_word_forward: Option, + pub motion_word_backward: Option, + pub motion_word_end: Option, + pub motion_line_start: Option, + pub motion_line_end: Option, + pub cancel: Option, +} + /// Pager context keybindings for transcript and static overlays. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -278,6 +326,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, diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 7a1e039afa..350050e0df 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -38,6 +38,8 @@ pub use crate::tui_keymap::TuiKeymapPreset; pub use crate::tui_keymap::TuiListKeymap; pub use crate::tui_keymap::TuiOnboardingKeymap; 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 = 16; @@ -562,6 +564,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. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7ee6d7b03f..12dd176122 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 41ebd64d08..e6f9f22e46 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d50396074d..c1647a8e45 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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() diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index a7015c4cf5..7c26f920eb 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -148,6 +148,11 @@ impl App { return; } + if self.keymap.app.toggle_vim_mode.is_pressed(key_event) { + self.chat_widget.toggle_vim_mode_and_notify(); + return; + } + if self.keymap.chat.edit_previous_message.is_pressed(key_event) { // Esc primes/advances backtracking only in normal (not working) mode // with the composer focused and empty. In any other state, forward diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e389aa420d..6a21bc1d35 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -635,7 +635,7 @@ impl ChatComposer { self.queue_keys = keymap.composer.queue.clone(); self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone(); self.editor_keymap = keymap.editor.clone(); - self.textarea.set_keymap_bindings(&self.editor_keymap); + self.textarea.set_keymap_bindings(keymap); self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); self.footer_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message); self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); @@ -956,6 +956,65 @@ impl ChatComposer { self.sync_popups(); } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.textarea.set_vim_enabled(enabled); + self.paste_burst.clear_after_explicit_paste(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = !self.textarea.is_vim_enabled(); + self.set_vim_enabled(enabled); + enabled + } + + pub(crate) fn is_vim_insert(&self) -> bool { + self.textarea.is_vim_insert() + } + + #[cfg(test)] + pub(crate) fn is_vim_enabled(&self) -> bool { + self.textarea.is_vim_enabled() + } + + fn vim_mode_indicator_span(&self) -> Option> { + self.textarea.vim_mode_label().map(|label| match label { + "Normal" => "Vim: Normal".magenta(), + "Insert" => "Vim: Insert".green(), + _ => unreachable!(), + }) + } + + fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { + let mut spans: Vec> = Vec::new(); + if let Some(vim_mode) = self.vim_mode_indicator_span() { + spans.push(vim_mode); + } + if let Some(collab) = + collaboration_mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint) + { + if !spans.is_empty() { + spans.push(" | ".dim()); + } + spans.extend(collab.spans); + } + if spans.is_empty() { + None + } else { + Some(Line::from(spans)) + } + } + + fn right_footer_line_with_context(&self) -> Line<'static> { + let mut line = + context_window_line(self.context_window_percent, self.context_window_used_tokens); + if let Some(vim_mode) = self.vim_mode_indicator_span() { + line.spans.push(" | ".dim()); + line.spans.push(vim_mode); + } + line + } + pub(crate) fn current_text_with_pending(&self) -> String { let mut text = self.current_text(); for (placeholder, actual) in &self.pending_pastes { @@ -1641,7 +1700,7 @@ impl ChatComposer { if self.disable_paste_burst { // When burst detection is disabled, treat IME/non-ASCII input as normal typing. // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input_with_keymap(input, &self.editor_keymap); + self.textarea.input(input); let text_after = self.textarea.text(); self.pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); @@ -1698,7 +1757,7 @@ impl ChatComposer { if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.textarea.input_with_keymap(input, &self.editor_keymap); + self.textarea.input(input); let text_after = self.textarea.text(); self.pending_pastes @@ -3114,7 +3173,7 @@ impl ChatComposer { return (InputResult::None, true); } - self.textarea.input_with_keymap(input, &self.editor_keymap); + self.textarea.input(input); self.sync_bash_mode_from_text(); if let Some(elements_before) = elements_before { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index cb2e7792fe..c3979b0bf8 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -380,6 +380,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.composer.set_vim_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -947,6 +952,15 @@ impl BottomPane { self.composer.is_empty() } + pub(crate) fn composer_is_vim_insert(&self) -> bool { + self.composer.is_vim_insert() + } + + #[cfg(test)] + pub(crate) fn composer_is_vim_enabled(&self) -> bool { + self.composer.is_vim_enabled() + } + pub(crate) fn is_task_running(&self) -> bool { self.is_task_running } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index b500e35050..4739095366 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -14,6 +14,8 @@ use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_altgr; use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; +use crate::keymap::VimNormalKeymap; +use crate::keymap::VimOperatorKeymap; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; @@ -99,6 +101,8 @@ pub(crate) struct TextArea { vim_mode: VimMode, vim_operator: Option, editor_keymap: EditorKeymap, + vim_normal_keymap: VimNormalKeymap, + vim_operator_keymap: VimOperatorKeymap, } #[derive(Debug, Clone)] @@ -140,6 +144,7 @@ enum VimMotion { impl TextArea { pub fn new() -> Self { + let defaults = RuntimeKeymap::defaults(); Self { text: String::new(), cursor_pos: 0, @@ -151,12 +156,16 @@ impl TextArea { vim_enabled: false, vim_mode: VimMode::Insert, vim_operator: None, - editor_keymap: RuntimeKeymap::defaults().editor, + editor_keymap: defaults.editor, + vim_normal_keymap: defaults.vim_normal, + vim_operator_keymap: defaults.vim_operator, } } - pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) { - self.editor_keymap = keymap.clone(); + pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.editor_keymap = keymap.editor.clone(); + self.vim_normal_keymap = keymap.vim_normal.clone(); + self.vim_operator_keymap = keymap.vim_operator.clone(); } /// Replace the visible textarea text and clear any existing text elements. @@ -576,235 +585,167 @@ impl TextArea { return; } - match event { - KeyEvent { - code: KeyCode::Char('i'), - modifiers: KeyModifiers::NONE, - .. - } => { - self.vim_mode = VimMode::Insert; - } - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::NONE, - .. - } => { - let next = self.next_atomic_boundary(self.cursor_pos); - self.set_cursor(next); - self.vim_mode = VimMode::Insert; - } - KeyEvent { - code: KeyCode::Char('A'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - self.set_cursor(self.end_of_current_line()); - self.vim_mode = VimMode::Insert; - } - KeyEvent { - code: KeyCode::Char('I'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - self.set_cursor(self.beginning_of_current_line()); - self.vim_mode = VimMode::Insert; - } - KeyEvent { - code: KeyCode::Char('o'), - modifiers: KeyModifiers::NONE, - .. - } => { - let eol = self.end_of_current_line(); - let insert_at = if eol < self.text.len() { eol + 1 } else { eol }; - self.insert_str_at(insert_at, "\n"); - self.set_cursor(insert_at + 1); - self.vim_mode = VimMode::Insert; - } - KeyEvent { - code: KeyCode::Char('O'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - let bol = self.beginning_of_current_line(); - self.insert_str_at(bol, "\n"); - self.set_cursor(bol); - self.vim_mode = VimMode::Insert; - } - KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_cursor_left(), - KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_cursor_right(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_cursor_down(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_cursor_up(), - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::NONE, - .. - } => self.set_cursor(self.beginning_of_next_word()), - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::NONE, - .. - } => self.set_cursor(self.beginning_of_previous_word()), - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::NONE, - .. - } => self.set_cursor(self.end_of_next_word()), - KeyEvent { - code: KeyCode::Char('0'), - modifiers: KeyModifiers::NONE, - .. - } => self.set_cursor(self.beginning_of_current_line()), - KeyEvent { - code: KeyCode::Char('$'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - self.set_cursor(self.end_of_current_line()); - } - KeyEvent { - code: KeyCode::Char('x'), - modifiers: KeyModifiers::NONE, - .. - } => { - self.delete_forward_kill(1); - } - KeyEvent { - code: KeyCode::Char('D'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - self.kill_to_end_of_line(); - } - KeyEvent { - code: KeyCode::Char('Y'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - self.yank_current_line(); - } - KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::NONE, - .. - } => self.paste_after_cursor(), - KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::NONE, - .. - } => { - self.vim_operator = Some(VimOperator::Delete); - } - KeyEvent { - code: KeyCode::Char('y'), - modifiers: KeyModifiers::NONE, - .. - } => { - self.vim_operator = Some(VimOperator::Yank); - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.vim_operator = None; - } - _ => {} + if self.vim_normal_keymap.enter_insert.is_pressed(event) { + self.vim_mode = VimMode::Insert; + return; + } + if self.vim_normal_keymap.append_after_cursor.is_pressed(event) { + let next = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(next); + self.vim_mode = VimMode::Insert; + return; + } + if self.vim_normal_keymap.append_line_end.is_pressed(event) { + self.set_cursor(self.end_of_current_line()); + self.vim_mode = VimMode::Insert; + return; + } + if self.vim_normal_keymap.insert_line_start.is_pressed(event) { + self.set_cursor(self.beginning_of_current_line()); + self.vim_mode = VimMode::Insert; + return; + } + if self.vim_normal_keymap.open_line_below.is_pressed(event) { + let eol = self.end_of_current_line(); + let insert_at = if eol < self.text.len() { eol + 1 } else { eol }; + self.insert_str_at(insert_at, "\n"); + self.set_cursor(insert_at + 1); + self.vim_mode = VimMode::Insert; + return; + } + if self.vim_normal_keymap.open_line_above.is_pressed(event) { + let bol = self.beginning_of_current_line(); + self.insert_str_at(bol, "\n"); + self.set_cursor(bol); + self.vim_mode = VimMode::Insert; + return; + } + if self.vim_normal_keymap.move_left.is_pressed(event) { + self.move_cursor_left(); + return; + } + if self.vim_normal_keymap.move_right.is_pressed(event) { + self.move_cursor_right(); + return; + } + if self.vim_normal_keymap.move_down.is_pressed(event) { + self.move_cursor_down(); + return; + } + if self.vim_normal_keymap.move_up.is_pressed(event) { + self.move_cursor_up(); + return; + } + if self.vim_normal_keymap.move_word_forward.is_pressed(event) { + self.set_cursor(self.beginning_of_next_word()); + return; + } + if self.vim_normal_keymap.move_word_backward.is_pressed(event) { + self.set_cursor(self.beginning_of_previous_word()); + return; + } + if self.vim_normal_keymap.move_word_end.is_pressed(event) { + self.set_cursor(self.end_of_next_word()); + return; + } + if self.vim_normal_keymap.move_line_start.is_pressed(event) { + self.set_cursor(self.beginning_of_current_line()); + return; + } + if self.vim_normal_keymap.move_line_end.is_pressed(event) { + self.set_cursor(self.end_of_current_line()); + return; + } + if self.vim_normal_keymap.delete_char.is_pressed(event) { + self.delete_forward_kill(1); + return; + } + if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) { + self.kill_to_end_of_line(); + return; + } + if self.vim_normal_keymap.yank_line.is_pressed(event) { + self.yank_current_line(); + return; + } + if self.vim_normal_keymap.paste_after.is_pressed(event) { + self.paste_after_cursor(); + return; + } + if self + .vim_normal_keymap + .start_delete_operator + .is_pressed(event) + { + self.vim_operator = Some(VimOperator::Delete); + return; + } + if self.vim_normal_keymap.start_yank_operator.is_pressed(event) { + self.vim_operator = Some(VimOperator::Yank); + return; + } + if self.vim_normal_keymap.cancel_operator.is_pressed(event) { + self.vim_operator = None; } } fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { - match event { - KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::NONE, - .. - } if op == VimOperator::Delete => { - self.delete_current_line(); - true - } - KeyEvent { - code: KeyCode::Char('y'), - modifiers: KeyModifiers::NONE, - .. - } if op == VimOperator::Yank => { - self.yank_current_line(); - true - } - _ => { - if let Some(motion) = self.vim_motion_for_event(event) { - self.apply_vim_operator(op, motion); - return true; - } - false - } + if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) { + self.delete_current_line(); + return true; } + if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) { + self.yank_current_line(); + return true; + } + if self.vim_operator_keymap.cancel.is_pressed(event) { + return true; + } + + if let Some(motion) = self.vim_motion_for_event(event) { + self.apply_vim_operator(op, motion); + return true; + } + false } fn vim_motion_for_event(&self, event: KeyEvent) -> Option { - match event { - KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::Left), - KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::Right), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::Down), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::Up), - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::WordForward), - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::WordBackward), - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::WordEnd), - KeyEvent { - code: KeyCode::Char('0'), - modifiers: KeyModifiers::NONE, - .. - } => Some(VimMotion::LineStart), - KeyEvent { - code: KeyCode::Char('$'), - modifiers, - .. - } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { - Some(VimMotion::LineEnd) - } - _ => None, + if self.vim_operator_keymap.motion_left.is_pressed(event) { + return Some(VimMotion::Left); } + if self.vim_operator_keymap.motion_right.is_pressed(event) { + return Some(VimMotion::Right); + } + if self.vim_operator_keymap.motion_down.is_pressed(event) { + return Some(VimMotion::Down); + } + if self.vim_operator_keymap.motion_up.is_pressed(event) { + return Some(VimMotion::Up); + } + if self + .vim_operator_keymap + .motion_word_forward + .is_pressed(event) + { + return Some(VimMotion::WordForward); + } + if self + .vim_operator_keymap + .motion_word_backward + .is_pressed(event) + { + return Some(VimMotion::WordBackward); + } + if self.vim_operator_keymap.motion_word_end.is_pressed(event) { + return Some(VimMotion::WordEnd); + } + if self.vim_operator_keymap.motion_line_start.is_pressed(event) { + return Some(VimMotion::LineStart); + } + if self.vim_operator_keymap.motion_line_end.is_pressed(event) { + return Some(VimMotion::LineEnd); + } + None } fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 99db085b7d..6600340bed 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5293,6 +5293,9 @@ impl ChatWidget { if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) { widget.bottom_pane.set_keymap_bindings(&keymap); } + widget + .bottom_pane + .set_vim_enabled(widget.config.tui_vim_mode_default); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -5645,6 +5648,469 @@ impl ChatWidget { self.last_agent_markdown.as_deref() } + fn dispatch_command(&mut self, cmd: SlashCommand) { + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.bottom_pane.drain_pending_submission_state(); + self.request_redraw(); + return; + } + match cmd { + SlashCommand::Feedback => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + // Step 1: pick a category (UI built in feedback_view) + let params = + crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Clear => { + self.app_event_tx.send(AppEvent::ClearUi); + } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } + SlashCommand::Fork => { + self.app_event_tx.send(AppEvent::ForkCurrentSession); + } + SlashCommand::Init => { + let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, /*hint*/ None); + return; + } + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.submit_user_message(INIT_PROMPT.to_string().into()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } + self.app_event_tx.compact(); + } + SlashCommand::Review => { + self.open_review_popup(); + } + SlashCommand::Rename => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + self.show_rename_prompt(); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Fast => { + let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + None + } else { + Some(ServiceTier::Fast) + }; + self.set_service_tier_selection(next_tier); + } + SlashCommand::Realtime => { + if !self.realtime_conversation_enabled() { + return; + } + if self.realtime_conversation.is_live() { + self.stop_realtime_conversation_from_ui(); + } else { + self.start_realtime_conversation(); + } + } + SlashCommand::Settings => { + if !self.realtime_audio_device_selection_enabled() { + return; + } + self.open_realtime_audio_popup(); + } + SlashCommand::Personality => { + self.open_personality_popup(); + } + SlashCommand::Plan => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /plan.".to_string()), + ); + return; + } + if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { + self.set_collaboration_mask(mask); + } else { + self.add_info_message( + "Plan mode unavailable right now.".to_string(), + /*hint*/ None, + ); + } + } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /collab.".to_string()), + ); + return; + } + self.open_collaboration_modes_popup(); + } + SlashCommand::Agent | SlashCommand::MultiAgents => { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + } + SlashCommand::Approvals => { + self.open_permissions_popup(); + } + SlashCommand::Permissions => { + self.open_permissions_popup(); + } + SlashCommand::Vim => { + self.toggle_vim_mode_and_notify(); + } + SlashCommand::ElevateSandbox => { + #[cfg(target_os = "windows")] + { + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let windows_degraded_sandbox_enabled = + matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); + if !windows_degraded_sandbox_enabled + || !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + { + // This command should not be visible/recognized outside degraded mode, + // but guard anyway in case something dispatches it directly. + return; + } + + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + else { + // Avoid panicking in interactive UI; treat this as a recoverable + // internal error. + self.add_error_message( + "Internal error: missing the 'auto' approval preset.".to_string(), + ); + return; + }; + + if let Err(err) = self + .config + .permissions + .approval_policy + .can_set(&preset.approval) + { + self.add_error_message(err.to_string()); + return; + } + + self.session_telemetry.counter( + "codex.windows_sandbox.setup_elevated_sandbox_command", + /*inc*/ 1, + &[], + ); + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = &self.session_telemetry; + // Not supported; on non-Windows this command should never be reachable. + }; + } + SlashCommand::SandboxReadRoot => { + self.add_error_message( + "Usage: /sandbox-add-read-dir ".to_string(), + ); + } + SlashCommand::Experimental => { + self.open_experimental_popup(); + } + SlashCommand::Quit | SlashCommand::Exit => { + self.request_quit_without_confirmation(); + } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + tracing::error!("failed to logout: {e}"); + } + self.request_quit_without_confirmation(); + } + // SlashCommand::Undo => { + // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); + // } + SlashCommand::Copy => { + self.copy_last_agent_markdown(); + } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Skills => { + self.open_skills_menu(); + } + SlashCommand::Status => { + if self.should_prefetch_rate_limits() { + let request_id = self.next_rate_limit_refresh_request_id(); + self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }); + } else { + self.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + } + } + SlashCommand::DebugConfig => { + self.add_debug_config_output(); + } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } + SlashCommand::Statusline => { + self.open_status_line_setup(); + } + SlashCommand::Theme => { + self.open_theme_picker(); + } + SlashCommand::Ps => { + self.add_ps_output(); + } + SlashCommand::Stop => { + self.clean_background_terminals(); + } + SlashCommand::MemoryDrop => { + self.add_app_server_stub_message("Memory maintenance"); + } + SlashCommand::MemoryUpdate => { + self.add_app_server_stub_message("Memory maintenance"); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + SlashCommand::Apps => { + self.add_connectors_output(); + } + SlashCommand::Plugins => { + self.add_plugins_output(); + } + SlashCommand::Rollout => { + if let Some(path) = self.rollout_path() { + self.add_info_message( + format!("Current rollout path: {}", path.display()), + /*hint*/ None, + ); + } else { + self.add_info_message( + "Rollout path is not available yet.".to_string(), + /*hint*/ None, + ); + } + } + SlashCommand::TestApproval => { + use std::collections::HashMap; + + use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; + use codex_protocol::protocol::FileChange; + + self.on_apply_patch_approval_request( + "1".to_string(), + ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + turn_id: "turn-1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }, + ); + } + } + } + + fn dispatch_command_with_args( + &mut self, + cmd: SlashCommand, + args: String, + _text_elements: Vec, + ) { + if !cmd.supports_inline_args() { + self.dispatch_command(cmd); + return; + } + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + return; + } + + let trimmed = args.trim(); + match cmd { + SlashCommand::Fast => { + if trimmed.is_empty() { + self.dispatch_command(cmd); + return; + } + match trimmed.to_ascii_lowercase().as_str() { + "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), + "off" => self.set_service_tier_selection(/*service_tier*/ None), + "status" => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) + { + "on" + } else { + "off" + }; + self.add_info_message( + format!("Fast mode is {status}."), + /*hint*/ None, + ); + } + _ => { + self.add_error_message("Usage: /fast [on|off|status]".to_string()); + } + } + } + SlashCommand::Rename if !trimmed.is_empty() => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args) + else { + self.add_error_message("Thread name cannot be empty.".to_string()); + return; + }; + self.app_event_tx.set_thread_name(name); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::Plan if !trimmed.is_empty() => { + self.dispatch_command(cmd); + if self.active_mode_kind() != ModeKind::Plan { + return; + } + let Some((prepared_args, prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ true) + else { + return; + }; + let local_images = self + .bottom_pane + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + let user_message = UserMessage { + text: prepared_args, + local_images, + remote_image_urls, + text_elements: prepared_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + }; + if self.is_session_configured() { + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + SlashCommand::Review if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.submit_op(AppCommand::review(ReviewRequest { + target: ReviewTarget::Custom { + instructions: prepared_args, + }, + user_facing_hint: None, + })); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::Resume if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::ResumeSessionByIdOrName(prepared_args)); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxGrantReadRoot { + path: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } + _ => self.dispatch_command(cmd), + } + } + fn show_rename_prompt(&mut self) { if !self.ensure_thread_rename_allowed() { return; @@ -10824,6 +11290,16 @@ impl ChatWidget { self.bottom_pane.is_task_running() } + pub(crate) fn toggle_vim_mode_and_notify(&mut self) { + let enabled = self.bottom_pane.toggle_vim_enabled(); + let message = if enabled { + "Vim mode enabled." + } else { + "Vim mode disabled." + }; + self.add_info_message(message.to_string(), None); + } + pub(crate) fn submit_user_message_with_mode( &mut self, text: String, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f19a55d22e..5a06709016 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1342,13 +1342,43 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { + let chat = make_startup_chat_with_cli_overrides(vec![( + "features.collaboration_modes".to_string(), + TomlValue::Boolean(true), + )]) + .await; + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); + assert_eq!( + chat.current_model(), + codex_core::test_support::get_model_offline(chat.config.model.as_deref()) + ); +} + +#[tokio::test] +async fn vim_mode_default_disabled_starts_composer_in_insert_mode() { + let chat = make_startup_chat_with_cli_overrides(Vec::new()).await; + assert!(!chat.bottom_pane.composer_is_vim_enabled()); +} + +#[tokio::test] +async fn vim_mode_default_enabled_starts_composer_in_normal_mode() { + let chat = make_startup_chat_with_cli_overrides(vec![( + "tui.vim_mode_default".to_string(), + TomlValue::Boolean(true), + )]) + .await; + + assert!(chat.bottom_pane.composer_is_vim_enabled()); + assert!(!chat.bottom_pane.composer_is_vim_insert()); +} + +async fn make_startup_chat_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, +) -> ChatWidget { let codex_home = tempdir().expect("tempdir"); let cfg = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cli_overrides(vec![( - "features.collaboration_modes".to_string(), - TomlValue::Boolean(true), - )]) + .cli_overrides(cli_overrides) .build() .await .expect("config"); @@ -1373,9 +1403,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { session_telemetry, }; - let chat = ChatWidget::new_with_app_event(init); - assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); - assert_eq!(chat.current_model(), resolved_model); + ChatWidget::new_with_app_event(init) } #[tokio::test] diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 7d94d0d76f..239f857a41 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -40,6 +40,8 @@ pub(crate) struct RuntimeKeymap { pub(crate) chat: ChatKeymap, pub(crate) composer: ComposerKeymap, pub(crate) editor: EditorKeymap, + pub(crate) vim_normal: VimNormalKeymap, + pub(crate) vim_operator: VimOperatorKeymap, pub(crate) pager: PagerKeymap, pub(crate) list: ListKeymap, pub(crate) approval: ApprovalKeymap, @@ -52,6 +54,8 @@ pub(crate) struct AppKeymap { pub(crate) open_transcript: Vec, /// Open external editor for the current draft. pub(crate) open_external_editor: Vec, + /// Toggle Vim mode for the composer input. + pub(crate) toggle_vim_mode: Vec, } #[derive(Clone, Debug)] @@ -96,6 +100,48 @@ pub(crate) struct EditorKeymap { pub(crate) yank: Vec, } +#[derive(Clone, Debug)] +pub(crate) struct VimNormalKeymap { + pub(crate) enter_insert: Vec, + pub(crate) append_after_cursor: Vec, + pub(crate) append_line_end: Vec, + pub(crate) insert_line_start: Vec, + pub(crate) open_line_below: Vec, + pub(crate) open_line_above: Vec, + pub(crate) move_left: Vec, + pub(crate) move_right: Vec, + pub(crate) move_up: Vec, + pub(crate) move_down: Vec, + pub(crate) move_word_forward: Vec, + pub(crate) move_word_backward: Vec, + pub(crate) move_word_end: Vec, + pub(crate) move_line_start: Vec, + pub(crate) move_line_end: Vec, + pub(crate) delete_char: Vec, + pub(crate) delete_to_line_end: Vec, + pub(crate) yank_line: Vec, + pub(crate) paste_after: Vec, + pub(crate) start_delete_operator: Vec, + pub(crate) start_yank_operator: Vec, + pub(crate) cancel_operator: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct VimOperatorKeymap { + pub(crate) delete_line: Vec, + pub(crate) yank_line: Vec, + pub(crate) motion_left: Vec, + pub(crate) motion_right: Vec, + pub(crate) motion_up: Vec, + pub(crate) motion_down: Vec, + pub(crate) motion_word_forward: Vec, + pub(crate) motion_word_backward: Vec, + pub(crate) motion_word_end: Vec, + pub(crate) motion_line_start: Vec, + pub(crate) motion_line_end: Vec, + pub(crate) cancel: Vec, +} + /// Pager/overlay keybindings for transcript and static help views. #[derive(Clone, Debug)] pub(crate) struct PagerKeymap { @@ -278,6 +324,11 @@ impl RuntimeKeymap { &defaults.app.open_external_editor, "tui.keymap.global.open_external_editor", )?, + toggle_vim_mode: resolve_bindings( + keymap.global.toggle_vim_mode.as_ref(), + &defaults.app.toggle_vim_mode, + "tui.keymap.global.toggle_vim_mode", + )?, }; let chat = ChatKeymap { @@ -320,6 +371,61 @@ impl RuntimeKeymap { yank: resolve_local!(keymap, defaults, editor, yank), }; + let vim_normal = VimNormalKeymap { + enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert), + append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor), + append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end), + insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start), + open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below), + open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above), + move_left: resolve_local!(keymap, defaults, vim_normal, move_left), + move_right: resolve_local!(keymap, defaults, vim_normal, move_right), + move_up: resolve_local!(keymap, defaults, vim_normal, move_up), + move_down: resolve_local!(keymap, defaults, vim_normal, move_down), + move_word_forward: resolve_local!(keymap, defaults, vim_normal, move_word_forward), + move_word_backward: resolve_local!(keymap, defaults, vim_normal, move_word_backward), + move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end), + move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start), + move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), + delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), + delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), + yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), + paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after), + start_delete_operator: resolve_local!( + keymap, + defaults, + vim_normal, + start_delete_operator + ), + start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator), + cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator), + }; + + let vim_operator = VimOperatorKeymap { + delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), + yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line), + motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left), + motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right), + motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up), + motion_down: resolve_local!(keymap, defaults, vim_operator, motion_down), + motion_word_forward: resolve_local!( + keymap, + defaults, + vim_operator, + motion_word_forward + ), + motion_word_backward: resolve_local!( + keymap, + defaults, + vim_operator, + motion_word_backward + ), + motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end), + motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start), + motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end), + cancel: resolve_local!(keymap, defaults, vim_operator, cancel), + }; + let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), @@ -369,6 +475,8 @@ impl RuntimeKeymap { chat, composer, editor, + vim_normal, + vim_operator, pager, list, approval, @@ -395,6 +503,7 @@ impl RuntimeKeymap { app: AppKeymap { open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))], + toggle_vim_mode: default_bindings![], }, chat: ChatKeymap { edit_previous_message: default_bindings![plain(KeyCode::Esc)], @@ -449,6 +558,62 @@ impl RuntimeKeymap { kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))], yank: default_bindings![ctrl(KeyCode::Char('y'))], }, + vim_normal: VimNormalKeymap { + enter_insert: default_bindings![plain(KeyCode::Char('i'))], + append_after_cursor: default_bindings![plain(KeyCode::Char('a'))], + append_line_end: default_bindings![ + shift(KeyCode::Char('a')), + plain(KeyCode::Char('A')) + ], + insert_line_start: default_bindings![ + shift(KeyCode::Char('i')), + plain(KeyCode::Char('I')) + ], + open_line_below: default_bindings![plain(KeyCode::Char('o'))], + open_line_above: default_bindings![ + shift(KeyCode::Char('o')), + plain(KeyCode::Char('O')) + ], + move_left: default_bindings![plain(KeyCode::Char('h'))], + move_right: default_bindings![plain(KeyCode::Char('l'))], + move_up: default_bindings![plain(KeyCode::Char('k'))], + move_down: default_bindings![plain(KeyCode::Char('j'))], + move_word_forward: default_bindings![plain(KeyCode::Char('w'))], + move_word_backward: default_bindings![plain(KeyCode::Char('b'))], + move_word_end: default_bindings![plain(KeyCode::Char('e'))], + move_line_start: default_bindings![plain(KeyCode::Char('0'))], + move_line_end: default_bindings![ + plain(KeyCode::Char('$')), + shift(KeyCode::Char('$')) + ], + delete_char: default_bindings![plain(KeyCode::Char('x'))], + delete_to_line_end: default_bindings![ + shift(KeyCode::Char('d')), + plain(KeyCode::Char('D')) + ], + yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))], + paste_after: default_bindings![plain(KeyCode::Char('p'))], + start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], + start_yank_operator: default_bindings![plain(KeyCode::Char('y'))], + cancel_operator: default_bindings![plain(KeyCode::Esc)], + }, + vim_operator: VimOperatorKeymap { + delete_line: default_bindings![plain(KeyCode::Char('d'))], + yank_line: default_bindings![plain(KeyCode::Char('y'))], + motion_left: default_bindings![plain(KeyCode::Char('h'))], + motion_right: default_bindings![plain(KeyCode::Char('l'))], + motion_up: default_bindings![plain(KeyCode::Char('k'))], + motion_down: default_bindings![plain(KeyCode::Char('j'))], + motion_word_forward: default_bindings![plain(KeyCode::Char('w'))], + motion_word_backward: default_bindings![plain(KeyCode::Char('b'))], + motion_word_end: default_bindings![plain(KeyCode::Char('e'))], + motion_line_start: default_bindings![plain(KeyCode::Char('0'))], + motion_line_end: default_bindings![ + plain(KeyCode::Char('$')), + shift(KeyCode::Char('$')) + ], + cancel: default_bindings![plain(KeyCode::Esc)], + }, pager: PagerKeymap { scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))], scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))], @@ -551,6 +716,7 @@ impl RuntimeKeymap { "open_external_editor", self.app.open_external_editor.as_slice(), ), + ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ( "edit_previous_message", self.chat.edit_previous_message.as_slice(), @@ -570,6 +736,7 @@ impl RuntimeKeymap { "open_external_editor", self.app.open_external_editor.as_slice(), ), + ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("composer.submit", self.composer.submit.as_slice()), ("composer.queue", self.composer.queue.as_slice()), ( @@ -607,6 +774,103 @@ impl RuntimeKeymap { ], )?; + validate_unique( + "vim_normal", + [ + ("enter_insert", self.vim_normal.enter_insert.as_slice()), + ( + "append_after_cursor", + self.vim_normal.append_after_cursor.as_slice(), + ), + ( + "append_line_end", + self.vim_normal.append_line_end.as_slice(), + ), + ( + "insert_line_start", + self.vim_normal.insert_line_start.as_slice(), + ), + ( + "open_line_below", + self.vim_normal.open_line_below.as_slice(), + ), + ( + "open_line_above", + self.vim_normal.open_line_above.as_slice(), + ), + ("move_left", self.vim_normal.move_left.as_slice()), + ("move_right", self.vim_normal.move_right.as_slice()), + ("move_up", self.vim_normal.move_up.as_slice()), + ("move_down", self.vim_normal.move_down.as_slice()), + ( + "move_word_forward", + self.vim_normal.move_word_forward.as_slice(), + ), + ( + "move_word_backward", + self.vim_normal.move_word_backward.as_slice(), + ), + ("move_word_end", self.vim_normal.move_word_end.as_slice()), + ( + "move_line_start", + self.vim_normal.move_line_start.as_slice(), + ), + ("move_line_end", self.vim_normal.move_line_end.as_slice()), + ("delete_char", self.vim_normal.delete_char.as_slice()), + ( + "delete_to_line_end", + self.vim_normal.delete_to_line_end.as_slice(), + ), + ("yank_line", self.vim_normal.yank_line.as_slice()), + ("paste_after", self.vim_normal.paste_after.as_slice()), + ( + "start_delete_operator", + self.vim_normal.start_delete_operator.as_slice(), + ), + ( + "start_yank_operator", + self.vim_normal.start_yank_operator.as_slice(), + ), + ( + "cancel_operator", + self.vim_normal.cancel_operator.as_slice(), + ), + ], + )?; + + validate_unique( + "vim_operator", + [ + ("delete_line", self.vim_operator.delete_line.as_slice()), + ("yank_line", self.vim_operator.yank_line.as_slice()), + ("motion_left", self.vim_operator.motion_left.as_slice()), + ("motion_right", self.vim_operator.motion_right.as_slice()), + ("motion_up", self.vim_operator.motion_up.as_slice()), + ("motion_down", self.vim_operator.motion_down.as_slice()), + ( + "motion_word_forward", + self.vim_operator.motion_word_forward.as_slice(), + ), + ( + "motion_word_backward", + self.vim_operator.motion_word_backward.as_slice(), + ), + ( + "motion_word_end", + self.vim_operator.motion_word_end.as_slice(), + ), + ( + "motion_line_start", + self.vim_operator.motion_line_start.as_slice(), + ), + ( + "motion_line_end", + self.vim_operator.motion_line_end.as_slice(), + ), + ("cancel", self.vim_operator.cancel.as_slice()), + ], + )?; + validate_unique( "pager", [ diff --git a/docs/config.md b/docs/config.md index 22c0b96021..dcef03396e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -6,6 +6,18 @@ For advanced configuration instructions, see [this documentation](https://develo For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference). +## TUI behavior + +Use `[tui]` to configure startup behavior for the terminal UI. + +```toml +[tui] +# Start the composer in Vim "Normal" mode on launch. +vim_mode_default = true +``` + +`vim_mode_default` defaults to `false` (composer starts in insert mode). + ## TUI keymap The TUI supports rebinding shortcuts via `[tui.keymap]` in `~/.codex/config.toml`. @@ -33,13 +45,23 @@ When defaults change in the future, a new version (for example `v2`) is added an ### Supported actions - `global`: `open_transcript`, `open_external_editor`, `edit_previous_message`, - `confirm_edit_previous_message`, `submit`, `queue`, `toggle_shortcuts` + `confirm_edit_previous_message`, `submit`, `queue`, `toggle_shortcuts`, + `toggle_vim_mode` - `chat`: `edit_previous_message`, `confirm_edit_previous_message` - `composer`: `submit`, `queue`, `toggle_shortcuts` - `editor`: `insert_newline`, `move_left`, `move_right`, `move_up`, `move_down`, `move_word_left`, `move_word_right`, `move_line_start`, `move_line_end`, `delete_backward`, `delete_forward`, `delete_backward_word`, `delete_forward_word`, `kill_line_start`, `kill_line_end`, `yank` +- `vim_normal`: `enter_insert`, `append_after_cursor`, `append_line_end`, + `insert_line_start`, `open_line_below`, `open_line_above`, `move_left`, + `move_right`, `move_up`, `move_down`, `move_word_forward`, + `move_word_backward`, `move_word_end`, `move_line_start`, `move_line_end`, + `delete_char`, `delete_to_line_end`, `yank_line`, `paste_after`, + `start_delete_operator`, `start_yank_operator`, `cancel_operator` +- `vim_operator`: `delete_line`, `yank_line`, `motion_left`, `motion_right`, + `motion_up`, `motion_down`, `motion_word_forward`, `motion_word_backward`, + `motion_word_end`, `motion_line_start`, `motion_line_end`, `cancel` - `pager`: `scroll_up`, `scroll_down`, `page_up`, `page_down`, `half_page_up`, `half_page_down`, `jump_top`, `jump_bottom`, `close`, `close_transcript`, `edit_previous_message`, `edit_next_message`, `confirm_edit_message` diff --git a/docs/default-keymap.toml b/docs/default-keymap.toml index bd0575d6a3..c2453b2ec5 100644 --- a/docs/default-keymap.toml +++ b/docs/default-keymap.toml @@ -37,6 +37,9 @@ queue = "tab" # Toggle composer shortcut overlay. # Include both forms because some terminals report `?` with SHIFT and others don't. toggle_shortcuts = ["?", "shift-?"] +# Toggle Vim mode for the composer input. +# Empty list keeps this unbound by default. +toggle_vim_mode = [] [tui.keymap.chat] # Overrides [tui.keymap.global] for chat-only behavior. @@ -68,6 +71,46 @@ kill_line_start = ["ctrl-u"] kill_line_end = ["ctrl-k"] yank = ["ctrl-y"] +[tui.keymap.vim_normal] +# Vim normal-mode actions for modal editing. +enter_insert = "i" +append_after_cursor = "a" +append_line_end = "shift-a" +insert_line_start = "shift-i" +open_line_below = "o" +open_line_above = "shift-o" +move_left = "h" +move_right = "l" +move_up = "k" +move_down = "j" +move_word_forward = "w" +move_word_backward = "b" +move_word_end = "e" +move_line_start = "0" +move_line_end = "$" +delete_char = "x" +delete_to_line_end = "shift-d" +yank_line = "shift-y" +paste_after = "p" +start_delete_operator = "d" +start_yank_operator = "y" +cancel_operator = "esc" + +[tui.keymap.vim_operator] +# Vim operator-pending actions for delete/yank motions. +delete_line = "d" +yank_line = "y" +motion_left = "h" +motion_right = "l" +motion_up = "k" +motion_down = "j" +motion_word_forward = "w" +motion_word_backward = "b" +motion_word_end = "e" +motion_line_start = "0" +motion_line_end = "$" +cancel = "esc" + [tui.keymap.pager] # Transcript/static overlay navigation. scroll_up = ["up", "k"] diff --git a/docs/example-config.md b/docs/example-config.md index f1102b63ec..a7d8638c02 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -5,6 +5,9 @@ For a full sample configuration, see [this documentation](https://developers.ope ## Keymap snippet ```toml +[tui] +vim_mode_default = false + [tui.keymap] preset = "latest" # currently points to v1 defaults diff --git a/docs/keymap-action-matrix.md b/docs/keymap-action-matrix.md index ee28c807fe..dada5155e9 100644 --- a/docs/keymap-action-matrix.md +++ b/docs/keymap-action-matrix.md @@ -38,6 +38,7 @@ For runtime behavior, safety invariants, and testing guidance, see - `submit`: submit current draft - `queue`: queue current draft while a task is running - `toggle_shortcuts`: toggle composer shortcut overlay +- `toggle_vim_mode`: toggle Vim mode for composer input ### `chat` @@ -61,6 +62,32 @@ For runtime behavior, safety invariants, and testing guidance, see - `kill_line_start` / `kill_line_end`: kill to line boundary - `yank`: paste kill-buffer contents +### `vim_normal` + +- `enter_insert`: switch to insert mode +- `append_after_cursor`: move right and switch to insert mode +- `append_line_end`: move to end of line and switch to insert mode +- `insert_line_start`: move to beginning of line and switch to insert mode +- `open_line_below` / `open_line_above`: open line and switch to insert mode +- `move_left` / `move_right` / `move_up` / `move_down`: cursor movement +- `move_word_forward` / `move_word_backward` / `move_word_end`: word motions +- `move_line_start` / `move_line_end`: line boundary motions +- `delete_char`: delete character at cursor +- `delete_to_line_end`: delete from cursor to line end +- `yank_line`: copy current line +- `paste_after`: paste after cursor +- `start_delete_operator` / `start_yank_operator`: enter operator-pending state +- `cancel_operator`: clear pending operator + +### `vim_operator` + +- `delete_line`: apply delete operator to full line +- `yank_line`: apply yank operator to full line +- `motion_left` / `motion_right` / `motion_up` / `motion_down`: motion targets +- `motion_word_forward` / `motion_word_backward` / `motion_word_end`: word targets +- `motion_line_start` / `motion_line_end`: line targets +- `cancel`: cancel pending operator + ### `pager` - `scroll_up` / `scroll_down`: row scroll diff --git a/docs/tui-keymap.md b/docs/tui-keymap.md index 949d285dfd..a7cff6e845 100644 --- a/docs/tui-keymap.md +++ b/docs/tui-keymap.md @@ -70,10 +70,12 @@ Supported contexts: 2. `chat` 3. `composer` 4. `editor` -5. `pager` -6. `list` -7. `approval` -8. `onboarding` +5. `vim_normal` +6. `vim_operator` +7. `pager` +8. `list` +9. `approval` +10. `onboarding` Action inventory by context is documented in `docs/config.md` and the template at `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`. @@ -124,7 +126,8 @@ Current conflict passes in `RuntimeKeymap::validate_conflicts` enforce: 2. App/composer shadowing prevention, because app handlers execute before forwarding to composer handlers. 3. Composer-local uniqueness for submit/queue/shortcut-toggle. -4. Context-local uniqueness in editor, pager, list, approval, and onboarding. +4. Context-local uniqueness in editor, vim_normal, vim_operator, pager, list, + approval, and onboarding. Intentionally allowed: