From eb3f3ee0ddc517251f2333a8cd39aa91de7f92a9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 16:42:56 -0700 Subject: [PATCH 01/11] Pause active goals before Esc interrupt --- codex-rs/tui/src/bottom_pane/mod.rs | 29 +++++++----- codex-rs/tui/src/chatwidget/interaction.rs | 4 ++ .../src/chatwidget/tests/status_and_layout.rs | 46 ++++++++++++++++--- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 951c310d1f8..835c72b7964 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -616,21 +616,10 @@ 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 the // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if self.keymap.chat.interrupt_turn.is_pressed(key_event) - && self.is_task_running - && !(is_agent_command && key_event.code == KeyCode::Esc) - && !self.composer.popup_active() - && !self.composer_should_handle_vim_insert_escape(key_event) + if self.should_interrupt_running_task(key_event) && let Some(status) = &self.status { // Send Op::Interrupt @@ -1306,6 +1295,22 @@ impl BottomPane { self.is_task_running } + pub(crate) fn should_interrupt_running_task(&self, key_event: KeyEvent) -> bool { + let is_agent_command = self + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_some_and(|(name, _, _)| name == "agent"); + + self.keymap.chat.interrupt_turn.is_pressed(key_event) + && self.is_task_running + && !(is_agent_command && key_event.code == KeyCode::Esc) + && self.no_modal_or_popup_active() + && !self.composer_should_handle_vim_insert_escape(key_event) + && self.status.is_some() + } + pub(crate) fn terminal_title_requires_action(&self) -> bool { self.active_view() .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 25e93088da7..bbe37ee42e0 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -133,6 +133,7 @@ impl ChatWidget { && !self.should_handle_vim_insert_escape(key_event) { self.input_queue.submit_pending_steers_after_interrupt = true; + self.pause_active_goal_for_interrupt(); if !self.submit_op(AppCommand::interrupt()) { self.input_queue.submit_pending_steers_after_interrupt = false; } @@ -165,6 +166,9 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); + if self.bottom_pane.should_interrupt_running_task(key_event) { + self.pause_active_goal_for_interrupt(); + } let input_result = self.bottom_pane.handle_key_event(key_event); self.handle_composer_input_result(input_result, had_modal_or_popup); } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 1667501335f..54df3d987c7 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1410,6 +1410,40 @@ async fn streaming_final_answer_keeps_task_running_state() { #[tokio::test] async fn ctrl_c_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); + assert_goal_paused_event(&mut rx, thread_id); +} + +#[tokio::test] +async fn esc_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_goal_paused_event(&mut rx, thread_id); + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); +} + +#[tokio::test] +async fn esc_pending_steers_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + chat.input_queue + .pending_steers + .push_back(pending_steer("pending steer")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); + assert_goal_paused_event(&mut rx, thread_id); +} + +fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); @@ -1430,13 +1464,13 @@ async fn ctrl_c_interrupt_pauses_active_goal_turn() { /*replay_kind*/ None, ); chat.on_task_started(); + thread_id +} - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - - match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), - } +fn assert_goal_paused_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + thread_id: ThreadId, +) { assert_matches!( rx.try_recv(), Ok(AppEvent::SetThreadGoalStatus { From 2bdb36808a5b8f39cb0601b1af916d2ba51bc0eb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 16:53:34 -0700 Subject: [PATCH 02/11] codex: address PR review feedback (#28813) --- codex-rs/tui/src/chatwidget/interaction.rs | 21 ++++++++++++------- .../src/chatwidget/tests/status_and_layout.rs | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index bbe37ee42e0..2e4dcf9e988 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -133,8 +133,9 @@ impl ChatWidget { && !self.should_handle_vim_insert_escape(key_event) { self.input_queue.submit_pending_steers_after_interrupt = true; - self.pause_active_goal_for_interrupt(); - if !self.submit_op(AppCommand::interrupt()) { + if self.submit_op(AppCommand::interrupt()) { + self.pause_active_goal_for_interrupt(); + } else { self.input_queue.submit_pending_steers_after_interrupt = false; } return; @@ -166,10 +167,12 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); - if self.bottom_pane.should_interrupt_running_task(key_event) { + let should_pause_active_goal = + self.bottom_pane.should_interrupt_running_task(key_event); + let input_result = self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { self.pause_active_goal_for_interrupt(); } - let input_result = self.bottom_pane.handle_key_event(key_event); self.handle_composer_input_result(input_result, had_modal_or_popup); } } @@ -382,8 +385,9 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); - self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); + if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + self.pause_active_goal_for_interrupt(); + } } else { self.request_quit_without_confirmation(); } @@ -400,8 +404,9 @@ impl ChatWidget { self.arm_quit_shortcut(key); if self.is_cancellable_work_active() { - self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); + if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + self.pause_active_goal_for_interrupt(); + } } } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 54df3d987c7..47c1e6d6a49 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1425,8 +1425,8 @@ async fn esc_interrupt_pauses_active_goal_turn() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_goal_paused_event(&mut rx, thread_id); assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); } #[tokio::test] From 91ce78ba3230b36d8a5c498d198a86533bb966cf Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 16:59:26 -0700 Subject: [PATCH 03/11] codex: add Esc goal pause snapshot (#28813) --- ...sts__esc_interrupt_goal_paused_footer.snap | 11 ++++++++ .../src/chatwidget/tests/status_and_layout.rs | 28 +++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap new file mode 100644 index 00000000000..a0737a3ab18 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 47c1e6d6a49..c1a84f0af16 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1421,12 +1421,25 @@ async fn ctrl_c_interrupt_pauses_active_goal_turn() { #[tokio::test] async fn esc_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; let thread_id = start_active_goal_turn(&mut chat); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); assert_goal_paused_event(&mut rx, thread_id); + + update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); + let width = 80; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal paused footer"); + assert_chatwidget_snapshot!( + "esc_interrupt_goal_paused_footer", + normalized_backend_snapshot(terminal.backend()) + ); } #[tokio::test] @@ -1447,24 +1460,29 @@ fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); + update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active); + chat.on_task_started(); + thread_id +} + +fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) { let mut goal = test_thread_goal( - codex_app_server_protocol::ThreadGoalStatus::Active, + status, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); - goal.thread_id = thread_id.to_string(); + let thread_id = thread_id.to_string(); + goal.thread_id = thread_id.clone(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), + thread_id, turn_id: None, goal, }, ), /*replay_kind*/ None, ); - chat.on_task_started(); - thread_id } fn assert_goal_paused_event( From 32c06717409234ea408d4d7676eeb38ac29fed7f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:11:51 -0700 Subject: [PATCH 04/11] codex: pause goals for modal interrupts (#28813) --- .../tui/src/bottom_pane/bottom_pane_view.rs | 10 ++++++ codex-rs/tui/src/bottom_pane/mod.rs | 14 ++++++++ .../src/bottom_pane/request_user_input/mod.rs | 15 +++++++++ codex-rs/tui/src/chatwidget/interaction.rs | 18 ++++++++--- .../src/chatwidget/tests/status_and_layout.rs | 32 +++++++++++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index a246c05e570..2f181f9d51e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -67,6 +67,16 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Return true when this key event will interrupt the active agent turn. + fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { + false + } + + /// Return true when Ctrl-C will interrupt the active agent turn. + fn will_interrupt_turn_on_ctrl_c(&self) -> bool { + false + } + /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 835c72b7964..17789a1182e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1320,6 +1320,20 @@ impl BottomPane { !self.view_stack.is_empty() } + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) + } + + pub(crate) fn active_view_will_interrupt_turn_on_ctrl_c(&self) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(BottomPaneView::will_interrupt_turn_on_ctrl_c) + } + #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index acbf6156849..7a2acbc0e64 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1183,6 +1183,21 @@ impl BottomPaneView for RequestUserInputOverlay { true } + fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + key_event.kind != KeyEventKind::Release + && !self.confirm_unanswered_active() + && !(matches!(key_event.code, KeyCode::Esc) + && self.has_options() + && self.notes_ui_visible()) + && self.interrupt_turn_keys.is_pressed(key_event) + } + + fn will_interrupt_turn_on_ctrl_c(&self) -> bool { + self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty() + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 2e4dcf9e988..f3182d4ceae 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -17,7 +17,13 @@ impl ChatWidget { && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(key_event); self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } if self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } @@ -367,6 +373,7 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = self.bottom_pane.active_view_will_interrupt_turn_on_ctrl_c(); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { @@ -377,6 +384,9 @@ impl ChatWidget { self.arm_quit_shortcut(key); } } + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } return; } @@ -403,10 +413,10 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() { - if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { - self.pause_active_goal_for_interrupt(); - } + if self.is_cancellable_work_active() + && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) + { + self.pause_active_goal_for_interrupt(); } } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c1a84f0af16..d120233227e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1456,6 +1456,38 @@ async fn esc_pending_steers_interrupt_pauses_active_goal_turn() { assert_goal_paused_event(&mut rx, thread_id); } +#[tokio::test] +async fn request_user_input_interrupt_pauses_active_goal_turn() { + let cases = [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ]; + + for key_event in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "reasoning_scope".to_string(), + header: "Reasoning scope".to_string(), + question: "Which reasoning scope should I use?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + auto_resolution_ms: None, + }); + + chat.handle_key_event(key_event); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + } +} + fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); From c3b6d7c2011ef6708ee6c3d1e3ffda045d84d0b1 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:15:40 -0700 Subject: [PATCH 05/11] Revert "codex: pause goals for modal interrupts (#28813)" This reverts commit 32c06717409234ea408d4d7676eeb38ac29fed7f. --- .../tui/src/bottom_pane/bottom_pane_view.rs | 10 ------ codex-rs/tui/src/bottom_pane/mod.rs | 14 -------- .../src/bottom_pane/request_user_input/mod.rs | 15 --------- codex-rs/tui/src/chatwidget/interaction.rs | 18 +++-------- .../src/chatwidget/tests/status_and_layout.rs | 32 ------------------- 5 files changed, 4 insertions(+), 85 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 2f181f9d51e..a246c05e570 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -67,16 +67,6 @@ pub(crate) trait BottomPaneView: Renderable { false } - /// Return true when this key event will interrupt the active agent turn. - fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { - false - } - - /// Return true when Ctrl-C will interrupt the active agent turn. - fn will_interrupt_turn_on_ctrl_c(&self) -> bool { - false - } - /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 17789a1182e..835c72b7964 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1320,20 +1320,6 @@ impl BottomPane { !self.view_stack.is_empty() } - pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { - self.is_task_running - && self - .active_view() - .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) - } - - pub(crate) fn active_view_will_interrupt_turn_on_ctrl_c(&self) -> bool { - self.is_task_running - && self - .active_view() - .is_some_and(BottomPaneView::will_interrupt_turn_on_ctrl_c) - } - #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 7a2acbc0e64..acbf6156849 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1183,21 +1183,6 @@ impl BottomPaneView for RequestUserInputOverlay { true } - fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { - key_event.kind != KeyEventKind::Release - && !self.confirm_unanswered_active() - && !(matches!(key_event.code, KeyCode::Esc) - && self.has_options() - && self.notes_ui_visible()) - && self.interrupt_turn_keys.is_pressed(key_event) - } - - fn will_interrupt_turn_on_ctrl_c(&self) -> bool { - self.confirm_unanswered_active() - || !self.focus_is_notes() - || self.composer.current_text_with_pending().is_empty() - } - fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index f3182d4ceae..2e4dcf9e988 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -17,13 +17,7 @@ impl ChatWidget { && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { - let should_pause_active_goal = self - .bottom_pane - .active_view_will_interrupt_turn_on_key_event(key_event); self.bottom_pane.handle_key_event(key_event); - if should_pause_active_goal { - self.pause_active_goal_for_interrupt(); - } if self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } @@ -373,7 +367,6 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); - let should_pause_active_goal = self.bottom_pane.active_view_will_interrupt_turn_on_ctrl_c(); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { @@ -384,9 +377,6 @@ impl ChatWidget { self.arm_quit_shortcut(key); } } - if should_pause_active_goal { - self.pause_active_goal_for_interrupt(); - } return; } @@ -413,10 +403,10 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() - && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) - { - self.pause_active_goal_for_interrupt(); + if self.is_cancellable_work_active() { + if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + self.pause_active_goal_for_interrupt(); + } } } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d120233227e..c1a84f0af16 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1456,38 +1456,6 @@ async fn esc_pending_steers_interrupt_pauses_active_goal_turn() { assert_goal_paused_event(&mut rx, thread_id); } -#[tokio::test] -async fn request_user_input_interrupt_pauses_active_goal_turn() { - let cases = [ - KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ]; - - for key_event in cases { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let thread_id = start_active_goal_turn(&mut chat); - chat.handle_request_user_input_now(ToolRequestUserInputParams { - thread_id: thread_id.to_string(), - item_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - questions: vec![ToolRequestUserInputQuestion { - id: "reasoning_scope".to_string(), - header: "Reasoning scope".to_string(), - question: "Which reasoning scope should I use?".to_string(), - is_other: false, - is_secret: false, - options: None, - }], - auto_resolution_ms: None, - }); - - chat.handle_key_event(key_event); - - assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); - assert_goal_paused_event(&mut rx, thread_id); - } -} - fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); From d8f6d4ec3e70d63268e55cd5a3dd4e387af6aeba Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:17:52 -0700 Subject: [PATCH 06/11] codex: trim goal interrupt tests (#28813) --- ...sts__esc_interrupt_goal_paused_footer.snap | 11 ----- .../src/chatwidget/tests/status_and_layout.rs | 42 +++---------------- 2 files changed, 5 insertions(+), 48 deletions(-) delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap deleted file mode 100644 index a0737a3ab18..00000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/chatwidget/tests/status_and_layout.rs -expression: normalized_backend_snapshot(terminal.backend()) ---- -" " -"• Working (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c1a84f0af16..47f543a3c94 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1421,68 +1421,36 @@ async fn ctrl_c_interrupt_pauses_active_goal_turn() { #[tokio::test] async fn esc_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.show_welcome_banner = false; let thread_id = start_active_goal_turn(&mut chat); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); assert_goal_paused_event(&mut rx, thread_id); - - update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); - let width = 80; - let height = chat.desired_height(width); - let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw goal paused footer"); - assert_chatwidget_snapshot!( - "esc_interrupt_goal_paused_footer", - normalized_backend_snapshot(terminal.backend()) - ); -} - -#[tokio::test] -async fn esc_pending_steers_interrupt_pauses_active_goal_turn() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let thread_id = start_active_goal_turn(&mut chat); - chat.input_queue - .pending_steers - .push_back(pending_steer("pending steer")); - - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - next_interrupt_op(&mut op_rx); - assert_goal_paused_event(&mut rx, thread_id); } fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); - update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active); - chat.on_task_started(); - thread_id -} - -fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) { let mut goal = test_thread_goal( - status, + codex_app_server_protocol::ThreadGoalStatus::Active, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); - let thread_id = thread_id.to_string(); - goal.thread_id = thread_id.clone(); + goal.thread_id = thread_id.to_string(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { - thread_id, + thread_id: thread_id.to_string(), turn_id: None, goal, }, ), /*replay_kind*/ None, ); + chat.on_task_started(); + thread_id } fn assert_goal_paused_event( From 5845155d991f44c89061b1239b0e2a9cd2ea53fc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:26:27 -0700 Subject: [PATCH 07/11] codex: satisfy clippy after trim (#28104) --- codex-rs/tui/src/chatwidget/interaction.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 2e4dcf9e988..fe91a0d2542 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -403,10 +403,10 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() { - if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { - self.pause_active_goal_for_interrupt(); - } + if self.is_cancellable_work_active() + && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) + { + self.pause_active_goal_for_interrupt(); } } From 6526ae96fe512c18cd6e2032339565cb639f5d21 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:33:31 -0700 Subject: [PATCH 08/11] codex: snapshot Esc goal pause feedback (#28104) --- ...sts__esc_interrupt_goal_paused_footer.snap | 11 ++++++++ .../src/chatwidget/tests/status_and_layout.rs | 28 +++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap new file mode 100644 index 00000000000..a0737a3ab18 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 47f543a3c94..5226a3b84af 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1421,36 +1421,54 @@ async fn ctrl_c_interrupt_pauses_active_goal_turn() { #[tokio::test] async fn esc_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; let thread_id = start_active_goal_turn(&mut chat); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); assert_goal_paused_event(&mut rx, thread_id); + + update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); + let width = 80; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal paused footer"); + assert_chatwidget_snapshot!( + "esc_interrupt_goal_paused_footer", + normalized_backend_snapshot(terminal.backend()) + ); } fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); + update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active); + chat.on_task_started(); + thread_id +} + +fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) { let mut goal = test_thread_goal( - codex_app_server_protocol::ThreadGoalStatus::Active, + status, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); - goal.thread_id = thread_id.to_string(); + let thread_id = thread_id.to_string(); + goal.thread_id = thread_id.clone(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), + thread_id, turn_id: None, goal, }, ), /*replay_kind*/ None, ); - chat.on_task_started(); - thread_id } fn assert_goal_paused_event( From e399f7ec5989f5c28eb801da9ed6127624e4652d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:43:42 -0700 Subject: [PATCH 09/11] codex: pause goals for modal interrupts (#28104) --- .../tui/src/bottom_pane/bottom_pane_view.rs | 10 ++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 14 +++++++++++ .../src/bottom_pane/request_user_input/mod.rs | 15 ++++++++++++ codex-rs/tui/src/chatwidget/interaction.rs | 10 ++++++++ .../src/chatwidget/tests/status_and_layout.rs | 23 +++++++++++++++++++ 5 files changed, 72 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index a246c05e570..2f181f9d51e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -67,6 +67,16 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Return true when this key event will interrupt the active agent turn. + fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { + false + } + + /// Return true when Ctrl-C will interrupt the active agent turn. + fn will_interrupt_turn_on_ctrl_c(&self) -> bool { + false + } + /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 835c72b7964..17789a1182e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1320,6 +1320,20 @@ impl BottomPane { !self.view_stack.is_empty() } + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) + } + + pub(crate) fn active_view_will_interrupt_turn_on_ctrl_c(&self) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(BottomPaneView::will_interrupt_turn_on_ctrl_c) + } + #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index acbf6156849..7a2acbc0e64 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1183,6 +1183,21 @@ impl BottomPaneView for RequestUserInputOverlay { true } + fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + key_event.kind != KeyEventKind::Release + && !self.confirm_unanswered_active() + && !(matches!(key_event.code, KeyCode::Esc) + && self.has_options() + && self.notes_ui_visible()) + && self.interrupt_turn_keys.is_pressed(key_event) + } + + fn will_interrupt_turn_on_ctrl_c(&self) -> bool { + self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty() + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index fe91a0d2542..f3182d4ceae 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -17,7 +17,13 @@ impl ChatWidget { && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(key_event); self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } if self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } @@ -367,6 +373,7 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = self.bottom_pane.active_view_will_interrupt_turn_on_ctrl_c(); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { @@ -377,6 +384,9 @@ impl ChatWidget { self.arm_quit_shortcut(key); } } + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } return; } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 5226a3b84af..df1db9f2b08 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1442,6 +1442,29 @@ async fn esc_interrupt_pauses_active_goal_turn() { ); } +#[tokio::test] +async fn request_user_input_interrupt_pauses_active_goal_turn() { + for key_event in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: Vec::new(), + auto_resolution_ms: None, + }); + + chat.handle_key_event(key_event); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + } +} + fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); From 47a42a301958212700b2bf5741bf7bb255a992b3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 17:54:41 -0700 Subject: [PATCH 10/11] codex: add Windows goal pause snapshot (#28104) --- ...dget__tests__esc_interrupt_goal_paused_footer.snap | 2 +- ...sts__esc_interrupt_goal_paused_footer@windows.snap | 11 +++++++++++ .../tui/src/chatwidget/tests/status_and_layout.rs | 11 +++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap index a0737a3ab18..bebbb3ffd59 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests/status_and_layout.rs -expression: normalized_backend_snapshot(terminal.backend()) +expression: snapshot --- " " "• Working (0s • esc to interrupt) " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap new file mode 100644 index 00000000000..1025ae38629 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index df1db9f2b08..3c3de546eea 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1436,10 +1436,13 @@ async fn esc_interrupt_pauses_active_goal_turn() { terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw goal paused footer"); - assert_chatwidget_snapshot!( - "esc_interrupt_goal_paused_footer", - normalized_backend_snapshot(terminal.backend()) - ); + let snapshot = normalized_backend_snapshot(terminal.backend()); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); + }); + #[cfg(not(target_os = "windows"))] + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); } #[tokio::test] From 4917655ca3f4b30b18c76c1b53fc6d06f2840439 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 17 Jun 2026 19:26:21 -0700 Subject: [PATCH 11/11] codex: simplify modal interrupt prediction (#28104) --- codex-rs/tui/src/bottom_pane/bottom_pane_view.rs | 5 ----- codex-rs/tui/src/bottom_pane/mod.rs | 7 ------- .../tui/src/bottom_pane/request_user_input/mod.rs | 12 ++++++------ codex-rs/tui/src/chatwidget/interaction.rs | 7 ++++++- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 2f181f9d51e..551bacd25ac 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -72,11 +72,6 @@ pub(crate) trait BottomPaneView: Renderable { false } - /// Return true when Ctrl-C will interrupt the active agent turn. - fn will_interrupt_turn_on_ctrl_c(&self) -> bool { - false - } - /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 17789a1182e..7fffdeb98e4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1327,13 +1327,6 @@ impl BottomPane { .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) } - pub(crate) fn active_view_will_interrupt_turn_on_ctrl_c(&self) -> bool { - self.is_task_running - && self - .active_view() - .is_some_and(BottomPaneView::will_interrupt_turn_on_ctrl_c) - } - #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 7a2acbc0e64..bf1fa5fa8ae 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1184,6 +1184,12 @@ impl BottomPaneView for RequestUserInputOverlay { } fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + if KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL).is_press(key_event) { + return self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty(); + } + key_event.kind != KeyEventKind::Release && !self.confirm_unanswered_active() && !(matches!(key_event.code, KeyCode::Esc) @@ -1192,12 +1198,6 @@ impl BottomPaneView for RequestUserInputOverlay { && self.interrupt_turn_keys.is_pressed(key_event) } - fn will_interrupt_turn_on_ctrl_c(&self) -> bool { - self.confirm_unanswered_active() - || !self.focus_is_notes() - || self.composer.current_text_with_pending().is_empty() - } - fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index f3182d4ceae..74fc3d5e52e 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -373,7 +373,12 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); - let should_pause_active_goal = self.bottom_pane.active_view_will_interrupt_turn_on_ctrl_c(); + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active {