diff --git a/CHANGELOG.md b/CHANGELOG.md index a7dc3dc4e..5a7a228b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 visibility trail, and @BigBenLabs, @lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail. +- Added inline live-output refresh for background shell Exec cards keyed by the + exact shell task id, so long-running commands can show bounded stdout/stderr + tails without consuming deltas or matching by command text. Thanks + @donglovejava for the live shell-output direction in #2048. - Added a static prompt composer override for embedders that need to replace the byte-stable base/personality prompt segment while leaving mode metadata, approval policy, tool taxonomy, Context Management, and the Compaction Relay diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index a7dc3dc4e..5a7a228b6 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 visibility trail, and @BigBenLabs, @lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail. +- Added inline live-output refresh for background shell Exec cards keyed by the + exact shell task id, so long-running commands can show bounded stdout/stderr + tails without consuming deltas or matching by command text. Thanks + @donglovejava for the live shell-output direction in #2048. - Added a static prompt composer override for embedders that need to replace the byte-stable base/personality prompt segment while leaving mode metadata, approval policy, tool taxonomy, Context Management, and the Compaction Relay diff --git a/crates/tui/src/tui/active_cell.rs b/crates/tui/src/tui/active_cell.rs index dc1eca0f4..f99efb2bf 100644 --- a/crates/tui/src/tui/active_cell.rs +++ b/crates/tui/src/tui/active_cell.rs @@ -331,6 +331,8 @@ mod tests { command: command.to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: Some(Instant::now()), duration_ms: None, source: ExecSource::Assistant, diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 47f7db0c7..9ca7fb54b 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -890,6 +890,8 @@ pub struct ExecCell { pub command: String, pub status: ToolStatus, pub output: Option, + pub live_output: Option, + pub shell_task_id: Option, pub started_at: Option, pub duration_ms: Option, pub source: ExecSource, @@ -946,7 +948,7 @@ impl ExecCell { } if self.interaction.is_none() { - if let Some(output) = self.output.as_ref() { + if let Some(output) = self.output.as_ref().or(self.live_output.as_ref()) { lines.extend(render_exec_output_mode( output, width, @@ -4306,6 +4308,8 @@ mod tests { command: "echo hi".to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at, duration_ms: None, source: ExecSource::Assistant, @@ -4645,6 +4649,8 @@ mod tests { command: "ls".to_string(), status: ToolStatus::Success, output: Some("a\nb\n".to_string()), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(10), source: ExecSource::Assistant, @@ -4675,6 +4681,8 @@ mod tests { command: "cargo test --workspace --all-features".to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: None, duration_ms: None, source: ExecSource::Assistant, @@ -4991,6 +4999,8 @@ mod tests { command: "false".to_string(), status: ToolStatus::Failed, output: Some("boom".to_string()), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(42), source: ExecSource::Assistant, @@ -5046,6 +5056,49 @@ mod tests { lines.iter().map(line_text).collect::>().join("\n") } + #[test] + fn exec_cell_renders_live_shell_output_before_final_output() { + let cell = ExecCell { + command: "cargo test".to_string(), + status: ToolStatus::Running, + output: None, + live_output: Some("running line 1\nrunning line 2".to_string()), + shell_task_id: Some("shell_live".to_string()), + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }; + + let text = lines_text(&cell.lines_with_motion(80, true)); + + assert!(text.contains("running line 1")); + assert!(text.contains("running line 2")); + assert!(!text.contains("Ctrl+B opens shell controls")); + } + + #[test] + fn exec_cell_prefers_final_output_over_live_shell_tail() { + let cell = ExecCell { + command: "cargo test".to_string(), + status: ToolStatus::Success, + output: Some("final output".to_string()), + live_output: Some("stale live tail".to_string()), + shell_task_id: Some("shell_live".to_string()), + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }; + + let text = lines_text(&cell.lines_with_motion(80, true)); + + assert!(text.contains("final output")); + assert!(!text.contains("stale live tail")); + } + #[test] fn long_thinking_display_is_shorter_than_transcript() { // Build a multi-paragraph thinking body so the live view has @@ -5162,6 +5215,8 @@ mod tests { command: "noisy_script.sh".to_string(), status: ToolStatus::Success, output: Some(output), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(120), source: ExecSource::Assistant, @@ -5613,6 +5668,8 @@ mod tests { command: command.to_string(), status: ToolStatus::Success, output: Some("ok".to_string()), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: None, source: ExecSource::Assistant, diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 680b8ad30..65e10b9ba 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -2874,6 +2874,8 @@ mod tests { command: command.to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(ACTIVE_TOOL_STALE_RUNNING_ROW_TTL.as_millis() as u64 + 1), source: ExecSource::Assistant, @@ -2906,6 +2908,8 @@ mod tests { command: "cargo test --workspace".to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: Some(std::time::Instant::now()), duration_ms: None, source: ExecSource::Assistant, @@ -3040,6 +3044,8 @@ mod tests { .to_string(), status: ToolStatus::Failed, output: Some("Lint pending\nTest pending".to_string()), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(15_000), source: ExecSource::Assistant, @@ -3080,6 +3086,8 @@ mod tests { command: "cargo test -p codewhale-tui".to_string(), status: ToolStatus::Failed, output: Some("test failed".to_string()), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(1_250), source: ExecSource::Assistant, @@ -3109,6 +3117,8 @@ mod tests { command: "cargo check".to_string(), status: ToolStatus::Success, output: Some("Finished".to_string()), + live_output: None, + shell_task_id: None, started_at: None, duration_ms: Some(1_250), source: ExecSource::Assistant, diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index cf486db91..2981e59b1 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -101,6 +101,8 @@ pub(super) fn handle_tool_call_started( command, status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: Some(Instant::now()), duration_ms: None, source, @@ -133,6 +135,8 @@ pub(super) fn handle_tool_call_started( command, status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: Some(Instant::now()), duration_ms: None, source, @@ -506,6 +510,16 @@ pub(super) fn handle_tool_call_complete( HistoryCell::Tool(ToolCell::Exec(exec)) => { exec.status = status; if let Ok(tool_result) = result.as_ref() { + let shell_task_id = tool_result + .metadata + .as_ref() + .and_then(|m| m.get("task_id")) + .and_then(serde_json::Value::as_str) + .filter(|task_id| !task_id.trim().is_empty()) + .map(str::to_string); + if shell_task_id.is_some() { + exec.shell_task_id = shell_task_id; + } if let Some(meta_command) = tool_result .metadata .as_ref() @@ -538,6 +552,12 @@ pub(super) fn handle_tool_call_complete( exec.output = Some(tool_result.content.clone()); exec.output_summary = Some(super::history::summarize_tool_output(&tool_result.content)); + exec.live_output = None; + } else if status == ToolStatus::Running + && exec.interaction.is_none() + && !tool_result.content.is_empty() + { + exec.live_output = Some(tool_result.content.clone()); } } else if let Err(err) = result.as_ref() && exec.interaction.is_none() diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 863e5aff2..f101401d3 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -616,6 +616,8 @@ mod tests { command: command.to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: None, duration_ms: None, source: ExecSource::Assistant, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 28f8a8a5a..0af424c2f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -65,6 +65,7 @@ use crate::settings::Settings; use crate::task_manager::{ NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary, }; +use crate::tools::shell::{ShellJobSnapshot, ShellStatus}; use crate::tools::spec::{RuntimeToolServices, ToolResult}; use crate::tools::subagent::SubAgentStatus; use crate::tui::app::HuntVerdict; @@ -1014,6 +1015,89 @@ async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManag app.task_panel = entries; } +fn refresh_shell_exec_live_output(app: &mut App) -> bool { + let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref().cloned() else { + return false; + }; + let jobs = { + let Ok(mut mgr) = shell_mgr.lock() else { + return false; + }; + mgr.list_jobs() + .into_iter() + .map(|job| (job.id.clone(), job)) + .collect::>() + }; + if jobs.is_empty() { + return false; + } + + let mut changed = false; + for index in 0..app.virtual_cell_count() { + let Some((task_id, next_status, next_live, next_duration)) = + shell_exec_live_update(app, index, &jobs) + else { + continue; + }; + let Some(HistoryCell::Tool(ToolCell::Exec(exec))) = app.cell_at_virtual_index_mut(index) + else { + continue; + }; + if exec.output.is_some() || exec.shell_task_id.as_deref() != Some(task_id.as_str()) { + continue; + } + exec.status = next_status; + exec.live_output = next_live; + exec.duration_ms = Some(next_duration); + changed = true; + } + changed +} + +fn shell_exec_live_update( + app: &App, + index: usize, + jobs: &std::collections::HashMap, +) -> Option<(String, ToolStatus, Option, u64)> { + let HistoryCell::Tool(ToolCell::Exec(exec)) = app.cell_at_virtual_index(index)? else { + return None; + }; + if exec.output.is_some() { + return None; + } + let task_id = exec.shell_task_id.as_deref()?; + let job = jobs.get(task_id)?; + let next_status = shell_job_tool_status(&job.status); + let next_live = shell_job_live_output(job).or_else(|| exec.live_output.clone()); + if exec.status == next_status + && exec.live_output == next_live + && exec.duration_ms == Some(job.elapsed_ms) + { + return None; + } + Some((task_id.to_string(), next_status, next_live, job.elapsed_ms)) +} + +fn shell_job_tool_status(status: &ShellStatus) -> ToolStatus { + match status { + ShellStatus::Running => ToolStatus::Running, + ShellStatus::Completed => ToolStatus::Success, + ShellStatus::Failed | ShellStatus::Killed | ShellStatus::TimedOut => ToolStatus::Failed, + } +} + +fn shell_job_live_output(job: &ShellJobSnapshot) -> Option { + match (job.stdout_tail.is_empty(), job.stderr_tail.is_empty()) { + (true, true) => None, + (false, true) => Some(job.stdout_tail.clone()), + (true, false) => Some(format!("STDERR:\n{}", job.stderr_tail)), + (false, false) => Some(format!( + "{}\n\nSTDERR:\n{}", + job.stdout_tail, job.stderr_tail + )), + } +} + fn active_reasoning_task_entries(app: &App) -> Vec { let Some(active) = app.active_cell.as_ref() else { return Vec::new(); @@ -1301,6 +1385,9 @@ async fn run_event_loop( if last_task_refresh.elapsed() >= Duration::from_millis(2500) { refresh_active_task_panel(app, &task_manager).await; + if refresh_shell_exec_live_output(app) { + app.needs_redraw = true; + } last_task_refresh = Instant::now(); app.needs_redraw = true; } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 393a0a918..024af9733 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1838,6 +1838,8 @@ fn active_tool_status_label_summarizes_live_tool_group() { command: "cargo test --workspace --all-features".to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: app.turn_started_at, duration_ms: None, source: ExecSource::Assistant, @@ -1868,6 +1870,106 @@ fn active_tool_status_label_summarizes_live_tool_group() { assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label())); } +#[test] +fn shell_live_output_update_matches_exact_task_id_only() { + let mut app = create_test_app(); + app.push_history_cell(HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test --workspace".to_string(), + status: ToolStatus::Running, + output: None, + live_output: None, + shell_task_id: Some("shell_a".to_string()), + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }))); + app.push_history_cell(HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test --workspace".to_string(), + status: ToolStatus::Running, + output: None, + live_output: Some("previous".to_string()), + shell_task_id: Some("shell_b".to_string()), + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }))); + + let mut jobs = std::collections::HashMap::new(); + jobs.insert( + "shell_b".to_string(), + ShellJobSnapshot { + id: "shell_b".to_string(), + job_id: "shell_b".to_string(), + command: "cargo test --workspace".to_string(), + cwd: PathBuf::from("/tmp/repo"), + status: ShellStatus::Running, + exit_code: None, + elapsed_ms: 777, + stdout_tail: "stdout tail\n".to_string(), + stderr_tail: "stderr tail\n".to_string(), + stdout_len: 12, + stderr_len: 12, + stdin_available: false, + stale: false, + linked_task_id: None, + }, + ); + + assert!(shell_exec_live_update(&app, 0, &jobs).is_none()); + let (_task_id, status, output, duration) = + shell_exec_live_update(&app, 1, &jobs).expect("matching task id updates"); + + assert_eq!(status, ToolStatus::Running); + assert_eq!(duration, 777); + assert_eq!( + output.as_deref(), + Some("stdout tail\n\n\nSTDERR:\nstderr tail\n") + ); +} + +#[test] +fn shell_live_output_update_skips_finalized_exec_cell() { + let mut app = create_test_app(); + app.push_history_cell(HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test --workspace".to_string(), + status: ToolStatus::Success, + output: Some("final output".to_string()), + live_output: Some("old live output".to_string()), + shell_task_id: Some("shell_a".to_string()), + started_at: None, + duration_ms: Some(10), + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }))); + let mut jobs = std::collections::HashMap::new(); + jobs.insert( + "shell_a".to_string(), + ShellJobSnapshot { + id: "shell_a".to_string(), + job_id: "shell_a".to_string(), + command: "cargo test --workspace".to_string(), + cwd: PathBuf::from("/tmp/repo"), + status: ShellStatus::Completed, + exit_code: Some(0), + elapsed_ms: 999, + stdout_tail: "new live output".to_string(), + stderr_tail: String::new(), + stdout_len: 15, + stderr_len: 0, + stdin_available: false, + stale: false, + linked_task_id: None, + }, + ); + + assert!(shell_exec_live_update(&app, 0, &jobs).is_none()); +} + #[test] fn active_tool_status_label_strips_shell_wrappers_from_ci_polling() { let mut app = create_test_app(); @@ -1880,6 +1982,8 @@ fn active_tool_status_label_strips_shell_wrappers_from_ci_polling() { .to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: app.turn_started_at, duration_ms: None, source: ExecSource::Assistant, @@ -5435,6 +5539,8 @@ fn terminal_pause_has_live_owner_only_for_running_exec_cells() { command: "python3 -i".to_string(), status: ToolStatus::Running, output: None, + live_output: None, + shell_task_id: None, started_at: Some(Instant::now()), duration_ms: None, source: ExecSource::Assistant,