fix(tui): let esc exit empty shell mode (#19986)

## Summary

- exit shell mode when `Esc` is pressed while the absorbed `!` is the
only input
- add direct regression coverage plus a composer snapshot for the
restored normal prompt state

## Root cause

Shell mode stores the leading `!` outside the editable textarea. After
typing only `!`, the textarea is empty but the composer is still in bash
mode, so the existing empty-composer `Esc` handling never runs.

## Validation

- `just fmt`
- `cargo test -p codex-tui
bottom_pane::chat_composer::tests::esc_exits_empty_shell_mode`
- `cargo test -p codex-tui
bottom_pane::chat_composer::tests::footer_mode_snapshots`
- `cargo insta pending-snapshots`

`cargo test -p codex-tui` still reports unrelated existing `/status`
snapshot drift in this local environment because the rendered
permissions text is `workspace-write with network access` instead of the
older `read-only` fixture text.
This commit is contained in:
Felipe Coury
2026-04-28 14:35:24 -03:00
committed by GitHub
Unverified
parent bc5a1b961e
commit a036584104
2 changed files with 95 additions and 0 deletions
@@ -2972,6 +2972,15 @@ impl ChatComposer {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if self.is_bash_mode && key_event.code == KeyCode::Esc {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if self.textarea.is_empty() {
self.is_bash_mode = false;
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);
@@ -4790,6 +4799,19 @@ mod tests {
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state(
"footer_mode_shell_command_escape_exits_empty_mode",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from(
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
)));
composer.set_text_content("!".to_string(), Vec::new(), Vec::new());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
}
#[test]
@@ -4852,6 +4874,65 @@ mod tests {
);
}
#[test]
fn esc_exits_empty_shell_mode() {
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*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['!']);
assert!(composer.is_bash_mode);
assert_eq!(composer.current_text(), "!");
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_bash_mode);
assert_eq!(composer.current_text(), "");
}
#[test]
fn esc_keeps_shell_mode_when_paste_burst_flushes_pending_text() {
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*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['!']);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert_eq!(composer.current_text(), "!");
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_bash_mode);
assert_eq!(composer.current_text(), "!g");
}
#[test]
fn footer_collapse_snapshots() {
fn setup_collab_footer(
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 4485
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used "