Keep backtrack Esc hint gated on empty composer (#5076)

## Summary
- only prime backtrack and show the ESC hint when the composer is empty
- keep the composer-side ESC hint unchanged when drafts or attachments
exist and cover it with a regression test

Fixes #5030

------
https://chatgpt.com/codex/tasks/task_i_68e95ba59cd8832caec8e72ae2efeb55
This commit is contained in:
Jeremy Rose
2025-10-15 13:57:50 -07:00
committed by GitHub
parent 0016346dfb
commit 9b53a306e3
3 changed files with 48 additions and 15 deletions

View File

@@ -427,8 +427,9 @@ impl App {
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
// Esc primes/advances backtracking only in normal (not working) mode // Esc primes/advances backtracking only in normal (not working) mode
// with an empty composer. In any other state, forward Esc so the // with the composer focused and empty. In any other state, forward
// active UI (e.g. status indicator, modals, popups) handles it. // Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent { KeyEvent {
code: KeyCode::Esc, code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat, kind: KeyEventKind::Press | KeyEventKind::Repeat,

View File

@@ -82,15 +82,16 @@ impl App {
/// Handle global Esc presses for backtracking when no overlay is present. /// Handle global Esc presses for backtracking when no overlay is present.
pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {
// Only handle backtracking when composer is empty to avoid clobbering edits. if !self.chat_widget.composer_is_empty() {
if self.chat_widget.composer_is_empty() { return;
if !self.backtrack.primed { }
self.prime_backtrack();
} else if self.overlay.is_none() { if !self.backtrack.primed {
self.open_backtrack_preview(tui); self.prime_backtrack();
} else if self.backtrack.overlay_preview_active { } else if self.overlay.is_none() {
self.step_backtrack_and_highlight(tui); self.open_backtrack_preview(tui);
} } else if self.backtrack.overlay_preview_active {
self.step_backtrack_and_highlight(tui);
} }
} }

View File

@@ -857,10 +857,12 @@ impl ChatComposer {
return (InputResult::None, true); return (InputResult::None, true);
} }
if key_event.code == KeyCode::Esc { if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); if self.is_empty() {
if next_mode != self.footer_mode { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
self.footer_mode = next_mode; if next_mode != self.footer_mode {
return (InputResult::None, true); self.footer_mode = next_mode;
return (InputResult::None, true);
}
} }
} else { } else {
self.footer_mode = reset_mode_after_activity(self.footer_mode); self.footer_mode = reset_mode_after_activity(self.footer_mode);
@@ -1797,6 +1799,35 @@ mod tests {
}); });
} }
#[test]
fn esc_hint_stays_hidden_with_draft_content() {
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(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['d']);
assert!(!composer.is_empty());
assert_eq!(composer.current_text(), "d");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(matches!(composer.active_popup, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(!composer.esc_backtrack_hint);
}
#[test] #[test]
fn question_mark_only_toggles_on_first_char() { fn question_mark_only_toggles_on_first_char() {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;