Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
44 changes: 31 additions & 13 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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()
)
})
})
Expand Down
92 changes: 92 additions & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading