diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 376effd64505..b23322a53886 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -102,6 +102,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. @@ -173,6 +175,95 @@ pub struct TuiEditorKeymap { pub yank: Option, } +/// 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, + /// Enter insert mode after cursor (`a`). + pub append_after_cursor: Option, + /// Enter insert mode at end of line (`A`). + pub append_line_end: Option, + /// Enter insert mode at first non-blank of line (`I`). + pub insert_line_start: Option, + /// Open a new line below and enter insert mode (`o`). + pub open_line_below: Option, + /// Open a new line above and enter insert mode (`O`). + pub open_line_above: Option, + /// Move cursor left (`h`). + pub move_left: Option, + /// Move cursor right (`l`). + pub move_right: Option, + /// Move cursor up (`k`), or recall older composer history at history boundaries. + pub move_up: Option, + /// Move cursor down (`j`), or recall newer composer history at history boundaries. + pub move_down: Option, + /// Move cursor to start of next word (`w`). + pub move_word_forward: Option, + /// Move cursor to start of previous word (`b`). + pub move_word_backward: Option, + /// Move cursor to end of current/next word (`e`). + pub move_word_end: Option, + /// Move cursor to start of line (`0`). + pub move_line_start: Option, + /// Move cursor to end of line (`$`). + pub move_line_end: Option, + /// Delete character under cursor (`x`). + pub delete_char: Option, + /// Delete from cursor to end of line (`D`). + pub delete_to_line_end: Option, + /// Yank the entire line (`Y`). + pub yank_line: Option, + /// Paste after cursor (`p`). + pub paste_after: Option, + /// Begin delete operator; next key selects motion (`d`). + pub start_delete_operator: Option, + /// Begin yank operator; next key selects motion (`y`). + pub start_yank_operator: Option, + /// Cancel a pending operator and return to normal mode. + pub cancel_operator: Option, +} + +/// 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, + /// Repeat yank operator to yank the whole line (`yy`). + pub yank_line: Option, + /// Motion: left (`h`). + pub motion_left: Option, + /// Motion: right (`l`). + pub motion_right: Option, + /// Motion: up one line (`k`). + pub motion_up: Option, + /// Motion: down one line (`j`). + pub motion_down: Option, + /// Motion: to start of next word (`w`). + pub motion_word_forward: Option, + /// Motion: to start of previous word (`b`). + pub motion_word_backward: Option, + /// Motion: to end of current/next word (`e`). + pub motion_word_end: Option, + /// Motion: to start of line (`0`). + pub motion_line_start: Option, + /// Motion: to end of line (`$`). + pub motion_line_end: Option, + /// Cancel the pending operator and return to normal mode. + pub cancel: Option, +} + /// 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, diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c9ec732a021a..43d79ed9012d 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -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. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8bf457c2b0d2..4d9e3d2d2744 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2414,7 +2414,8 @@ "open_transcript": null, "queue": null, "submit": null, - "toggle_shortcuts": null + "toggle_shortcuts": null, + "toggle_vim_mode": null }, "list": { "accept": null, @@ -2433,6 +2434,44 @@ "page_up": null, "scroll_down": null, "scroll_up": null + }, + "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." @@ -2505,6 +2544,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" @@ -2852,6 +2896,14 @@ } ], "description": "Toggle the composer shortcut overlay." + }, + "toggle_vim_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle Vim mode for the composer input." } }, "type": "object" @@ -2941,7 +2993,8 @@ "open_transcript": null, "queue": null, "submit": null, - "toggle_shortcuts": null + "toggle_shortcuts": null, + "toggle_vim_mode": null } }, "list": { @@ -2975,6 +3028,58 @@ "scroll_down": null, "scroll_up": null } + }, + "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" @@ -3105,6 +3210,292 @@ }, "type": "object" }, + "TuiVimNormalKeymap": { + "additionalProperties": false, + "description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions 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.", + "properties": { + "append_after_cursor": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode after cursor (`a`)." + }, + "append_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode at end of line (`A`)." + }, + "cancel_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel a pending operator and return to normal mode." + }, + "delete_char": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete character under cursor (`x`)." + }, + "delete_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete from cursor to end of line (`D`)." + }, + "enter_insert": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode at cursor (`i`)." + }, + "insert_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode at first non-blank of line (`I`)." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor down (`j`), or recall newer composer history at history boundaries." + }, + "move_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor left (`h`)." + }, + "move_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of line (`$`)." + }, + "move_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to start of line (`0`)." + }, + "move_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor right (`l`)." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor up (`k`), or recall older composer history at history boundaries." + }, + "move_word_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to start of previous word (`b`)." + }, + "move_word_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of current/next word (`e`)." + }, + "move_word_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to start of next word (`w`)." + }, + "open_line_above": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open a new line above and enter insert mode (`O`)." + }, + "open_line_below": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open a new line below and enter insert mode (`o`)." + }, + "paste_after": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Paste after cursor (`p`)." + }, + "start_delete_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin delete operator; next key selects motion (`d`)." + }, + "start_yank_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin yank operator; next key selects motion (`y`)." + }, + "yank_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Yank the entire line (`Y`)." + } + }, + "type": "object" + }, + "TuiVimOperatorKeymap": { + "additionalProperties": false, + "description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis 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.", + "properties": { + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel the pending operator and return to normal mode." + }, + "delete_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat delete operator to delete the whole line (`dd`)." + }, + "motion_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: down one line (`j`)." + }, + "motion_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: left (`h`)." + }, + "motion_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to end of line (`$`)." + }, + "motion_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to start of line (`0`)." + }, + "motion_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: right (`l`)." + }, + "motion_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: up one line (`k`)." + }, + "motion_word_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to start of previous word (`b`)." + }, + "motion_word_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to end of current/next word (`e`)." + }, + "motion_word_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to start of next word (`w`)." + }, + "yank_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat yank operator to yank the whole line (`yy`)." + } + }, + "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 6c39f1f1e958..10f6abaafb81 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -549,6 +549,7 @@ fn config_toml_deserializes_model_availability_nux() { notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, + vim_mode_default: false, alternate_screen: AltScreenMode::default(), status_line: None, terminal_title: None, @@ -598,6 +599,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#" @@ -2062,6 +2092,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, @@ -6367,6 +6398,8 @@ 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, + tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), @@ -6376,7 +6409,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, - tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }, o3_profile_config @@ -6563,6 +6595,8 @@ 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, + tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), @@ -6572,7 +6606,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, - tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; @@ -6713,6 +6746,8 @@ 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, + tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(false), @@ -6722,7 +6757,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, - tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; @@ -6848,6 +6882,8 @@ 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, + tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), @@ -6857,7 +6893,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, - tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fba79069d6cd..861903c9d11e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -506,6 +506,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. @@ -2981,6 +2984,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/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index b0cbf3243a5f..56c71ab39269 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -194,6 +194,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_theme: None, terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), + tui_vim_mode_default: false, cwd, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, mcp_servers: Constrained::allow_any(HashMap::new()), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c188ff6b2404..35b7b248d256 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1100,15 +1100,19 @@ See the Codex keymap documentation for supported actions and examples." self.chat_widget.desired_height(tui.terminal.size()?.width); if terminal_resize_reflow_enabled { tui.draw_with_resize_reflow(desired_height, |frame| { - self.chat_widget.render(frame.area(), frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + let area = frame.area(); + self.chat_widget.render(area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(area) { + frame.set_cursor_style(self.chat_widget.cursor_style(area)); frame.set_cursor_position((x, y)); } })?; } else { tui.draw(desired_height, |frame| { - self.chat_widget.render(frame.area(), frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + let area = frame.area(); + self.chat_widget.render(area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(area) { + frame.set_cursor_style(self.chat_widget.cursor_style(area)); frame.set_cursor_position((x, y)); } })?; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 045f84de17cc..fc18b627392d 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -122,6 +122,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.app.open_transcript.is_pressed(key_event) { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); @@ -152,7 +157,7 @@ impl App { // with the composer focused and empty. In any other state, forward // Esc so the active UI (e.g. status indicator, modals, popups) // handles it. - if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() { + if self.should_handle_backtrack_esc(key_event) { self.handle_backtrack_esc_key(tui); } else { self.chat_widget.handle_key_event(key_event); @@ -206,6 +211,12 @@ impl App { }; } + pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { + self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() + && !self.chat_widget.should_handle_vim_insert_escape(key_event) + } + pub(super) fn refresh_status_line(&mut self) { self.chat_widget.refresh_status_line(); } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 58ec898413f7..9caa4a93a625 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5079,6 +5079,32 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); } +#[tokio::test] +async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { + let mut app = make_test_app().await; + let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE); + + assert!(app.chat_widget.composer_is_empty()); + assert!(app.should_handle_backtrack_esc(esc)); + + app.chat_widget.toggle_vim_mode_and_notify(); + assert!(app.should_handle_backtrack_esc(esc)); + + app.chat_widget + .handle_key_event(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('i'), + KeyModifiers::NONE, + )); + assert!(app.chat_widget.should_handle_vim_insert_escape(esc)); + assert!(!app.should_handle_backtrack_esc(esc)); + + app.chat_widget.handle_key_event(esc); + + assert!(!app.backtrack.primed); + assert!(!app.chat_widget.should_handle_vim_insert_escape(esc)); + assert!(app.should_handle_backtrack_esc(esc)); +} + #[tokio::test] async fn session_summary_skips_when_no_usage_or_resume_hint() { assert!( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3a88e0883fcb..3c255ada229f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -122,7 +122,6 @@ //! state. //! use crate::bottom_pane::footer::goal_status_indicator_line; -use crate::bottom_pane::footer::mode_indicator_line; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; @@ -168,6 +167,7 @@ use super::footer::footer_hint_items_width; use super::footer::footer_line_width; use super::footer::inset_footer_hint_area; use super::footer::max_left_width_for_right; +use super::footer::mode_indicator_line as collaboration_mode_indicator_line; use super::footer::passive_footer_status_line; use super::footer::render_context_right; use super::footer::render_footer_from_props; @@ -189,6 +189,7 @@ use crate::bottom_pane::prompt_args::parse_slash_name; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; +use crate::keymap::VimNormalKeymap; use crate::keymap::primary_binding; use crate::render::Insets; use crate::render::RectExt; @@ -406,6 +407,7 @@ pub(crate) struct ChatComposer { history_search_previous_keys: Vec, history_search_next_keys: Vec, editor_keymap: EditorKeymap, + vim_normal_keymap: VimNormalKeymap, footer_external_editor_key: Option, footer_show_transcript_key: Option, footer_insert_newline_key: Option, @@ -455,15 +457,6 @@ enum SlashValidation { const FOOTER_SPACING_HEIGHT: u16 = 0; -fn status_line_right_indicator( - collaboration_mode_indicator: Option, - goal_status_indicator: Option<&GoalStatusIndicator>, - show_cycle_hint: bool, -) -> Option> { - mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) - .or_else(|| goal_status_indicator_line(goal_status_indicator)) -} - /// Builds the one-line nudge that replaces the ambient footer without adding layout height. fn plan_mode_nudge_line() -> Line<'static> { Line::from(vec![ @@ -525,6 +518,7 @@ impl ChatComposer { let use_shift_enter_hint = enhanced_keys_supported; let default_keymap = RuntimeKeymap::defaults(); let default_editor_keymap = default_keymap.editor.clone(); + let default_vim_normal_keymap = default_keymap.vim_normal.clone(); let mut this = Self { textarea: TextArea::new(), @@ -598,6 +592,7 @@ impl ChatComposer { history_search_previous_keys: default_keymap.composer.history_search_previous.clone(), history_search_next_keys: default_keymap.composer.history_search_next.clone(), editor_keymap: default_editor_keymap, + vim_normal_keymap: default_vim_normal_keymap, footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), footer_insert_newline_key: footer_insert_newline_key( @@ -705,7 +700,8 @@ impl ChatComposer { self.history_search_previous_keys = keymap.composer.history_search_previous.clone(); self.history_search_next_keys = keymap.composer.history_search_next.clone(); self.editor_keymap = keymap.editor.clone(); - self.textarea.set_keymap_bindings(&self.editor_keymap); + self.vim_normal_keymap = keymap.vim_normal.clone(); + self.textarea.set_keymap_bindings(keymap); self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); self.footer_insert_newline_key = @@ -1036,6 +1032,83 @@ impl ChatComposer { self.sync_popups(); } + /// Enable or disable Vim editing for the composer textarea. + /// + /// The composer clears any in-flight paste-burst state when the mode + /// changes because Vim normal mode treats rapid character sequences as + /// commands, not as candidate literal paste text. It also resets transient + /// footer mode so the visible hints match the new editing surface. + 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); + } + + /// Toggle Vim editing and return the new enabled state. + /// + /// This is the app-level command target for the configurable Vim toggle + /// keybinding; callers should use the returned value for status messages + /// instead of rereading state after additional composer mutations. + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = !self.textarea.is_vim_enabled(); + self.set_vim_enabled(enabled); + enabled + } + + /// Return whether Vim editing is enabled for tests that assert mode transitions. + #[cfg(test)] + pub(crate) fn is_vim_enabled(&self) -> bool { + self.textarea.is_vim_enabled() + } + + /// Return whether Escape should be routed to the textarea before popups. + /// + /// Vim insert mode owns Escape as a transition back to normal mode. The app + /// event layer asks this before running generic Escape behavior so the same + /// key does not both leave insert mode and dismiss unrelated UI. + pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { + self.textarea.should_handle_vim_insert_escape(key_event) + } + + 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) + .or_else(|| goal_status_indicator_line(self.goal_status_indicator.as_ref())) + { + 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 { @@ -1188,6 +1261,11 @@ impl ChatComposer { fn history_navigation_cursor(&self) -> usize { if self.is_bash_mode && self.textarea.cursor() == 0 { 0 + } else if self.textarea.is_vim_normal_mode() + && !self.textarea.text().is_empty() + && self.textarea.cursor() == self.textarea.vim_normal_end_cursor() + { + self.current_text().len() } else { self.current_cursor() } @@ -1271,6 +1349,16 @@ impl ChatComposer { self.sync_popups(); } + fn move_cursor_to_history_entry_end(&mut self) { + let cursor = if self.textarea.is_vim_normal_mode() { + self.textarea.vim_normal_end_cursor() + } else { + self.textarea.text().len() + }; + self.textarea.set_cursor(cursor); + self.sync_popups(); + } + /// Convert canonical composer text into the textarea's internal representation. /// /// Shell mode stores the leading `!` as prompt state instead of editable text, @@ -1336,7 +1424,7 @@ impl ChatComposer { /// Rehydrate a history entry into the composer with shell-like cursor placement. /// /// This path restores text, elements, images, mention bindings, and pending paste payloads, - /// then moves the cursor to end-of-line. If a caller reused + /// then moves the cursor to the active mode's history boundary. If a caller reused /// [`Self::set_text_content_with_mention_bindings`] directly for history recall and forgot the /// final cursor move, repeated Up/Down would stop navigating history because cursor-gating /// treats interior positions as normal editing mode. @@ -1357,7 +1445,7 @@ impl ChatComposer { mention_bindings, ); self.set_pending_pastes(pending_pastes); - self.move_cursor_to_end(); + self.move_cursor_to_history_entry_end(); } pub(crate) fn text_elements(&self) -> Vec { @@ -1568,6 +1656,7 @@ impl ChatComposer { ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; + self.reset_vim_mode_after_successful_dispatch(&result.0); // Update (or hide/show) popup after processing the key. self.sync_popups(); result @@ -1743,7 +1832,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)); @@ -1800,7 +1889,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 @@ -2566,7 +2655,21 @@ impl ChatComposer { /// Common logic for handling message submission/queuing. /// Returns the appropriate InputResult based on `should_queue`. fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { - self.handle_submission_with_time(should_queue, Instant::now()) + let result = self.handle_submission_with_time(should_queue, Instant::now()); + self.reset_vim_mode_after_successful_dispatch(&result.0); + result + } + + fn reset_vim_mode_after_successful_dispatch(&mut self, result: &InputResult) { + if matches!( + result, + InputResult::Submitted { .. } + | InputResult::Queued { .. } + | InputResult::Command(_) + | InputResult::CommandWithArgs(_, _, _) + ) { + self.textarea.enter_vim_normal_mode(); + } } fn handle_submission_with_time( @@ -2981,6 +3084,47 @@ impl ChatComposer { return (InputResult::None, true); } } + if self.should_handle_vim_insert_escape(key_event) { + return self.handle_input_basic(key_event); + } + if self.textarea.is_vim_normal_mode() && self.textarea.is_vim_operator_pending() { + return self.handle_input_basic(key_event); + } + if self.textarea.is_vim_normal_mode() + && self.is_empty() + && matches!( + key_event, + KeyEvent { + code: KeyCode::Char('/'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.textarea.set_text_clearing_elements("/"); + self.textarea.set_cursor(self.textarea.text().len()); + self.textarea.enter_vim_insert_mode(); + return (InputResult::None, true); + } + if self.textarea.is_vim_normal_mode() + && self.is_empty() + && matches!( + key_event, + KeyEvent { + code: KeyCode::Char('!'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.is_bash_mode = true; + self.textarea.enter_vim_insert_mode(); + return (InputResult::None, true); + } if key_event.code == KeyCode::Esc { if self.is_empty() { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); @@ -3002,48 +3146,51 @@ impl ChatComposer { return self.handle_submission(/*should_queue*/ false); } - match key_event { - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } if self.is_empty() => (InputResult::None, false), - // ------------------------------------------------------------- - // History navigation (Up / Down) – only when the composer is not - // empty or when the cursor is at the correct position, to avoid - // interfering with normal cursor movement. - // ------------------------------------------------------------- - KeyEvent { - code: KeyCode::Up | KeyCode::Down, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. + if let KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } = key_event + && self.is_empty() + { + return (InputResult::None, false); + } + + let (history_up_pressed, history_down_pressed) = if self.textarea.is_vim_normal_mode() { + if self.textarea.is_vim_operator_pending() { + (false, false) + } else { + ( + self.vim_normal_keymap.move_up.is_pressed(key_event), + self.vim_normal_keymap.move_down.is_pressed(key_event), + ) } - | KeyEvent { - code: KeyCode::Char('p') | KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if self.history.should_handle_navigation( - &self.current_text(), - self.history_navigation_cursor(), - ) { - let replace_entry = match key_event.code { - KeyCode::Up => self.history.navigate_up(&self.app_event_tx), - KeyCode::Down => self.history.navigate_down(&self.app_event_tx), - KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx), - KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx), - _ => unreachable!(), - }; - if let Some(entry) = replace_entry { - self.apply_history_entry(entry); - return (InputResult::None, true); - } + } else { + ( + self.editor_keymap.move_up.is_pressed(key_event), + self.editor_keymap.move_down.is_pressed(key_event), + ) + }; + if history_up_pressed || history_down_pressed { + if self + .history + .should_handle_navigation(&self.current_text(), self.history_navigation_cursor()) + { + let replace_entry = if history_up_pressed { + self.history.navigate_up(&self.app_event_tx) + } else { + self.history.navigate_down(&self.app_event_tx) + }; + if let Some(entry) = replace_entry { + self.apply_history_entry(entry); + return (InputResult::None, true); } - self.handle_input_basic(key_event) } - input => self.handle_input_basic(input), + return self.handle_input_basic(key_event); } + + self.handle_input_basic(key_event) } fn is_bang_shell_command(&self) -> bool { @@ -3136,7 +3283,7 @@ impl ChatComposer { } = input { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst { + if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid // holding the first char while still allowing burst detection for paste input. if !ch.is_ascii() { @@ -3213,7 +3360,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 { @@ -3339,8 +3486,6 @@ impl ChatComposer { quit_shortcut_key: self.quit_shortcut_key, collaboration_modes_enabled: self.collaboration_modes_enabled, is_wsl, - context_window_percent: self.context_window_percent, - context_window_used_tokens: self.context_window_used_tokens, status_line_value: self.status_line_value.clone(), status_line_enabled: self.status_line_enabled, key_hints: FooterKeyHints { @@ -4015,6 +4160,14 @@ impl Renderable for ChatComposer { self.textarea.cursor_pos_with_state(textarea_rect, state) } + fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { + if self.textarea.uses_vim_insert_cursor() { + crossterm::cursor::SetCursorStyle::SteadyBar + } else { + crossterm::cursor::SetCursorStyle::DefaultUserShape + } + } + fn desired_height(&self, width: u16) -> u16 { let footer_props = self.footer_props(); let footer_hint_height = self @@ -4158,16 +4311,8 @@ impl ChatComposer { } else if let Some(line) = self.shell_mode_footer_line() { Some(line) } else if status_line_active { - let full = status_line_right_indicator( - self.collaboration_mode_indicator, - self.goal_status_indicator.as_ref(), - show_cycle_hint, - ); - let compact = status_line_right_indicator( - self.collaboration_mode_indicator, - self.goal_status_indicator.as_ref(), - /*show_cycle_hint*/ false, - ); + let full = self.mode_indicator_line(show_cycle_hint); + let compact = self.mode_indicator_line(/*show_cycle_hint*/ false); let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); if can_show_left_with_context(hint_rect, left_width, full_width) { full @@ -4175,10 +4320,7 @@ impl ChatComposer { compact } } else { - Some(context_window_line( - footer_props.context_window_percent, - footer_props.context_window_used_tokens, - )) + Some(self.right_footer_line_with_context()) }; let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); if status_line_active @@ -5206,6 +5348,207 @@ mod tests { assert!(!composer.esc_backtrack_hint); } + #[test] + fn empty_vim_insert_escape_enters_normal_without_esc_hint() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert!(composer.is_empty()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert!(composer.is_empty()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert!(!composer.esc_backtrack_hint); + } + + #[test] + fn slash_opens_command_popup_in_vim_normal_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert_eq!(composer.textarea.text(), "/"); + assert_eq!(composer.textarea.cursor(), "/".len()); + assert!(matches!(composer.active_popup, ActivePopup::Command(_))); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + } + + #[test] + fn slash_command_can_be_typed_and_dispatched_after_vim_normal_slash() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + for ch in ['/', 'd', 'i', 'f', 'f'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "/diff"); + assert!(matches!(composer.active_popup, ActivePopup::Command(_))); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(needs_redraw); + assert!(composer.is_empty()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); + } + + #[test] + fn inline_slash_command_dispatch_resets_vim_mode_to_normal() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new()); + composer.active_popup = ActivePopup::None; + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(needs_redraw); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + match result { + InputResult::CommandWithArgs(cmd, args, text_elements) => { + assert_eq!(cmd, SlashCommand::Plan); + assert_eq!(args, "investigate this"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected CommandWithArgs"), + } + } + + #[test] + fn bang_enters_shell_mode_in_vim_normal_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert!(composer.is_bash_mode); + assert_eq!(composer.current_text(), "!"); + assert_eq!(composer.textarea.text(), ""); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + } + + #[test] + fn shell_command_can_be_typed_after_vim_normal_bang() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + for ch in ['!', 'e', 'c', 'h', 'o'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert!(composer.is_bash_mode); + assert_eq!(composer.current_text(), "!echo"); + assert_eq!(composer.textarea.text(), "echo"); + assert!(matches!(composer.active_popup, ActivePopup::None)); + } + #[test] fn base_footer_mode_tracks_empty_state_after_quit_hint_expires() { use crossterm::event::KeyCode; @@ -5321,7 +5664,187 @@ mod tests { } #[test] - fn clear_for_ctrl_c_preserves_image_draft_state() { + fn vim_mode_resets_to_normal_after_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(/*enabled*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + assert!(composer.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("h".to_string(), Vec::new(), Vec::new()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(composer.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert!(composer.is_empty()); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "h"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn vim_mode_resets_to_normal_after_queued_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(/*enabled*/ true); + composer.set_task_running(/*running*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("queued".to_string(), Vec::new(), Vec::new()); + let (result, _) = composer.handle_submission(/*should_queue*/ true); + + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert!(composer.is_empty()); + match result { + InputResult::Queued { text, .. } => assert_eq!(text, "queued"), + _ => panic!("expected Queued"), + } + } + + #[test] + fn vim_mode_stays_insert_after_suppressed_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(/*enabled*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("/not-a-command".to_string(), Vec::new(), Vec::new()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.textarea.text(), "/not-a-command"); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + } + + #[test] + fn esc_switches_vim_insert_to_normal() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); + composer.textarea.set_cursor(composer.textarea.text().len()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert_eq!(composer.textarea.cursor(), "hey".len()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert_eq!(composer.textarea.cursor(), "he".len()); + } + + #[test] + fn vim_insert_uses_bar_cursor_style() { + use crate::render::renderable::Renderable; + use crossterm::cursor::SetCursorStyle; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use crossterm::queue; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 80, 10); + let style_output = |style| { + let mut output = Vec::new(); + queue!(output, style).expect("queue cursor style"); + output + }; + let default = style_output(SetCursorStyle::DefaultUserShape); + let steady_bar = style_output(SetCursorStyle::SteadyBar); + + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.set_vim_enabled(/*enabled*/ true); + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); + assert_eq!(style_output(composer.cursor_style(area)), steady_bar); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(style_output(composer.cursor_style(area)), default,); + } + + #[test] + fn clear_for_ctrl_c_preserves_image_draft_state() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -8229,6 +8752,202 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn vim_normal_j_k_navigate_history_at_history_boundaries() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_vim_enabled(/*enabled*/ true); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "second"); + assert_eq!(composer.textarea.cursor(), "second".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.textarea.cursor(), "first".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "second"); + assert_eq!(composer.textarea.cursor(), "second".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert!(composer.textarea.is_empty()); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + + #[test] + fn remapped_vim_normal_history_navigation_does_not_fall_back_to_j_k() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let mut keymap = RuntimeKeymap::defaults(); + keymap.vim_normal.move_up = vec![key_hint::plain(KeyCode::F(2))]; + keymap.vim_normal.move_down = vec![key_hint::plain(KeyCode::F(3))]; + composer.set_keymap_bindings(&keymap); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert!(composer.textarea.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "first"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn vim_normal_j_k_fall_back_to_multiline_cursor_movement() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.textarea.set_text_clearing_elements("one\ntwo"); + composer.textarea.set_cursor(/*pos*/ 0); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), "one\n".len()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 0); + } + + #[test] + fn vim_normal_operator_motion_does_not_navigate_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "second"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert!(composer.textarea.is_empty()); + assert_eq!(composer.current_text(), ""); + } + + #[test] + fn vim_normal_operator_pending_consumes_submit_key() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + assert!(composer.textarea.is_vim_operator_pending()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.textarea.text(), "hello"); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert!(!composer.textarea.is_vim_operator_pending()); + } + + #[test] + fn remapped_editor_history_navigation_does_not_fall_back_to_up() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let mut keymap = RuntimeKeymap::defaults(); + keymap.editor.move_up = vec![key_hint::plain(KeyCode::F(2))]; + composer.set_keymap_bindings(&keymap); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(composer.textarea.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "first"); + } + #[test] fn history_navigation_from_start_of_bang_command_recalls_older_entry() { use crossterm::event::KeyCode; @@ -8265,6 +8984,41 @@ mod tests { assert_eq!(composer.current_text(), "first"); } + #[test] + fn vim_normal_history_navigation_from_start_of_bang_command_recalls_older_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_vim_enabled(/*enabled*/ true); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "!git"); + assert_eq!(composer.textarea.cursor(), "git".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "first"); + assert_eq!(composer.textarea.cursor(), "first".len() - 1); + } + #[test] fn set_text_content_reattaches_images_without_placeholder_metadata() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index b75f5e1e8009..20657a2540e4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -572,6 +572,32 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn vim_normal_history_search_preview_places_cursor_on_last_char() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.textarea.cursor(), "git status".len() - 1); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } + #[test] fn history_search_stays_on_single_match_at_boundaries() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index c17f8fb4f355..a28aa4a1caeb 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -74,8 +74,6 @@ pub(crate) struct FooterProps { /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. pub(crate) quit_shortcut_key: KeyBinding, - pub(crate) context_window_percent: Option, - pub(crate) context_window_used_tokens: Option, pub(crate) status_line_value: Option>, pub(crate) status_line_enabled: bool, pub(crate) key_hints: FooterKeyHints, @@ -1243,11 +1241,27 @@ mod tests { ); } + fn snapshot_footer_with_context( + name: &str, + props: FooterProps, + percent: Option, + used_tokens: Option, + ) { + snapshot_footer_with_mode_indicator_and_context( + name, + /*width*/ 80, + &props, + /*collaboration_mode_indicator*/ None, + context_window_line(percent, used_tokens), + ); + } + fn draw_footer_frame( terminal: &mut Terminal, height: u16, props: &FooterProps, collaboration_mode_indicator: Option, + context_line: Line<'static>, ) { terminal .draw(|f| { @@ -1320,10 +1334,7 @@ mod tests { compact } } else { - Some(context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - )) + Some(context_line.clone()) }; let right_width = right_line .as_ref() @@ -1417,21 +1428,50 @@ mod tests { width: u16, props: &FooterProps, collaboration_mode_indicator: Option, + ) { + snapshot_footer_with_mode_indicator_and_context( + name, + width, + props, + collaboration_mode_indicator, + context_window_line(/*percent*/ None, /*used_tokens*/ None), + ); + } + + fn snapshot_footer_with_mode_indicator_and_context( + name: &str, + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + context_line: Line<'static>, ) { let height = footer_height(props).max(1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + context_line, + ); assert_snapshot!(name, terminal.backend()); } - fn render_footer_with_mode_indicator( + fn render_footer_with_mode_indicator_and_context( width: u16, props: &FooterProps, collaboration_mode_indicator: Option, + context_line: Line<'static>, ) -> String { let height = footer_height(props).max(1); let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); - draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + context_line, + ); terminal.backend().vt100().screen().contents() } @@ -1447,8 +1487,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1466,8 +1504,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints { @@ -1488,8 +1524,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1507,8 +1541,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1526,8 +1558,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1545,8 +1575,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1564,8 +1592,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1573,7 +1599,7 @@ mod tests { }, ); - snapshot_footer( + snapshot_footer_with_context( "footer_shortcuts_context_running", FooterProps { mode: FooterMode::ComposerEmpty, @@ -1583,16 +1609,16 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(72), - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, + Some(72), + /*used_tokens*/ None, ); - snapshot_footer( + snapshot_footer_with_context( "footer_context_tokens_used", FooterProps { mode: FooterMode::ComposerEmpty, @@ -1602,13 +1628,13 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: Some(123_456), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, + /*percent*/ None, + Some(123_456), ); snapshot_footer( @@ -1621,8 +1647,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1638,8 +1662,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1668,8 +1690,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1691,8 +1711,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), @@ -1709,8 +1727,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), @@ -1727,8 +1743,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), @@ -1745,19 +1759,18 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: None, // command timed out / empty status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_enabled_mode_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1768,19 +1781,18 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_disabled_context_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1791,8 +1803,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: None, status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), @@ -1800,11 +1810,12 @@ mod tests { }; // has status line and no collaboration mode - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_enabled_no_mode_right", /*width*/ 120, &props, /*collaboration_mode_indicator*/ None, + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1815,8 +1826,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: Some(Line::from( "Status line content that should truncate before the mode indicator".to_string(), )), @@ -1825,11 +1834,12 @@ mod tests { active_agent_label: None, }; - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_truncated_with_gap", /*width*/ 40, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1840,8 +1850,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), @@ -1858,8 +1866,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), @@ -1879,8 +1885,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: Some(Line::from( "Status line content that is definitely too long to fit alongside the mode label" .to_string(), @@ -1890,10 +1894,11 @@ mod tests { active_agent_label: None, }; - let screen = render_footer_with_mode_indicator( + let screen = render_footer_with_mode_indicator_and_context( /*width*/ 80, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let collapsed = screen.split_whitespace().collect::>().join(" "); assert!( diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index a1e3bba5a658..2d0ac3717b4f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -1475,7 +1475,7 @@ mod tests { fn preserve_side_content_bg_keeps_rendered_background_colors() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items: vec![SelectionItem { @@ -1494,7 +1494,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); let area = Rect::new(0, 0, 120, 35); let mut buf = Buffer::empty(area); @@ -1852,7 +1851,7 @@ mod tests { fn enter_with_no_matches_triggers_cancel_callback() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = ListSelectionView::new( + let mut view = new_view( SelectionViewParams { items: vec![SelectionItem { name: "Read Only".to_string(), @@ -1866,7 +1865,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); view.set_search_query("no-matches".to_string()); @@ -1884,7 +1882,7 @@ mod tests { fn move_down_without_selection_change_does_not_fire_callback() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = ListSelectionView::new( + let mut view = new_view( SelectionViewParams { items: vec![SelectionItem { name: "Only choice".to_string(), @@ -1897,7 +1895,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); while rx.try_recv().is_ok() {} @@ -2357,7 +2354,7 @@ mod tests { fn side_layout_width_half_uses_exact_split() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { items: vec![SelectionItem { name: "Item 1".to_string(), @@ -2373,7 +2370,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); let content_width: u16 = 120; @@ -2385,7 +2381,7 @@ mod tests { fn side_layout_width_half_falls_back_when_list_would_be_too_narrow() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { items: vec![SelectionItem { name: "Item 1".to_string(), @@ -2401,7 +2397,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); assert_eq!(view.side_layout_width(/*content_width*/ 80), None); @@ -2411,7 +2406,7 @@ mod tests { fn stacked_side_content_is_used_when_side_by_side_does_not_fit() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items: vec![SelectionItem { @@ -2432,7 +2427,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); let rendered = render_lines_with_width(&view, /*width*/ 70); @@ -2450,7 +2444,7 @@ mod tests { fn side_content_clearing_resets_symbols_and_style() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items: vec![SelectionItem { @@ -2467,7 +2461,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); let width = 120; @@ -2510,7 +2503,7 @@ mod tests { fn side_content_clearing_handles_non_zero_buffer_origin() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items: vec![SelectionItem { @@ -2527,7 +2520,6 @@ mod tests { ..Default::default() }, tx, - crate::keymap::RuntimeKeymap::defaults().list, ); let width = 120; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index bd2656dd6a9c..1264bc987cfc 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -420,6 +420,17 @@ 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(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = self.composer.toggle_vim_enabled(); + self.request_redraw(); + enabled + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -590,6 +601,7 @@ impl BottomPane { && self.is_task_running && !is_agent_command && !self.composer.popup_active() + && !self.composer_should_handle_vim_insert_escape(key_event) && let Some(status) = &self.status { // Send Op::Interrupt @@ -1125,6 +1137,15 @@ impl BottomPane { self.composer.is_empty() } + #[cfg(test)] + pub(crate) fn composer_is_vim_enabled(&self) -> bool { + self.composer.is_vim_enabled() + } + + pub(crate) fn composer_should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { + self.composer.should_handle_vim_insert_escape(key_event) + } + pub(crate) fn is_task_running(&self) -> bool { self.is_task_running } @@ -1563,6 +1584,10 @@ impl Renderable for BottomPane { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.as_renderable().cursor_style(area) + } } #[cfg(test)] diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 7ab6d38fca62..268665ef06d7 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; @@ -95,7 +97,13 @@ pub(crate) struct TextArea { elements: Vec, next_element_id: u64, kill_buffer: String, + kill_buffer_kind: KillBufferKind, + vim_enabled: bool, + vim_mode: VimMode, + vim_operator: Option, editor_keymap: EditorKeymap, + vim_normal_keymap: VimNormalKeymap, + vim_operator_keymap: VimOperatorKeymap, } #[derive(Debug, Clone)] @@ -110,8 +118,55 @@ pub(crate) struct TextAreaState { scroll: u16, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMode { + /// Normal mode routes printable keys to movement, operators, and mode transitions. + Normal, + /// Insert mode routes input through the regular editor keymap until Escape is pressed. + Insert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KillBufferKind { + /// Characterwise kills and yanks paste at the cursor. + Characterwise, + /// Linewise kills and yanks paste as whole lines below the cursor line. + Linewise, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimOperator { + /// Delete the range selected by the next motion or repeated operator key. + Delete, + /// Copy the range selected by the next motion or repeated operator key. + Yank, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMotion { + /// Move one atomic boundary to the left. + Left, + /// Move one atomic boundary to the right. + Right, + /// Move one visual row up, preserving preferred display column. + Up, + /// Move one visual row down, preserving preferred display column. + Down, + /// Move to the start of the next word-like run. + WordForward, + /// Move to the start of the previous word-like run. + WordBackward, + /// Move to the end of the current or next word-like run. + WordEnd, + /// Move to the start of the current line. + LineStart, + /// Move to the end of the current line. + LineEnd, +} + impl TextArea { pub fn new() -> Self { + let defaults = RuntimeKeymap::defaults(); Self { text: String::new(), cursor_pos: 0, @@ -120,18 +175,26 @@ impl TextArea { elements: Vec::new(), next_element_id: 1, kill_buffer: String::new(), - editor_keymap: RuntimeKeymap::defaults().editor, + kill_buffer_kind: KillBufferKind::Characterwise, + vim_enabled: false, + vim_mode: VimMode::Insert, + vim_operator: None, + editor_keymap: defaults.editor, + vim_normal_keymap: defaults.vim_normal, + vim_operator_keymap: defaults.vim_operator, } } - /// Replace the editor keymap used by subsequent text-editing input. + /// Replace the editor and Vim keymaps used by subsequent text-editing input. /// - /// This method intentionally swaps only the keymap cache. It does not - /// reinterpret pending input, move the cursor, or mutate the kill buffer, so - /// callers can safely apply a live config update while preserving the - /// current draft exactly as typed. - pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) { - self.editor_keymap = keymap.clone(); + /// This method intentionally swaps only the keymap caches. It does not + /// reinterpret pending input, change Vim mode, move the cursor, or mutate + /// the kill buffer, so callers can safely apply a live config update while + /// preserving the current draft exactly as typed. + 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. @@ -185,6 +248,121 @@ impl TextArea { self.preferred_col = None; } + /// Enable or disable modal Vim editing for the textarea. + /// + /// Enabling always enters normal mode and disabling always returns to + /// insert semantics. Pending operators are cleared in both directions so a + /// toggle cannot leave the next keypress interpreted as the second half of + /// an old `d` or `y` command. + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.vim_enabled = enabled; + self.vim_operator = None; + self.vim_mode = if enabled { + VimMode::Normal + } else { + VimMode::Insert + }; + } + + /// Return whether modal Vim editing is currently enabled. + pub(crate) fn is_vim_enabled(&self) -> bool { + self.vim_enabled + } + + /// Return whether Vim mode is enabled and currently waiting in normal mode. + /// + /// Composer-level handlers use this to decide whether Up/Down should be + /// offered to history navigation only after normal-mode movement reaches a + /// text boundary. + pub(crate) fn is_vim_normal_mode(&self) -> bool { + self.vim_enabled && self.vim_mode == VimMode::Normal + } + + /// Return the cursor position that represents the last editable item in Vim normal mode. + pub(crate) fn vim_normal_end_cursor(&self) -> usize { + if self.text.is_empty() { + 0 + } else { + self.prev_atomic_boundary(self.text.len()) + } + } + + /// Return whether a Vim operator is waiting for a motion. + /// + /// This is observable so the composer can avoid stealing the second key of + /// `d{motion}` or `y{motion}` for higher-level shortcuts. + pub(crate) fn is_vim_operator_pending(&self) -> bool { + self.vim_operator.is_some() + } + + /// Enter Vim insert mode if modal editing is enabled. + /// + /// Calling this while Vim is disabled is a no-op, which lets parent + /// workflows reset mode after submissions without first branching on the + /// current keymap state. + pub(crate) fn enter_vim_insert_mode(&mut self) { + if self.vim_enabled { + self.vim_mode = VimMode::Insert; + self.vim_operator = None; + } + } + + /// Enter Vim normal mode if modal editing is enabled. + /// + /// This clears any pending operator and preferred vertical column. The + /// latter matches normal Vim navigation expectations after leaving insert + /// mode; preserving the old column would make the next `j` or `k` jump to a + /// stale visual target. + pub(crate) fn enter_vim_normal_mode(&mut self) { + if self.vim_enabled { + self.vim_mode = VimMode::Normal; + self.vim_operator = None; + self.preferred_col = None; + } + } + + /// Return whether rapid plain-key bursts should be treated as paste input. + /// + /// Paste burst detection is disabled in Vim normal mode so a fast sequence + /// like `dd` or `yw` remains command input instead of being converted into + /// literal text. + pub(crate) fn allows_paste_burst(&self) -> bool { + !self.vim_enabled || self.vim_mode == VimMode::Insert + } + + /// Return whether rendering should use the insert-mode cursor style. + pub(crate) fn uses_vim_insert_cursor(&self) -> bool { + self.vim_enabled && self.vim_mode == VimMode::Insert + } + + /// Return whether Escape should be intercepted before composer-level routing. + /// + /// In Vim insert mode, Escape is an editing transition rather than a popup + /// cancel/backtrack shortcut. Letting the composer handle it first would + /// close UI surfaces while leaving the textarea in insert mode. + pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool { + self.vim_enabled + && self.vim_mode == VimMode::Insert + && event.code == KeyCode::Esc + && event.modifiers == KeyModifiers::NONE + && matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + } + + /// Return the footer label for the active Vim mode. + /// + /// `None` means Vim editing is disabled, so callers should omit the mode + /// indicator rather than rendering an insert-mode label for normal + /// non-modal editing. + pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { + if !self.vim_enabled { + return None; + } + Some(match self.vim_mode { + VimMode::Normal => "Normal", + VimMode::Insert => "Insert", + }) + } + pub fn text(&self) -> &str { &self.text } @@ -318,6 +496,15 @@ impl TextArea { self.beginning_of_line(self.cursor_pos) } + fn first_non_blank_of_current_line(&self) -> usize { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + self.text[bol..eol] + .char_indices() + .find_map(|(offset, ch)| (!ch.is_whitespace()).then_some(bol + offset)) + .unwrap_or(eol) + } + fn end_of_line(&self, pos: usize) -> usize { self.text[pos..] .find('\n') @@ -334,8 +521,12 @@ impl TextArea { if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } - let keymap = self.editor_keymap.clone(); - self.input_with_keymap(event, &keymap); + if self.vim_enabled { + self.handle_vim_input(event); + } else { + let keymap = self.editor_keymap.clone(); + self.input_with_keymap(event, &keymap); + } } pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) { @@ -447,10 +638,285 @@ impl TextArea { return; } self.insert_str(&c.to_string()); + } + + tracing::debug!("Unhandled key event in TextArea: {:?}", event); + } + + fn handle_vim_input(&mut self, event: KeyEvent) { + match self.vim_mode { + VimMode::Insert => self.handle_vim_insert(event), + VimMode::Normal => self.handle_vim_normal(event), + } + } + + fn handle_vim_insert(&mut self, event: KeyEvent) { + if matches!(event.code, KeyCode::Esc) { + let bol = self.beginning_of_current_line(); + if self.cursor_pos > bol { + self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol); + } + self.enter_vim_normal_mode(); return; } + let keymap = self.editor_keymap.clone(); + self.input_with_keymap(event, &keymap); + } - let _ = event; + fn handle_vim_normal(&mut self, event: KeyEvent) { + if let Some(op) = self.vim_operator.take() { + self.handle_vim_operator(op, event); + return; + } + + 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.first_non_blank_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"); + let cursor = if eol < self.text.len() { + insert_at + } else { + insert_at + 1 + }; + self.set_cursor(cursor); + 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.vim_word_end_cursor()); + 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.vim_line_end_cursor()); + return; + } + if self.vim_normal_keymap.delete_char.is_pressed(event) { + self.delete_forward_kill(/*n*/ 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 { + 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 { + 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) { + let Some(range) = self.range_for_motion(motion) else { + return; + }; + match op { + VimOperator::Delete => self.kill_range(range), + VimOperator::Yank => self.yank_range(range), + } + } + + fn range_for_motion(&mut self, motion: VimMotion) -> Option> { + if matches!(motion, VimMotion::Up | VimMotion::Down) { + return self.linewise_range_for_vertical_motion(motion); + } + let start = self.cursor_pos; + let target = self.target_for_motion(motion); + if start == target { + return None; + } + let (range_start, range_end) = if target < start { + (target, start) + } else { + (start, target) + }; + Some(range_start..range_end) + } + + fn linewise_range_for_vertical_motion(&self, motion: VimMotion) -> Option> { + let current = self.current_line_range_with_newline(); + let range = match motion { + VimMotion::Up => { + let start = if current.start == 0 { + current.start + } else { + self.beginning_of_line(current.start.saturating_sub(1)) + }; + start..current.end + } + VimMotion::Down => { + let end = if current.end >= self.text.len() { + current.end + } else { + let next_eol = self.end_of_line(current.end); + if next_eol < self.text.len() { + next_eol + 1 + } else { + next_eol + } + }; + current.start..end + } + VimMotion::Left + | VimMotion::Right + | VimMotion::WordForward + | VimMotion::WordBackward + | VimMotion::WordEnd + | VimMotion::LineStart + | VimMotion::LineEnd => return None, + }; + (range.start < range.end).then_some(range) + } + + fn target_for_motion(&mut self, motion: VimMotion) -> usize { + let original_cursor = self.cursor_pos; + let original_preferred = self.preferred_col; + match motion { + VimMotion::Left => self.move_cursor_left(), + VimMotion::Right => self.move_cursor_right(), + VimMotion::Up => self.move_cursor_up(), + VimMotion::Down => self.move_cursor_down(), + VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), + VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), + VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), + VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), + } + let target = self.cursor_pos; + self.cursor_pos = original_cursor; + self.preferred_col = original_preferred; + target } // ####### Input Functions ####### @@ -482,6 +948,20 @@ impl TextArea { self.replace_range(self.cursor_pos..target, ""); } + pub fn delete_forward_kill(&mut self, n: usize) { + if n == 0 || self.cursor_pos >= self.text.len() { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.next_atomic_boundary(target); + if target >= self.text.len() { + break; + } + } + self.kill_range(self.cursor_pos..target); + } + pub fn delete_backward_word(&mut self) { let start = self.beginning_of_previous_word(); self.kill_range(start..self.cursor_pos); @@ -549,6 +1029,14 @@ impl TextArea { } fn kill_range(&mut self, range: Range) { + self.kill_range_with_kind(range, KillBufferKind::Characterwise); + } + + fn kill_line_range(&mut self, range: Range) { + self.kill_range_with_kind(range, KillBufferKind::Linewise); + } + + fn kill_range_with_kind(&mut self, range: Range, kind: KillBufferKind) { let range = self.expand_range_to_element_boundaries(range); if range.start >= range.end { return; @@ -559,10 +1047,87 @@ impl TextArea { return; } - self.kill_buffer = removed; + self.store_kill_buffer(removed, kind); self.replace_range_raw(range, ""); } + fn yank_range(&mut self, range: Range) { + self.yank_range_with_kind(range, KillBufferKind::Characterwise); + } + + fn yank_line_range(&mut self, range: Range) { + self.yank_range_with_kind(range, KillBufferKind::Linewise); + } + + fn yank_range_with_kind(&mut self, range: Range, kind: KillBufferKind) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + let removed = self.text[range].to_string(); + if removed.is_empty() { + return; + } + self.store_kill_buffer(removed, kind); + } + + fn store_kill_buffer(&mut self, text: String, kind: KillBufferKind) { + self.kill_buffer = text; + self.kill_buffer_kind = kind; + } + + fn paste_after_cursor(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + if self.kill_buffer_kind == KillBufferKind::Linewise { + self.paste_line_after_current_line(); + return; + } + let insert_at = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(insert_at); + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn paste_line_after_current_line(&mut self) { + let eol = self.end_of_current_line(); + let insert_at = if eol < self.text.len() { eol + 1 } else { eol }; + let cursor = if eol < self.text.len() { + insert_at + } else { + insert_at + 1 + }; + let text = if eol < self.text.len() { + if self.kill_buffer.ends_with('\n') { + self.kill_buffer.clone() + } else { + format!("{}\n", self.kill_buffer) + } + } else { + format!("\n{}", self.kill_buffer.trim_end_matches('\n')) + }; + self.insert_str_at(insert_at, &text); + self.set_cursor(cursor.min(self.text.len())); + } + + fn yank_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.yank_line_range(range); + } + + fn delete_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.kill_line_range(range); + } + + fn current_line_range_with_newline(&self) -> Range { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + let end = if eol < self.text.len() { eol + 1 } else { eol }; + bol..end + } + /// Move the cursor left by a single grapheme cluster. pub fn move_cursor_left(&mut self) { self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); @@ -1189,6 +1754,44 @@ impl TextArea { self.adjust_pos_out_of_elements(end, /*prefer_start*/ false) } + fn vim_word_end_cursor(&self) -> usize { + let end = self.end_of_next_word(); + if end > self.cursor_pos { + self.prev_atomic_boundary(end) + } else { + end + } + } + + fn vim_line_end_cursor(&self) -> usize { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + if eol > bol { + self.prev_atomic_boundary(eol).max(bol) + } else { + eol + } + } + + pub(crate) fn beginning_of_next_word(&self) -> usize { + let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) + else { + return self.text.len(); + }; + let word_start = self.cursor_pos + first_non_ws; + if word_start != self.cursor_pos { + return self.adjust_pos_out_of_elements(word_start, /*prefer_start*/ true); + } + let end = self.end_of_next_word(); + if end >= self.text.len() { + return self.text.len(); + } + let Some(next_non_ws) = self.text[end..].find(|c: char| !c.is_whitespace()) else { + return self.text.len(); + }; + self.adjust_pos_out_of_elements(end + next_non_ws, /*prefer_start*/ true) + } + fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { if let Some(idx) = self.find_element_containing(pos) { let e = &self.elements[idx]; @@ -1408,6 +2011,7 @@ impl TextArea { #[cfg(test)] mod tests { use super::*; + use crate::key_hint; // crossterm types are intentionally not imported here to avoid unused warnings use pretty_assertions::assert_eq; use rand::prelude::*; @@ -1564,6 +2168,234 @@ mod tests { assert_eq!(t.cursor(), elem_start); } + #[test] + fn vim_insert_and_escape() { + let mut t = TextArea::new(); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.text(), "h"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn vim_insert_key_enters_insert_mode() { + let mut t = TextArea::new(); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "h"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_normal_arrow_keys_move_cursor() { + let mut t = ta_with("ab\ncd"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + + t.input(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(t.cursor(), 5); + + t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(t.cursor(), 4); + + t.input(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn vim_escape_from_insert_at_start_does_not_underflow() { + let mut t = TextArea::new(); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn vim_escape_from_insert_at_line_start_stays_on_line() { + let mut t = ta_with("one\ntwo"); + t.set_cursor(/*pos*/ "one\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.cursor(), "one\n".len()); + } + + #[test] + fn vim_escape_moves_by_grapheme_boundary() { + let mut t = ta_with("👍👍"); + t.set_cursor(t.text().len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.cursor(), "👍".len()); + } + + #[test] + fn vim_escape_respects_atomic_element_boundary() { + let mut t = TextArea::new(); + t.insert_str("a"); + t.insert_element(""); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn vim_shift_i_enters_insert_at_first_non_blank_with_shift_only_binding() { + let mut t = ta_with("hello\n world"); + t.vim_normal_keymap.insert_line_start = vec![key_hint::shift(KeyCode::Char('i'))]; + t.set_cursor(/*pos*/ "hello\n wor".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "hello\n ".len()); + } + + #[test] + fn vim_shift_a_enters_insert_at_line_end_with_shift_only_binding() { + let mut t = ta_with("hello\nworld"); + t.vim_normal_keymap.append_line_end = vec![key_hint::shift(KeyCode::Char('a'))]; + t.set_cursor(/*pos*/ 8); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)); + + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 11); + } + + #[test] + fn vim_shift_o_opens_line_above_with_shift_only_binding() { + let mut t = ta_with("hello\nworld"); + t.vim_normal_keymap.open_line_above = vec![key_hint::shift(KeyCode::Char('o'))]; + t.set_cursor(/*pos*/ 8); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\n\nworld"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn vim_o_opens_line_below_on_inserted_line() { + let mut t = ta_with("one\ntwo"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\n\ntwo"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "one\n".len()); + } + + #[test] + fn vim_delete_word() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + } + + #[test] + fn vim_operator_invalid_motion_is_consumed() { + let mut t = ta_with("hello"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + assert!(t.is_vim_operator_pending()); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.cursor(), 0); + assert!(!t.is_vim_operator_pending()); + } + + #[test] + fn vim_e_lands_on_word_end_character() { + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), 2); + + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "ab"); + assert_eq!(t.kill_buffer, "c"); + } + + #[test] + fn vim_dollar_lands_on_line_end_character() { + let mut t = ta_with("abc\n123"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), 2); + + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "ab\n123"); + assert_eq!(t.kill_buffer, "c"); + } + + #[test] + fn vim_linewise_yank_pastes_below_current_line() { + let mut t = ta_with("abc\n123\nxyz"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "abc\nabc\n123\nxyz"); + assert_eq!(t.cursor(), "abc\n".len()); + assert_eq!(t.kill_buffer, "abc\n"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + #[test] fn delete_backward_word_and_kill_line_variants() { // delete backward word at end removes the whole previous word diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9440f2d07ce1..021686679f19 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4964,6 +4964,9 @@ impl ChatWidget { if let Some(keymap) = runtime_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()); @@ -5114,6 +5117,7 @@ impl ChatWidget { && !self.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() + && !self.should_handle_vim_insert_escape(key_event) { self.submit_pending_steers_after_interrupt = true; if !self.submit_op(AppCommand::interrupt()) { @@ -10265,6 +10269,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(), /*hint*/ None); + } + pub(crate) fn submit_user_message_with_mode( &mut self, text: String, @@ -10306,6 +10320,11 @@ impl ChatWidget { self.bottom_pane.is_normal_backtrack_mode() } + pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { + self.bottom_pane + .composer_should_handle_vim_insert_escape(key_event) + } + pub(crate) fn insert_str(&mut self, text: &str) { self.bottom_pane.insert_str(text); } @@ -10840,6 +10859,10 @@ impl Renderable for ChatWidget { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.as_renderable().cursor_style(area) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index b6173ce2e0b4..dd869892b166 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -243,6 +243,9 @@ impl ChatWidget { SlashCommand::Permissions => { self.open_permissions_popup(); } + SlashCommand::Vim => { + self.toggle_vim_mode_and_notify(); + } SlashCommand::Keymap => { self.open_keymap_picker(); } @@ -846,6 +849,7 @@ impl ChatWidget { | SlashCommand::Plugins | SlashCommand::Rollout | SlashCommand::Copy + | SlashCommand::Vim | SlashCommand::Diff | SlashCommand::Rename | SlashCommand::TestApproval => QueueDrain::Continue, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 9303ef7aad59..c376b3aa62f8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -895,6 +895,33 @@ async fn empty_enter_during_task_does_not_queue() { assert!(chat.queued_user_messages.is_empty()); } +#[tokio::test] +async fn pending_steer_esc_does_not_steal_vim_insert_escape() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + + chat.bottom_pane.set_task_running(/*running*/ true); + chat.pending_steers.push_back(pending_steer("queued steer")); + chat.toggle_vim_mode_and_notify(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert!(chat.should_handle_vim_insert_escape(esc)); + chat.handle_key_event(esc); + + assert!(!chat.should_handle_vim_insert_escape(esc)); + assert_eq!(chat.pending_steers.len(), 1); + assert!(!chat.submit_pending_steers_after_interrupt); + assert!(op_rx.try_recv().is_err()); + + chat.handle_key_event(esc); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert!(chat.submit_pending_steers_after_interrupt); +} + #[tokio::test] async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 849580997e1d..b6afdf0f7595 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1487,13 +1487,46 @@ 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(), + crate::legacy_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.composer_is_empty()); + let mut chat = chat; + chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), ""); +} + +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"); @@ -1512,16 +1545,14 @@ async fn collaboration_modes_defaults_to_code_on_startup() { status_account_display: None, runtime_model_provider_base_url: None, initial_plan_type: None, - model: Some(resolved_model.clone()), + model: Some(resolved_model), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), 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/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index cadf1fa13f48..1108da6c0f92 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -25,6 +25,7 @@ use std::io; use std::io::Write; use crossterm::cursor::MoveTo; +use crossterm::cursor::SetCursorStyle; use crossterm::queue; use crossterm::style::Colors; use crossterm::style::Print; @@ -78,7 +79,6 @@ fn display_width(s: &str) -> usize { visible.width() } -#[derive(Debug, Hash)] pub struct Frame<'a> { /// Where should the cursor be after drawing this frame? /// @@ -86,6 +86,9 @@ pub struct Frame<'a> { /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. pub(crate) cursor_position: Option, + /// Visible cursor shape to apply after drawing this frame. + cursor_style: SetCursorStyle, + /// The area of the viewport pub(crate) viewport_area: Rect, @@ -128,6 +131,11 @@ impl Frame<'_> { self.cursor_position = Some(position.into()); } + /// After drawing this frame, set the terminal's visible cursor style. + pub fn set_cursor_style(&mut self, style: SetCursorStyle) { + self.cursor_style = style; + } + /// Gets the buffer that this `Frame` draws into as a mutable reference. pub fn buffer_mut(&mut self) -> &mut Buffer { self.buffer @@ -167,6 +175,10 @@ where #[allow(clippy::print_stderr)] fn drop(&mut self) { // Attempt to restore the cursor state + if let Err(err) = self.reset_cursor_style() { + eprintln!("Failed to reset the cursor style: {err}"); + } + if self.hidden_cursor && let Err(err) = self.show_cursor() { @@ -205,6 +217,7 @@ where pub fn get_frame(&mut self) -> Frame<'_> { Frame { cursor_position: None, + cursor_style: SetCursorStyle::DefaultUserShape, viewport_area: self.viewport_area, buffer: self.current_buffer_mut(), } @@ -362,6 +375,7 @@ where // stdout first. But we also can't keep the frame around, since it holds a &mut to // Buffer. Thus, we're taking the important data out of the Frame and dropping it. let cursor_position = frame.cursor_position; + let cursor_style = frame.cursor_style; // Draw to stdout self.flush()?; @@ -369,6 +383,7 @@ where match cursor_position { None => self.hide_cursor()?, Some(position) => { + self.set_cursor_style(cursor_style)?; self.show_cursor()?; self.set_cursor_position(position)?; } @@ -395,6 +410,16 @@ where Ok(()) } + /// Sets the visible terminal cursor style. + pub fn set_cursor_style(&mut self, style: SetCursorStyle) -> io::Result<()> { + queue!(self.backend, style) + } + + /// Restores the user-configured terminal cursor style. + pub fn reset_cursor_style(&mut self) -> io::Result<()> { + self.set_cursor_style(SetCursorStyle::DefaultUserShape) + } + /// Gets the current cursor position. /// /// This is the position of the cursor after the last draw call. @@ -712,9 +737,110 @@ impl ModifierDiff { mod tests { use super::*; use pretty_assertions::assert_eq; + use ratatui::backend::WindowSize; use ratatui::layout::Rect; use ratatui::style::Style; + struct CaptureBackend { + output: Vec, + size: Size, + cursor: Position, + } + + impl CaptureBackend { + fn new(width: u16, height: u16) -> Self { + Self { + output: Vec::new(), + size: Size { width, height }, + cursor: Position { x: 0, y: 0 }, + } + } + + fn output(&self) -> String { + String::from_utf8_lossy(&self.output).into_owned() + } + } + + impl Write for CaptureBackend { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.output.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl Backend for CaptureBackend { + fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> + where + I: Iterator, + { + Ok(()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + Ok(()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + Ok(()) + } + + fn get_cursor_position(&mut self) -> io::Result { + Ok(self.cursor) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + self.cursor = position.into(); + Ok(()) + } + + fn clear(&mut self) -> io::Result<()> { + Ok(()) + } + + fn clear_region(&mut self, _clear_type: ClearType) -> io::Result<()> { + Ok(()) + } + + fn append_lines(&mut self, _line_count: u16) -> io::Result<()> { + Ok(()) + } + + fn scroll_region_up( + &mut self, + _region: std::ops::Range, + _scroll_by: u16, + ) -> io::Result<()> { + Ok(()) + } + + fn scroll_region_down( + &mut self, + _region: std::ops::Range, + _scroll_by: u16, + ) -> io::Result<()> { + Ok(()) + } + + fn size(&self) -> io::Result { + Ok(self.size) + } + + fn window_size(&mut self) -> io::Result { + Ok(WindowSize { + columns_rows: self.size, + pixels: self.size, + }) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + #[test] fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { let area = Rect::new(0, 0, 3, 2); @@ -760,4 +886,48 @@ mod tests { "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" ); } + + #[test] + fn terminal_draw_applies_requested_cursor_style() { + let mut output = Vec::new(); + let mut terminal = + Terminal::with_options(CaptureBackend::new(/*width*/ 2, /*height*/ 1)) + .expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 2, 1)); + + terminal + .try_draw(|frame| { + frame.set_cursor_style(SetCursorStyle::SteadyBar); + frame.set_cursor_position((0, 0)); + io::Result::Ok(()) + }) + .expect("draw"); + + queue!(output, SetCursorStyle::SteadyBar).expect("queue style"); + let expected = String::from_utf8(output).expect("utf8"); + let actual = terminal.backend().output(); + assert!( + actual.contains(&expected), + "expected terminal output to contain cursor style {expected:?}, got {actual:?}" + ); + } + + #[test] + fn reset_cursor_style_emits_default_user_shape() { + let mut output = Vec::new(); + let mut terminal = + Terminal::with_options(CaptureBackend::new(/*width*/ 2, /*height*/ 1)) + .expect("terminal"); + + terminal.reset_cursor_style().expect("reset cursor style"); + ratatui::backend::Backend::flush(terminal.backend_mut()).expect("flush backend"); + + queue!(output, SetCursorStyle::DefaultUserShape).expect("queue style"); + let expected = String::from_utf8(output).expect("utf8"); + let actual = terminal.backend().output(); + assert!( + actual.contains(&expected), + "expected terminal output to contain cursor style reset {expected:?}, got {actual:?}" + ); + } } diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 9b24d9fbf0e5..f7b4ff398666 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -1,3 +1,12 @@ +//! Key binding primitives and input matching for the TUI. +//! +//! This module provides `KeyBinding`, the runtime representation of a single +//! keybinding (key code + modifier set), along with matching logic that handles +//! cross-terminal inconsistencies in how shifted letters are reported. +//! +//! It also supplies rendering helpers that convert bindings into styled +//! `ratatui::text::Span` values for UI hint display. + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -17,10 +26,13 @@ const SHIFT_PREFIX: &str = "shift + "; /// One concrete key event that can trigger a TUI action. /// -/// The binding stores the terminal key code plus the exact modifier set that -/// must be present on an incoming press or repeat event. It does not model -/// multi-key chords or partial matches; callers that need sequences must keep -/// that state outside this type. +/// Matching via `is_press` handles both exact equality and a shifted-letter +/// compatibility fallback for terminals that report uppercase letters without +/// the SHIFT modifier flag. This means a binding defined as `shift-a` will +/// match a terminal event of either `Shift+a` or plain `A`. +/// +/// This does not model multi-key chords or partial matches; callers that need +/// sequences must keep that state outside this type. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) struct KeyBinding { key: KeyCode, @@ -205,10 +217,29 @@ mod tests { fn shifted_letter_binding_matches_uppercase_char_events() { let binding = shift(KeyCode::Char('a')); + assert!(binding.is_press(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT))); assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE))); assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT))); } + #[test] + fn shift_letter_binding_preserves_other_modifiers_with_uppercase_compat() { + let binding = KeyBinding::new( + KeyCode::Char('i'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + + assert!(binding.is_press(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::CONTROL))); + } + + #[test] + fn shift_letter_binding_does_not_match_plain_lowercase_or_other_uppercase() { + let binding = shift(KeyCode::Char('o')); + + assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))); + assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE))); + } + #[test] fn ctrl_letter_binding_matches_c0_control_char_events() { let binding = ctrl(KeyCode::Char('p')); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 70d2e99571fc..0a75f79020b2 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -44,6 +44,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, @@ -59,9 +61,16 @@ pub(crate) struct AppKeymap { pub(crate) copy: Vec, /// Clear the terminal UI. pub(crate) clear_terminal: Vec, + /// Toggle Vim mode for the composer input. + pub(crate) toggle_vim_mode: Vec, } -/// Main chat-surface keybindings. +/// Chat-level keybindings evaluated at the app event layer. +/// +/// These participate in the first app-scope conflict validation pass alongside +/// `AppKeymap` actions because both are checked before input reaches the +/// composer. Dispatch gating (empty-composer guard for backtrack) happens in +/// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { /// Decrease the active reasoning effort. @@ -72,6 +81,11 @@ pub(crate) struct ChatKeymap { pub(crate) edit_queued_message: Vec, } +/// Composer-level keybindings validated in the second app-scope conflict pass. +/// +/// App-level handlers execute before the composer receives input, so any key +/// bound here that also appears in `AppKeymap` would be silently intercepted. +/// The conflict validator prevents this by checking app + composer uniqueness. #[derive(Clone, Debug)] pub(crate) struct ComposerKeymap { /// Submit current draft. @@ -110,6 +124,62 @@ pub(crate) struct EditorKeymap { pub(crate) yank: Vec, } +/// Vim normal-mode keybindings for modal editing in the composer textarea. +/// +/// Normal mode is the resting state when Vim is enabled. Pressing a movement +/// or editing key here either moves the cursor, triggers an operator-pending +/// state (via `start_delete_operator` / `start_yank_operator`), or transitions +/// to insert mode. Default bindings include both `shift(letter)` and +/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to +/// handle cross-terminal shift-reporting inconsistencies. +#[derive(Clone, Debug, Default)] +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, +} + +/// Vim operator-pending keybindings active after `d` or `y` in normal mode. +/// +/// When an operator (`start_delete_operator` or `start_yank_operator`) is +/// pressed, the next keypress is matched against this context to determine the +/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole +/// line. `Esc` cancels the pending operator and returns to normal mode. +#[derive(Clone, Debug, Default)] +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 { @@ -294,6 +364,11 @@ impl RuntimeKeymap { &defaults.app.clear_terminal, "tui.keymap.global.clear_terminal", )?, + 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 { @@ -346,6 +421,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), @@ -382,6 +512,8 @@ impl RuntimeKeymap { chat, composer, editor, + vim_normal, + vim_operator, pager, list, approval, @@ -403,6 +535,7 @@ impl RuntimeKeymap { open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))], copy: default_bindings![ctrl(KeyCode::Char('o'))], clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], + toggle_vim_mode: default_bindings![], }, chat: ChatKeymap { decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], @@ -463,6 +596,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')), plain(KeyCode::Insert)], + 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')), plain(KeyCode::Left)], + move_right: default_bindings![plain(KeyCode::Char('l')), plain(KeyCode::Right)], + move_up: default_bindings![plain(KeyCode::Char('k')), plain(KeyCode::Up)], + move_down: default_bindings![plain(KeyCode::Char('j')), plain(KeyCode::Down)], + 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'))], @@ -536,6 +725,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -575,6 +765,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -615,6 +806,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), @@ -662,6 +854,7 @@ impl RuntimeKeymap { self.chat.increase_reasoning_effort.as_slice(), ), ("composer.submit", self.composer.submit.as_slice()), + ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), @@ -747,6 +940,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", [ @@ -1318,6 +1608,47 @@ mod tests { ); } + #[test] + fn vim_normal_defaults_include_insert_and_arrow_aliases() { + let runtime = RuntimeKeymap::defaults(); + + assert_eq!( + runtime.vim_normal.enter_insert, + vec![ + key_hint::plain(KeyCode::Char('i')), + key_hint::plain(KeyCode::Insert) + ] + ); + assert_eq!( + runtime.vim_normal.move_left, + vec![ + key_hint::plain(KeyCode::Char('h')), + key_hint::plain(KeyCode::Left) + ] + ); + assert_eq!( + runtime.vim_normal.move_right, + vec![ + key_hint::plain(KeyCode::Char('l')), + key_hint::plain(KeyCode::Right) + ] + ); + assert_eq!( + runtime.vim_normal.move_up, + vec![ + key_hint::plain(KeyCode::Char('k')), + key_hint::plain(KeyCode::Up) + ] + ); + assert_eq!( + runtime.vim_normal.move_down, + vec![ + key_hint::plain(KeyCode::Char('j')), + key_hint::plain(KeyCode::Down) + ] + ); + } + #[test] fn invalid_global_copy_binding_reports_global_path() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index cc6a769a5c06..78a2d53bdd6c 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1,7 +1,21 @@ //! Guided keymap remapping UI for `/keymap`. //! -//! Pick an action, choose whether to set or remove its root-level custom -//! binding, then validate and persist the resulting runtime keymap. +//! This module owns the interactive editing flow that starts from a resolved +//! [`RuntimeKeymap`] and produces a new root-level [`TuiKeymap`] override. The +//! picker and action menus show users the currently active binding, which may +//! come from defaults, global fallback, or explicit config, while writes always +//! target the concrete `tui.keymap..` slot selected by the +//! user. +//! +//! The flow is intentionally split into three steps: choose an action, choose +//! whether to replace/add/remove a binding, then capture exactly one terminal +//! key event. Validation happens after capture by reusing runtime keymap +//! resolution, so conflict rules stay centralized in `keymap.rs` instead of +//! being duplicated in the UI. +//! +//! This module does not persist config files directly. It emits app events with +//! the edited config so the app layer can decide how to save, reload, and +//! surface errors. mod actions; mod picker; @@ -47,14 +61,14 @@ pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-bin #[derive(Debug, PartialEq, Eq)] pub(crate) enum KeymapEditOutcome { + /// The edit produced a new config snapshot and user-facing status message. Updated { keymap_config: Box, bindings: Vec, message: String, }, - Unchanged { - message: String, - }, + /// The requested edit resolved to the same effective binding set. + Unchanged { message: String }, } fn key_binding_span(binding: &str) -> ratatui::text::Span<'static> { @@ -109,6 +123,13 @@ fn action_menu_item( } } +/// Build the action-specific menu after a user chooses a shortcut row. +/// +/// The menu is based on both active runtime bindings and root config state: the +/// active bindings decide whether replace/add choices are available, while the +/// config state decides whether "remove custom binding" can restore fallback +/// behavior. Passing stale context/action strings yields a generic fallback +/// menu rather than panicking, because selection views can outlive config reloads. pub(crate) fn build_keymap_action_menu_params( context: String, action: String, @@ -362,6 +383,12 @@ pub(crate) fn build_keymap_conflict_params( } } +/// Build the transient capture view for the selected keymap edit. +/// +/// The view displays the current binding summary from the latest runtime map +/// and then delegates the captured key back to the app event loop. Unknown +/// actions are rendered as unbound so the eventual edit path can report the +/// stale selection with a precise error. pub(crate) fn build_keymap_capture_view( context: String, action: String, @@ -393,6 +420,14 @@ fn keymap_with_replacement( keymap_with_bindings(keymap, context, action, &[key.to_string()]) } +/// Apply a captured key to one action and return the edited root config. +/// +/// The current effective bindings come from `runtime_keymap`, so adding an +/// alternate to a default-only action first materializes those defaults into +/// root config before appending the captured key. Replacing one binding guards +/// against stale menus by requiring the selected `old_key` to still be active; +/// otherwise a user could overwrite a binding that changed after the menu was +/// opened. pub(crate) fn keymap_with_edit( keymap: &TuiKeymap, runtime_keymap: &RuntimeKeymap, @@ -481,6 +516,12 @@ fn keymap_with_bindings( Ok(keymap) } +/// Return the active config key specs for one runtime action. +/// +/// This converts resolved [`crate::key_hint::KeyBinding`] values back into +/// canonical config strings for display and for edit operations that need to +/// preserve existing bindings. Callers should treat errors as stale UI state, +/// because valid menu entries should always point at known actions. pub(crate) fn active_binding_specs( runtime_keymap: &RuntimeKeymap, context: &str, @@ -504,6 +545,11 @@ fn dedup_bindings(bindings: Vec) -> Vec { }) } +/// Remove the root-level custom binding for one action. +/// +/// Clearing the slot with `None` is different from setting an empty binding +/// list: `None` restores default/global fallback behavior, while an empty list +/// explicitly unbinds the action in runtime resolution. pub(crate) fn keymap_without_custom_binding( keymap: &TuiKeymap, context: &str, @@ -525,6 +571,12 @@ fn has_custom_binding(keymap: &TuiKeymap, context: &str, action: &str) -> Result Ok(slot.is_some()) } +/// Bottom-pane view that captures a single key event for a pending `/keymap` edit. +/// +/// The view is deliberately transient: it renders instructions, accepts one +/// keypress, and emits the captured key to the app layer. It does not mutate +/// config itself, because mutation needs the latest runtime keymap to detect +/// conflicts and stale selections. pub(crate) struct KeymapCaptureView { context: String, action: String, @@ -874,6 +926,7 @@ mod tests { "Composer.queue", "Global.open_external_editor", "Global.copy", + "Global.toggle_vim_mode", "Editor.delete_backward_word", "Editor.delete_forward_word", "Editor.move_word_left", @@ -984,8 +1037,9 @@ mod tests { let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); assert_eq!(unbound_tab.items.len(), 1); - assert_eq!(unbound_tab.items[0].name, "No unbound shortcuts"); - assert!(unbound_tab.items[0].is_disabled); + assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode"); + assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound")); + assert!(!unbound_tab.items[0].is_disabled); } #[test] diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 22bf7b217ca0..40c6c1bb74d3 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -1,4 +1,15 @@ //! Catalog and accessors for keymap actions shown by `/keymap`. +//! +//! The descriptor table is the single UI-facing inventory of configurable +//! actions. Each descriptor ties together the config path segment, user-facing +//! context label, stable action name, and short description used by the picker +//! and action menu. +//! +//! The accessors below deliberately mirror the descriptor table for both the +//! editable root config and the resolved runtime keymap. Keeping those matches +//! in one module makes it easier to audit a new action: if it appears in the +//! catalog, it must also be readable from runtime state and writable in +//! `TuiKeymap`. use std::collections::BTreeSet; @@ -10,9 +21,13 @@ use crate::keymap::RuntimeKeymap; #[derive(Clone, Copy, Debug)] pub(super) struct KeymapActionDescriptor { + /// Config context segment, such as `composer` in `tui.keymap.composer.submit`. pub(super) context: &'static str, + /// Human-readable group label shown in the picker. pub(super) context_label: &'static str, + /// Config action segment, such as `submit` in `tui.keymap.composer.submit`. pub(super) action: &'static str, + /// Short user-facing explanation of what the action does. pub(super) description: &'static str, } @@ -36,6 +51,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "open_external_editor", "Open the current draft in an external editor."), action("global", "Global", "copy", "Copy the last agent response to the clipboard."), action("global", "Global", "clear_terminal", "Clear the terminal UI."), + action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -60,6 +76,40 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."), action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."), action("editor", "Editor", "yank", "Paste the kill buffer."), + action("vim_normal", "Vim normal", "enter_insert", "Enter insert mode at the cursor."), + action("vim_normal", "Vim normal", "append_after_cursor", "Enter insert mode after the cursor."), + action("vim_normal", "Vim normal", "append_line_end", "Enter insert mode at end of line."), + action("vim_normal", "Vim normal", "insert_line_start", "Enter insert mode at the first non-blank character."), + action("vim_normal", "Vim normal", "open_line_below", "Open a new line below and enter insert mode."), + action("vim_normal", "Vim normal", "open_line_above", "Open a new line above and enter insert mode."), + action("vim_normal", "Vim normal", "move_left", "Move left in Vim normal mode."), + action("vim_normal", "Vim normal", "move_right", "Move right in Vim normal mode."), + action("vim_normal", "Vim normal", "move_up", "Move up or recall older history in Vim normal mode."), + action("vim_normal", "Vim normal", "move_down", "Move down or recall newer history in Vim normal mode."), + action("vim_normal", "Vim normal", "move_word_forward", "Move to the start of the next word."), + action("vim_normal", "Vim normal", "move_word_backward", "Move to the start of the previous word."), + action("vim_normal", "Vim normal", "move_word_end", "Move to the end of the current or next word."), + action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."), + action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), + action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), + action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), + action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."), + action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."), + action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."), + action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."), + action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."), + action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."), + action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."), + action("vim_operator", "Vim operator", "motion_left", "Operator motion left."), + action("vim_operator", "Vim operator", "motion_right", "Operator motion right."), + action("vim_operator", "Vim operator", "motion_up", "Operator motion up."), + action("vim_operator", "Vim operator", "motion_down", "Operator motion down."), + action("vim_operator", "Vim operator", "motion_word_forward", "Operator motion to start of next word."), + action("vim_operator", "Vim operator", "motion_word_backward", "Operator motion to start of previous word."), + action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."), + action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."), + action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."), + action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."), action("pager", "Pager", "scroll_up", "Scroll up by one row."), action("pager", "Pager", "scroll_down", "Scroll down by one row."), action("pager", "Pager", "page_up", "Scroll up by one page."), @@ -84,6 +134,11 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("approval", "Approval", "cancel", "Cancel an elicitation request."), ]; +/// Convert a stable action identifier into a display label. +/// +/// This is intentionally presentation-only: the returned string must never be +/// parsed back into an action name, because underscores and casing are part of +/// the stable config contract. pub(super) fn action_label(action: &str) -> String { action .split('_') @@ -99,6 +154,12 @@ pub(super) fn action_label(action: &str) -> String { } #[rustfmt::skip] +/// Return the mutable root-config binding slot for one catalog action. +/// +/// The returned `Option` distinguishes three states that the +/// editor must preserve: absent means use fallback/default resolution, `Some` +/// with one or more keys is a custom binding, and `Some(Many([]))` is an +/// explicit unbind. pub(super) fn binding_slot<'a>( keymap: &'a mut TuiKeymap, context: &str, @@ -109,6 +170,7 @@ pub(super) fn binding_slot<'a>( ("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor), ("global", "copy") => Some(&mut keymap.global.copy), ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), + ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -133,6 +195,40 @@ pub(super) fn binding_slot<'a>( ("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start), ("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end), ("editor", "yank") => Some(&mut keymap.editor.yank), + ("vim_normal", "enter_insert") => Some(&mut keymap.vim_normal.enter_insert), + ("vim_normal", "append_after_cursor") => Some(&mut keymap.vim_normal.append_after_cursor), + ("vim_normal", "append_line_end") => Some(&mut keymap.vim_normal.append_line_end), + ("vim_normal", "insert_line_start") => Some(&mut keymap.vim_normal.insert_line_start), + ("vim_normal", "open_line_below") => Some(&mut keymap.vim_normal.open_line_below), + ("vim_normal", "open_line_above") => Some(&mut keymap.vim_normal.open_line_above), + ("vim_normal", "move_left") => Some(&mut keymap.vim_normal.move_left), + ("vim_normal", "move_right") => Some(&mut keymap.vim_normal.move_right), + ("vim_normal", "move_up") => Some(&mut keymap.vim_normal.move_up), + ("vim_normal", "move_down") => Some(&mut keymap.vim_normal.move_down), + ("vim_normal", "move_word_forward") => Some(&mut keymap.vim_normal.move_word_forward), + ("vim_normal", "move_word_backward") => Some(&mut keymap.vim_normal.move_word_backward), + ("vim_normal", "move_word_end") => Some(&mut keymap.vim_normal.move_word_end), + ("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start), + ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), + ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), + ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), + ("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line), + ("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after), + ("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator), + ("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator), + ("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator), + ("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line), + ("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line), + ("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left), + ("vim_operator", "motion_right") => Some(&mut keymap.vim_operator.motion_right), + ("vim_operator", "motion_up") => Some(&mut keymap.vim_operator.motion_up), + ("vim_operator", "motion_down") => Some(&mut keymap.vim_operator.motion_down), + ("vim_operator", "motion_word_forward") => Some(&mut keymap.vim_operator.motion_word_forward), + ("vim_operator", "motion_word_backward") => Some(&mut keymap.vim_operator.motion_word_backward), + ("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end), + ("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start), + ("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end), + ("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel), ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), ("pager", "page_up") => Some(&mut keymap.pager.page_up), @@ -160,6 +256,11 @@ pub(super) fn binding_slot<'a>( } #[rustfmt::skip] +/// Return the resolved runtime bindings for one catalog action. +/// +/// This reads from [`RuntimeKeymap`] rather than root config so UI labels show +/// the actual active binding after defaults, global fallback, explicit +/// unbinding, and duplicate-key validation have already been applied. pub(super) fn bindings_for_action<'a>( runtime_keymap: &'a RuntimeKeymap, context: &str, @@ -170,6 +271,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()), ("global", "copy") => Some(runtime_keymap.app.copy.as_slice()), ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), + ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), @@ -194,6 +296,40 @@ pub(super) fn bindings_for_action<'a>( ("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()), ("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()), ("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()), + ("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()), + ("vim_normal", "append_after_cursor") => Some(runtime_keymap.vim_normal.append_after_cursor.as_slice()), + ("vim_normal", "append_line_end") => Some(runtime_keymap.vim_normal.append_line_end.as_slice()), + ("vim_normal", "insert_line_start") => Some(runtime_keymap.vim_normal.insert_line_start.as_slice()), + ("vim_normal", "open_line_below") => Some(runtime_keymap.vim_normal.open_line_below.as_slice()), + ("vim_normal", "open_line_above") => Some(runtime_keymap.vim_normal.open_line_above.as_slice()), + ("vim_normal", "move_left") => Some(runtime_keymap.vim_normal.move_left.as_slice()), + ("vim_normal", "move_right") => Some(runtime_keymap.vim_normal.move_right.as_slice()), + ("vim_normal", "move_up") => Some(runtime_keymap.vim_normal.move_up.as_slice()), + ("vim_normal", "move_down") => Some(runtime_keymap.vim_normal.move_down.as_slice()), + ("vim_normal", "move_word_forward") => Some(runtime_keymap.vim_normal.move_word_forward.as_slice()), + ("vim_normal", "move_word_backward") => Some(runtime_keymap.vim_normal.move_word_backward.as_slice()), + ("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()), + ("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()), + ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), + ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), + ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), + ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), + ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), + ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), + ("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()), + ("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()), + ("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()), + ("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()), + ("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()), + ("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()), + ("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.as_slice()), + ("vim_operator", "motion_down") => Some(runtime_keymap.vim_operator.motion_down.as_slice()), + ("vim_operator", "motion_word_forward") => Some(runtime_keymap.vim_operator.motion_word_forward.as_slice()), + ("vim_operator", "motion_word_backward") => Some(runtime_keymap.vim_operator.motion_word_backward.as_slice()), + ("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()), + ("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()), + ("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()), + ("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()), ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), @@ -220,6 +356,11 @@ pub(super) fn bindings_for_action<'a>( } } +/// Format a resolved binding list for compact menu display. +/// +/// Duplicate runtime variants that normalize to the same config spec are shown +/// once so compatibility defaults, such as alternate SHIFT reporting forms, do +/// not look like separate user choices. pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String { let mut seen = BTreeSet::new(); let specs = bindings diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index fb3a334a420e..429586bf6019 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -60,6 +60,7 @@ const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "queue"), ("global", "open_external_editor"), ("global", "copy"), + ("global", "toggle_vim_mode"), ("editor", "delete_backward_word"), ("editor", "delete_forward_word"), ("editor", "move_word_left"), @@ -94,6 +95,12 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ description: "Inline editor movement and editing shortcuts.", contexts: &["editor"], }, + KeymapContextTab { + id: "vim-shortcuts", + label: "Vim", + description: "Vim normal-mode and operator shortcuts.", + contexts: &["vim_normal", "vim_operator"], + }, KeymapContextTab { id: "navigation-shortcuts", label: "Navigation", diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index 2bb78a3cdeb6..6455b0f78bcc 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crossterm::cursor::SetCursorStyle; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Line; @@ -16,6 +17,9 @@ pub trait Renderable { fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { None } + fn cursor_style(&self, _area: Rect) -> SetCursorStyle { + SetCursorStyle::DefaultUserShape + } } pub enum RenderableItem<'a> { @@ -44,6 +48,13 @@ impl<'a> Renderable for RenderableItem<'a> { RenderableItem::Borrowed(child) => child.cursor_pos(area), } } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + match self { + RenderableItem::Owned(child) => child.cursor_style(area), + RenderableItem::Borrowed(child) => child.cursor_style(area), + } + } } impl<'a> From> for RenderableItem<'a> { @@ -127,6 +138,18 @@ impl Renderable for Option { 0 } } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_ref() + .and_then(|renderable| renderable.cursor_pos(area)) + } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.as_ref() + .map_or(SetCursorStyle::DefaultUserShape, |renderable| { + renderable.cursor_style(area) + }) + } } impl Renderable for Arc { @@ -136,6 +159,12 @@ impl Renderable for Arc { fn desired_height(&self, width: u16) -> u16 { self.as_ref().desired_height(width) } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_ref().cursor_pos(area) + } + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.as_ref().cursor_style(area) + } } pub struct ColumnRenderable<'a> { @@ -180,6 +209,19 @@ impl Renderable for ColumnRenderable<'_> { } None } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + let mut y = area.y; + for child in &self.children { + let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width)) + .intersection(area); + if !child_area.is_empty() && child.cursor_pos(child_area).is_some() { + return child.cursor_style(child_area); + } + y += child_area.height; + } + SetCursorStyle::DefaultUserShape + } } impl<'a> ColumnRenderable<'a> { @@ -304,6 +346,19 @@ impl<'a> Renderable for FlexRenderable<'a> { .zip(self.children.iter()) .find_map(|(rect, child)| child.child.cursor_pos(rect)) } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.allocate(area) + .into_iter() + .zip(self.children.iter()) + .find_map(|(rect, child)| { + child + .child + .cursor_pos(rect) + .map(|_| child.child.cursor_style(rect)) + }) + .unwrap_or(SetCursorStyle::DefaultUserShape) + } } pub struct RowRenderable<'a> { @@ -354,6 +409,19 @@ impl Renderable for RowRenderable<'_> { } None } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + let mut x = area.x; + for (width, child) in &self.children { + let available_width = area.width.saturating_sub(x - area.x); + let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height); + if !child_area.is_empty() && child.cursor_pos(child_area).is_some() { + return child.cursor_style(child_area); + } + x = x.saturating_add(*width); + } + SetCursorStyle::DefaultUserShape + } } impl<'a> RowRenderable<'a> { @@ -385,6 +453,10 @@ impl<'a> Renderable for InsetRenderable<'a> { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.child.cursor_pos(area.inset(self.insets)) } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.child.cursor_style(area.inset(self.insets)) + } } impl<'a> InsetRenderable<'a> { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a18784332354..3d0be1b580fc 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -17,6 +17,7 @@ pub enum SlashCommand { Approvals, Permissions, Keymap, + Vim, #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, #[strum(serialize = "sandbox-add-read-dir")] @@ -117,6 +118,7 @@ impl SlashCommand { SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::Keymap => "remap TUI shortcuts", + SlashCommand::Vim => "toggle Vim mode for the composer", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::SandboxReadRoot => { "let sandbox read a directory: /sandbox-add-read-dir " @@ -178,6 +180,7 @@ impl SlashCommand { | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::Keymap + | SlashCommand::Vim | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot | SlashCommand::Experimental diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 725fa820af46..0633d837ccdc 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -6,6 +6,7 @@ Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the trans Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default +Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default @@ -13,4 +14,3 @@ Submit | enter | Composer submit Submit Submit the current composer draft. enter Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default -History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 114eab926a1a..a9e6b02e80fe 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,18 +5,18 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 50 actions, 1 customized, 0 unbound. + 85 actions, 1 customized, 1 unbound. - [All] Common Customized (1) Unbound (0) App Composer Editor Navigation Approval + [All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval Type to search shortcuts › Global Open Transcript ctrl-t Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. Chat Edit Queued Message alt-up, shift-left - Composer * Submit ctrl-enter left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 395324147ae6..4c5cf695b4f9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,19 +2,21 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (50 selectable) -tab: Common (18 selectable) +tab: All (85 selectable) +tab: Common (19 selectable) tab: Customized (0) (0 selectable) -tab: Unbound (0) (0 selectable) -tab: App (7 selectable) +tab: Unbound (1) (1 selectable) +tab: App (8 selectable) tab: Composer (5 selectable) tab: Editor (16 selectable) +tab: Vim (34 selectable) tab: Navigation (14 selectable) tab: Approval (8 selectable) Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default +Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default @@ -22,4 +24,3 @@ Submit | enter | Composer submit Submit Submit the current composer draft. enter Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default -History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index d044094b0ae0..76a046086841 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,9 +5,9 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 50 actions, 0 customized, 0 unbound. + 85 actions, 0 customized, 1 unbound. - [All] Common Customized (0) Unbound (0) App Composer Editor + [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Type to search shortcuts @@ -15,9 +15,9 @@ expression: "render_picker(params, 78)" Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. Chat Edit Queued Message alt-up, shift-left - Composer Submit enter left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index cfc0a831b98c..674b2caf6cf3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,18 +5,18 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 50 actions, 0 customized, 0 unbound. + 85 actions, 0 customized, 1 unbound. - [All] Common Customized (0) Unbound (0) App Composer Editor Navigation Approval + [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Type to search shortcuts › Global Open Transcript ctrl-t Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. Chat Edit Queued Message alt-up, shift-left - Composer Submit enter left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 30af7804a8d2..431dfb6f0db0 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -14,6 +14,7 @@ use std::time::Duration; use crossterm::Command; use crossterm::SynchronizedUpdate; +use crossterm::cursor::SetCursorStyle; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; @@ -187,7 +188,13 @@ fn restore_common( { first_error.get_or_insert(err); } - let _ = execute!(stdout(), crossterm::cursor::Show); + if let Err(err) = execute!( + stdout(), + SetCursorStyle::DefaultUserShape, + crossterm::cursor::Show + ) { + first_error.get_or_insert(err); + } match first_error { Some(err) => Err(err), None => Ok(()),