diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 72ffeace9..ac903d996 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -129,6 +129,7 @@ pub(crate) enum CancellationEvent { NotHandled, } +use crate::bottom_pane::prompt_args::parse_slash_name; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; @@ -398,11 +399,20 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { + let is_agent_command = self + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_some_and(|(name, _, _)| name == "agent"); + // If a task is running and a status line is visible, allow Esc to // send an interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. if key_event.code == KeyCode::Esc + && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running + && !is_agent_command && !self.composer.popup_active() && let Some(status) = &self.status { @@ -1593,6 +1603,90 @@ mod tests { assert_eq!(pane.composer_text(), "/"); } + #[test] + fn esc_with_agent_command_without_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + // Repro: `/agent ` hides the popup (cursor past command name). Esc should + // keep editing command text instead of interrupting the running task. + pane.insert_str("/agent "); + assert!( + !pane.composer.popup_active(), + "expected command popup to be hidden after entering `/agent `" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt while typing `/agent`" + ); + } + assert_eq!(pane.composer_text(), "/agent "); + } + + #[test] + fn esc_release_after_dismissing_agent_picker_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + items: vec![SelectionItem { + name: "Main".to_string(), + ..Default::default() + }], + ..Default::default() + }); + + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Press, + )); + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc release after dismissing agent picker to not interrupt" + ); + } + assert!( + pane.no_modal_or_popup_active(), + "expected Esc press to dismiss the agent picker" + ); + } + #[test] fn esc_interrupts_running_task_when_no_popup() { let (tx_raw, mut rx) = unbounded_channel::();