diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b801d06496..915b599fa5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -14,6 +14,7 @@ use crate::app_event::PermissionProfileSelection; use crate::app_event::PluginLocation; use crate::app_event::PluginRemoteSectionError; use crate::app_event::RateLimitRefreshOrigin; +use crate::app_event::RunningTaskExitAction; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ea83ddb4f3..869623cc53 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -531,6 +531,61 @@ impl App { } return Ok(self.handle_exit_mode(app_server, mode).await); } + AppEvent::RunningTaskExit { action, thread_id } => match action { + RunningTaskExitAction::RunInBackground => { + return Ok(self.handle_exit_mode(app_server, ExitMode::Immediate).await); + } + RunningTaskExitAction::CancelTask => { + if self.chat_widget.thread_id() == Some(thread_id) + && self.chat_widget.is_agent_turn_running() + && self.chat_widget.submit_op(AppCommand::interrupt()) + { + self.chat_widget.pause_active_goal_for_interrupt(); + } else if self.side_threads.contains_key(&thread_id) + && let Err(error) = self + .try_submit_active_thread_op_via_app_server( + app_server, + thread_id, + &AppCommand::interrupt(), + ) + .await + { + self.chat_widget + .add_error_message(format!("Failed to interrupt task: {error}")); + } + } + RunningTaskExitAction::Exit => { + if self.chat_widget.thread_id() == Some(thread_id) + && self.chat_widget.is_active_goal_turn_running() + && let Err(error) = app_server + .thread_goal_set( + thread_id, + /*objective*/ None, + Some(codex_app_server_protocol::ThreadGoalStatus::Paused), + /*token_budget*/ None, + ) + .await + { + self.chat_widget + .add_error_message(format!("Failed to pause task goal: {error}")); + return Ok(AppRunControl::Continue); + } + let turn_id = self + .active_turn_id_for_thread(thread_id) + .await + .unwrap_or_default(); + match app_server.turn_interrupt(thread_id, turn_id).await { + Ok(()) => { + self.app_event_tx + .send(AppEvent::Exit(ExitMode::ShutdownFirst)); + } + Err(error) => { + self.chat_widget + .add_error_message(format!("Failed to interrupt task: {error}")); + } + } + } + }, AppEvent::Logout => match app_server.logout_account().await { Ok(()) => { self.show_shutdown_feedback(tui)?; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index f1a38e04b6..e386834d3c 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -212,6 +212,84 @@ impl App { } return; } + if matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. }) + && self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && self.chat_widget.composer_is_empty() + && self.active_side_parent_thread_id().is_none() + && matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') + ) + { + let mut running_thread_id = if self.chat_widget.is_agent_turn_running() { + self.chat_widget.thread_id() + } else { + None + }; + let mut running_side_thread_id = + running_thread_id.filter(|thread_id| self.side_threads.contains_key(thread_id)); + if running_side_thread_id.is_none() { + for thread_id in self.side_threads.keys().copied() { + if self.active_turn_id_for_thread(thread_id).await.is_some() { + running_side_thread_id = Some(thread_id); + break; + } + } + } + if running_thread_id.is_none() { + running_thread_id = running_side_thread_id; + } + + if let Some(thread_id) = running_thread_id { + let allow_background = running_side_thread_id.is_none() + && !self.chat_widget.has_queued_follow_up_messages(); + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Task is still running".to_string()), + subtitle: Some("Choose what happens to the current task.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: [ + ( + "Cancel task", + "Stop the current task and stay in Codex", + RunningTaskExitAction::CancelTask, + ), + ( + "Run in background", + "Exit Codex and leave the task running", + RunningTaskExitAction::RunInBackground, + ), + ( + "Exit", + "Stop the current task and exit Codex", + RunningTaskExitAction::Exit, + ), + ] + .into_iter() + .filter(|(_, _, action)| { + allow_background || *action != RunningTaskExitAction::RunInBackground + }) + .map(|(name, description, action)| SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::RunningTaskExit { action, thread_id }); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..Default::default() + }); + return; + } + } + if side_return_shortcut_matches(key_event) && self.maybe_return_from_side(tui, app_server).await { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 136127513b..2656614cb0 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2,6 +2,8 @@ #[path = "tests/advanced_reasoning_tests.rs"] mod advanced_reasoning_tests; +#[path = "tests/background_exit_tests.rs"] +mod background_exit_tests; #[path = "tests/key_chords.rs"] mod key_chords; #[path = "tests/mcp_startup.rs"] diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs new file mode 100644 index 0000000000..2c6a5fbdde --- /dev/null +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -0,0 +1,427 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn prepare_local_daemon_thread(app: &mut App) -> Result { + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(test_thread_session( + thread_id, + test_path_buf("/tmp/project"), + )); + Ok(thread_id) +} + +fn prepare_running_local_daemon(app: &mut App) -> Result { + let thread_id = prepare_local_daemon_thread(app)?; + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, "turn-1"), + /*replay_kind*/ None, + ); + Ok(thread_id) +} + +async fn open_running_task_exit_menu( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, +) { + app.handle_key_event( + tui, + app_server, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ) + .await; +} + +async fn prepare_background_exit_test( + app: &App, + app_event_rx: &mut mpsc::UnboundedReceiver, + op_rx: &mut mpsc::UnboundedReceiver, +) -> Result<(AppServerSession, tui::Tui)> { + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + Ok((app_server, crate::tui::test_support::make_test_tui()?)) +} + +#[tokio::test] +async fn daemon_ctrl_c_shows_background_exit_menu_and_escape_dismisses_it() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(!app.chat_widget.no_modal_or_popup_active()); + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" + Task is still running + Choose what happens to the current task. + + › 1. Cancel task Stop the current task and stay in Codex + 2. Run in background Exit Codex and leave the task running + 3. Exit Stop the current task and exit Codex + + Press enter to confirm or esc to go back + "); + + app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(op_rx.try_recv().is_err()); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn cancel_task_interrupts_without_exiting() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let event = app_event_rx.try_recv()?; + assert_matches!( + event, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::CancelTask, + .. + } + ); + let control = app.handle_event(&mut tui, &mut app_server, event).await?; + + assert_matches!(control, AppRunControl::Continue); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn run_in_background_detaches_without_interrupting_main_or_side_threads() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let parent_thread_id = prepare_running_local_daemon(&mut app)?; + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let event = app_event_rx.try_recv()?; + assert_matches!( + event, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::RunInBackground, + thread_id, + } if thread_id == parent_thread_id + ); + let control = app.handle_event(&mut tui, &mut app_server, event).await?; + + assert_matches!(control, AppRunControl::Exit(ExitReason::UserRequested)); + assert!(app.side_threads.contains_key(&side_thread_id)); + assert!(op_rx.try_recv().is_err()); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn exit_interrupts_before_requesting_shutdown() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget + .set_feature_enabled(Feature::Goals, /*enabled*/ true); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session(started.session.clone()); + let goal = app_server + .thread_goal_set( + thread_id, + Some("test goal".to_string()), + Some(codex_app_server_protocol::ThreadGoalStatus::Paused), + /*token_budget*/ None, + ) + .await? + .goal; + app.chat_widget.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal: codex_app_server_protocol::ThreadGoal { + status: codex_app_server_protocol::ThreadGoalStatus::Active, + ..goal + }, + }, + ), + /*replay_kind*/ None, + ); + let command = if cfg!(windows) { + "Start-Sleep -Seconds 30" + } else { + "sleep 30" + }; + app_server + .thread_shell_command(thread_id, command.to_string()) + .await?; + let turn_id = loop { + let event = time::timeout(Duration::from_secs(/*secs*/ 5), app_server.next_event()) + .await + .expect("app-server should emit a turn/start event") + .expect("app-server event stream should remain open"); + if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event + && let ServerNotification::TurnStarted(notification) = notification.as_ref() + && notification.thread_id == thread_id.to_string() + { + break notification.turn.id.clone(); + } + }; + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + started.session, + vec![test_turn(&turn_id, TurnStatus::InProgress, Vec::new())], + ), + ); + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, &turn_id), + /*replay_kind*/ None, + ); + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + let control = app + .handle_event( + &mut tui, + &mut app_server, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::Exit, + thread_id, + }, + ) + .await?; + + assert_matches!(control, AppRunControl::Continue); + assert!(op_rx.try_recv().is_err()); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::Exit(ExitMode::ShutdownFirst)) + ); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_closes_running_side_thread_and_returns_to_parent() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let side_thread_id = prepare_running_local_daemon(&mut app)?; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let parent_thread_id = started.session.thread_id; + app.primary_thread_id = Some(parent_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.thread_event_channels.insert( + parent_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + started.session, + started.turns, + ), + ); + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert_eq!(app.active_thread_id, Some(parent_thread_id)); + assert!(!app.side_threads.contains_key(&side_thread_id)); + assert!(op_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_hides_background_exit_for_running_background_side_thread() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let parent_thread_id = prepare_local_daemon_thread(&mut app)?; + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.thread_event_channels.insert( + side_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(side_thread_id, test_path_buf("/tmp/project")), + vec![test_turn("side-turn", TurnStatus::InProgress, Vec::new())], + ), + ); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" + Task is still running + Choose what happens to the current task. + + › 1. Cancel task Stop the current task and stay in Codex + 2. Exit Stop the current task and exit Codex + + Press enter to confirm or esc to go back + "); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let event = app_event_rx.try_recv()?; + + assert_matches!( + event, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::Exit, + thread_id, + } if thread_id == side_thread_id + ); + assert!(op_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_hides_background_exit_with_queued_follow_up() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up".to_string()] + ); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("Run in background")); + assert!(op_rx.try_recv().is_err()); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn non_daemon_ctrl_c_keeps_interrupt_behavior() -> Result<()> { + for target in [ + AppServerTarget::Embedded, + AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500".to_string(), + auth_token: None, + }, + }, + ] { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.app_server_target = target; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + } + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_with_draft_preserves_composer_cancellation() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget.apply_external_edit("draft".to_string()); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(app.chat_widget.composer_is_empty()); + assert!(op_rx.try_recv().is_err()); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "draft" + ); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_during_paste_burst_does_not_show_exit_menu() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!(app.chat_widget.composer_text_with_pending().is_empty()); + assert!(!app.chat_widget.composer_is_empty()); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(!app.chat_widget.composer_is_empty()); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_during_mcp_startup_does_not_show_background_exit_menu() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let thread_id = prepare_local_daemon_thread(&mut app)?; + app.chat_widget + .set_mcp_startup_expected_servers(["slow".to_string()]); + app.chat_widget.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(thread_id.to_string()), + name: "slow".to_string(), + status: McpServerStartupState::Starting, + error: None, + failure_reason: None, + }), + /*replay_kind*/ None, + ); + assert!(app.chat_widget.is_task_running_for_test()); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index eb65ba6fd4..01dc7549a0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -342,6 +342,12 @@ pub(crate) enum AppEvent { /// background tasks, rollout flush, or child process cleanup). Exit(ExitMode), + /// Apply a choice from the running-task exit menu to its originating thread. + RunningTaskExit { + action: RunningTaskExitAction, + thread_id: ThreadId, + }, + /// Request app-server account logout, then exit after it succeeds. Logout, @@ -1213,6 +1219,14 @@ pub(crate) enum ExitMode { Immediate, } +/// Choice made when leaving a daemon-backed task that is still running. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RunningTaskExitAction { + CancelTask, + RunInBackground, + Exit, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FeedbackCategory { BadResult, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3242bc6b42..9e847e67de 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1686,7 +1686,7 @@ impl ChatWidget { } pub(crate) fn composer_is_empty(&self) -> bool { - self.bottom_pane.composer_is_empty() + self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst() } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index b6d97c59be..06431c9060 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -496,15 +496,20 @@ impl ChatWidget { self.bottom_pane.is_task_running() || self.review.is_review_mode } - fn pause_active_goal_for_interrupt(&self) { - if !self.turn_lifecycle.agent_turn_running { - return; - } - if !self - .current_goal_status - .as_ref() - .is_some_and(GoalStatusState::is_active) - { + pub(crate) fn is_agent_turn_running(&self) -> bool { + self.turn_lifecycle.agent_turn_running + } + + pub(crate) fn is_active_goal_turn_running(&self) -> bool { + self.turn_lifecycle.agent_turn_running + && self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active) + } + + pub(crate) fn pause_active_goal_for_interrupt(&self) { + if !self.is_active_goal_turn_running() { return; } let Some(thread_id) = self.thread_id else { diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 88ff71a287..db05609d1c 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -294,7 +294,7 @@ impl ChatWidget { None } - pub(super) fn has_queued_follow_up_messages(&self) -> bool { + pub(crate) fn has_queued_follow_up_messages(&self) -> bool { self.input_queue.has_queued_follow_up_messages() }