diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 5484ac0d17..be7d7a6e2d 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -1146,6 +1146,11 @@ impl ShellManager { self.foreground_background_requested = true; } + #[cfg(test)] + pub(crate) fn foreground_background_requested_for_test(&self) -> bool { + self.foreground_background_requested + } + fn clear_foreground_background_request(&mut self) { self.foreground_background_requested = false; } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 00867d6dce..4bb3c3b356 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -13252,6 +13252,17 @@ async fn steer_user_message( } let message_index = app.api_messages.len(); + // A foreground shell blocks the turn loop that consumes steer input. + // Ask the shared shell manager to detach it before enqueueing the steer so + // the loop can leave the foreground wait and process this message (#4930). + if active_foreground_shell_running(app) + && let Err(err) = request_active_foreground_shell_background(app) + { + restore_steer_paused_state(app, &paused_snapshot); + engine_handle.set_paused(paused_snapshot.paused); + return Err(err.context("could not move foreground shell to /jobs before steering")); + } + if let Err(err) = engine_handle.steer(content.clone()).await { restore_steer_paused_state(app, &paused_snapshot); engine_handle.set_paused(paused_snapshot.paused); @@ -17471,24 +17482,29 @@ pub(crate) fn request_foreground_shell_background(app: &mut App) { return; } - let Some(shell_manager) = app.runtime_services.shell_manager.clone() else { - app.status_message = Some("No shell session is active.".to_string()); - return; - }; - - match shell_manager.lock() { - Ok(mut manager) => { - manager.request_foreground_background(); + match request_active_foreground_shell_background(app) { + Ok(()) => { app.status_message = Some("Moving current shell command to /jobs...".to_string()); } - Err(_) => { - app.status_message = Some( - "Shell tracking hit an internal error — restart Codewhale to recover.".to_string(), - ); + Err(err) => { + app.status_message = Some(err.to_string()); } } } +fn request_active_foreground_shell_background(app: &App) -> Result<()> { + let shell_manager = app + .runtime_services + .shell_manager + .clone() + .context("No shell session is active.")?; + let mut manager = shell_manager.lock().map_err(|_| { + anyhow::anyhow!("Shell tracking hit an internal error — restart Codewhale to recover.") + })?; + manager.request_foreground_background(); + Ok(()) +} + pub(crate) fn prefill_jobs_cancel_all_if_tasks_sidebar(app: &mut App) -> bool { if !app.view_stack.is_empty() || app.sidebar_focus != SidebarFocus::Tasks @@ -17512,7 +17528,9 @@ pub(crate) fn active_foreground_shell_running(app: &App) -> bool { matches!( cell, HistoryCell::Tool(ToolCell::Exec(exec)) - if exec.status == ToolStatus::Running && exec.interaction.is_none() + if exec.status == ToolStatus::Running + && exec.interaction.is_none() + && exec.shell_task_id.is_none() ) }) }) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index beb1042c6e..1328f816b7 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -11700,6 +11700,58 @@ async fn steer_user_message_records_prompt_for_cancel_restore() { ); } +#[tokio::test] +async fn steer_user_message_backgrounds_foreground_shell_before_dispatch() { + let mut app = create_test_app(); + app.is_loading = true; + let shell_manager = app + .runtime_services + .shell_manager + .clone() + .expect("test app shell manager"); + let mut active = ActiveCell::new(); + active.push_tool( + "foreground-shell", + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test --workspace".to_string(), + status: ToolStatus::Running, + output: None, + live_output: None, + shell_task_id: None, + owner_agent_id: None, + owner_agent_name: None, + started_at: Some(Instant::now()), + duration_ms: None, + stale_elapsed_since_output_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + })), + ); + app.active_cell = Some(active); + let mut engine = crate::core::engine::mock_engine_handle(); + + steer_user_message( + &mut app, + &engine.handle, + QueuedMessage::new("use the partial results".to_string(), None), + ) + .await + .expect("steer user message"); + + assert!( + shell_manager + .lock() + .expect("shell manager lock") + .foreground_background_requested_for_test(), + "foreground shell must receive its detach request" + ); + assert_eq!( + engine.rx_steer.recv().await.as_deref(), + Some("use the partial results") + ); +} + #[tokio::test] async fn empty_enter_sends_next_queued_message_into_running_turn() { let mut app = create_test_app(); @@ -13099,6 +13151,46 @@ fn terminal_pause_has_live_owner_only_for_running_exec_cells() { ); } +#[test] +fn active_foreground_shell_running_excludes_detached_background_jobs() { + let mut app = create_test_app(); + let mut active = ActiveCell::new(); + active.push_tool( + "shell", + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test --workspace".to_string(), + status: ToolStatus::Running, + output: None, + live_output: None, + shell_task_id: Some("shell-42".to_string()), + owner_agent_id: None, + owner_agent_name: None, + started_at: Some(Instant::now()), + duration_ms: None, + stale_elapsed_since_output_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + })), + ); + app.active_cell = Some(active); + + assert!( + !active_foreground_shell_running(&app), + "a detached job remains Running but is no longer a foreground wait" + ); + + let Some(HistoryCell::Tool(ToolCell::Exec(exec))) = app + .active_cell + .as_mut() + .and_then(|active| active.entry_mut(0)) + else { + panic!("running shell cell"); + }; + exec.shell_task_id = None; + assert!(active_foreground_shell_running(&app)); +} + #[test] fn active_rlm_task_entries_surface_foreground_rlm_work() { let mut app = create_test_app();