diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 7c26f920eb..9355acdc92 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -153,7 +153,7 @@ impl App { return; } - if self.keymap.chat.edit_previous_message.is_pressed(key_event) { + if self.should_route_edit_previous_message(key_event) { // Esc primes/advances backtracking only in normal (not working) mode // with the composer focused and empty. In any other state, forward // Esc so the active UI (e.g. status indicator, modals, popups) @@ -226,4 +226,9 @@ impl App { pub(super) fn refresh_status_line(&mut self) { self.chat_widget.refresh_status_line(); } + + pub(super) fn should_route_edit_previous_message(&self, key_event: KeyEvent) -> bool { + self.keymap.chat.edit_previous_message.is_pressed(key_event) + && !self.chat_widget.should_handle_vim_insert_escape(key_event) + } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d45c75bd7d..34b5a58abe 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4824,6 +4824,29 @@ 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 edit_previous_shortcut_does_not_steal_empty_vim_insert_escape() { + let mut app = make_test_app().await; + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + + assert!(app.chat_widget.composer_is_empty()); + assert!(app.should_route_edit_previous_message(esc)); + + app.chat_widget.toggle_vim_mode_and_notify(); + assert!(app.should_route_edit_previous_message(esc)); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert!(app.chat_widget.should_handle_vim_insert_escape(esc)); + assert!(!app.should_route_edit_previous_message(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_route_edit_previous_message(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 75b1dce595..be214822fe 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -970,6 +970,10 @@ impl ChatComposer { self.textarea.is_vim_enabled() } + 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(), @@ -2922,6 +2926,9 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } + if self.should_handle_vim_insert_escape(key_event) { + return self.handle_input_basic(key_event); + } if key_event.code == KeyCode::Esc { if self.is_empty() { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); @@ -5040,6 +5047,44 @@ 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(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 base_footer_mode_tracks_empty_state_after_quit_hint_expires() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 342995fece..431f28fd18 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -963,6 +963,10 @@ impl BottomPane { 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 } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 0e900819ae..068c04488b 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -241,6 +241,14 @@ impl TextArea { self.vim_enabled && self.vim_mode == VimMode::Insert } + 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) + } + pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { if !self.vim_enabled { return None; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 042cde8d4e..ce230ffbdc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11342,6 +11342,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); }