fix(tui): route empty vim insert escape to composer

Let Vim Insert handle plain `Esc` before the chat edit-previous and backtrack shortcut layers so an empty composer can still return to Normal mode.

Keep existing empty-composer backtrack behavior after Vim has reached Normal mode, and cover both composer-local and app-level routing.
This commit is contained in:
Felipe Coury
2026-04-23 10:35:34 -03:00
parent ebc6522276
commit 4f8d37cbf8
6 changed files with 91 additions and 1 deletions
+6 -1
View File
@@ -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)
}
}
+23
View File
@@ -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!(
@@ -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<Span<'static>> {
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::<AppEvent>();
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;
+4
View File
@@ -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
}
+8
View File
@@ -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;
+5
View File
@@ -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);
}