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();
}
// Esc primes/advances backtracking only in normal (not working) mode
// with an empty composer. In any other state, forward Esc so the
// active UI (e.g. status indicator, modals, popups) handles it.
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,

View File

@@ -82,15 +82,16 @@ impl App {
/// Handle global Esc presses for backtracking when no overlay is present.
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.backtrack.primed {
self.prime_backtrack();
} else if self.overlay.is_none() {
self.open_backtrack_preview(tui);
} else if self.backtrack.overlay_preview_active {
self.step_backtrack_and_highlight(tui);
}
if !self.chat_widget.composer_is_empty() {
return;
}
if !self.backtrack.primed {
self.prime_backtrack();
} else if self.overlay.is_none() {
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);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
if self.is_empty() {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
}
} else {
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]
fn question_mark_only_toggles_on_first_char() {
use crossterm::event::KeyCode;