From b93c3e703233e84caa8863b53fba8439bf5ae9ec Mon Sep 17 00:00:00 2001 From: donglovejava <211940267+donglovejava@users.noreply.github.com> Date: Mon, 25 May 2026 11:06:12 +0800 Subject: [PATCH] feat(tui): live shell output during execution --- crates/tui/src/tui/active_cell.rs | 1 + crates/tui/src/tui/app.rs | 69 ++++++++++++++++++++++++++++++ crates/tui/src/tui/history.rs | 11 +++++ crates/tui/src/tui/sidebar.rs | 5 +++ crates/tui/src/tui/tool_routing.rs | 2 + crates/tui/src/tui/transcript.rs | 1 + crates/tui/src/tui/ui.rs | 2 + crates/tui/src/tui/ui/tests.rs | 3 ++ 8 files changed, 94 insertions(+) diff --git a/crates/tui/src/tui/active_cell.rs b/crates/tui/src/tui/active_cell.rs index dc1eca0f4..ff26e469b 100644 --- a/crates/tui/src/tui/active_cell.rs +++ b/crates/tui/src/tui/active_cell.rs @@ -336,6 +336,7 @@ mod tests { source: ExecSource::Assistant, interaction: None, output_summary: None, + live_output: None, })) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index d62a00da9..31ffef985 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2514,6 +2514,75 @@ impl App { self.needs_redraw = true; } +/// Poll shell manager for live output of running exec cells and update + /// their `live_output` field so the TUI shows incremental progress. + /// Called from the idle frame handler in `run_event_loop`. + pub fn poll_shell_progress(&mut self) { + use crate::tui::history::{ExecCell, ToolCell}; + + let Some(ref shell_mgr) = self.runtime_services.shell_manager else { + return; + }; + let Some(ref active) = self.active_cell else { + return; + }; + + // Collect (index, command) of running exec cells + let mut running_execs: Vec<(usize, String)> = Vec::new(); + for (i, entry) in active.entries().iter().enumerate() { + if let HistoryCell::Tool(ToolCell::Exec(ExecCell { + command, + status: crate::tui::history::ToolStatus::Running, + .. + })) = entry + { + running_execs.push((i, command.clone())); + } + } + if running_execs.is_empty() { + return; + } + + // Get live output from shell manager + let jobs = match shell_mgr.lock() { + Ok(mut mgr) => mgr.list_jobs(), + Err(_) => return, + }; + + let active_ref = self.active_cell.as_mut().expect("active_cell just checked"); + let mut updated = false; + for (idx, cmd) in &running_execs { + let cmd_prefix: String = cmd.chars().take(100).collect(); + for job in &jobs { + if job.status == crate::tools::shell::ShellStatus::Running { + let job_prefix: String = job.command.chars().take(100).collect(); + if cmd_prefix == job_prefix { + let tail = if !job.stderr_tail.trim().is_empty() { + job.stderr_tail.trim() + } else { + job.stdout_tail.trim() + }; + if !tail.is_empty() { + if let Some(HistoryCell::Tool(ToolCell::Exec(ref mut ec))) = + active_ref.entry_mut(*idx) + { + let new_output = tail.to_string(); + if ec.live_output.as_deref() != Some(&new_output) { + ec.live_output = Some(new_output); + updated = true; + } + } + } + } + } + } + } + if updated { + self.bump_active_cell_revision(); + } + } + + /// Total number of cells in the *virtual* transcript: `history.len()` /// plus active cell entries (if any). #[must_use] diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 477eafa01..235dc60ee 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -657,6 +657,10 @@ pub struct ExecCell { pub interaction: Option, /// Cached output summary — avoids re-parsing JSON every frame. pub output_summary: Option, + /// Live incremental output shown while the command is still running. + /// Polled from the shell manager during idle frames and displayed + /// before the final output is available. + pub live_output: Option, } impl ExecCell { @@ -714,6 +718,13 @@ impl ExecCell { TOOL_OUTPUT_LINE_LIMIT, mode, )); + } else if let Some(live) = self.live_output.as_ref() { + lines.extend(render_exec_output_mode( + live, + width, + TOOL_OUTPUT_LINE_LIMIT, + mode, + )); } else if self.status == ToolStatus::Running && self.source == ExecSource::Assistant { lines.extend(wrap_plain_line( " Ctrl+B opens shell controls.", diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index bff5c51a0..9b8abdae1 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -2207,6 +2207,7 @@ mod tests { source: ExecSource::Assistant, interaction: None, output_summary: None, + live_output: None, })), ); } @@ -2239,6 +2240,7 @@ mod tests { source: ExecSource::Assistant, interaction: None, output_summary: None, + live_output: None, })), ); app.active_cell = Some(active); @@ -2373,6 +2375,7 @@ mod tests { source: ExecSource::Assistant, interaction: None, output_summary: Some("2 checks pending".to_string()), + live_output: None, }))); } @@ -2413,6 +2416,7 @@ mod tests { source: ExecSource::Assistant, interaction: None, output_summary: Some("test failed".to_string()), + live_output: None, }))); let text = lines_to_text(&task_panel_lines(&app, 80, 8)); @@ -2442,6 +2446,7 @@ mod tests { source: ExecSource::Assistant, interaction: None, output_summary: None, + live_output: None, }))); let text = lines_to_text(&task_panel_lines(&app, 80, 8)); diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index e0e35bdd0..6ad7d1a86 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -112,6 +112,7 @@ pub(super) fn handle_tool_call_started( source, interaction: Some(summary.clone()), output_summary: None, + live_output: None, })), ); return; @@ -144,6 +145,7 @@ pub(super) fn handle_tool_call_started( source, interaction: None, output_summary: None, + live_output: None, })), ); return; diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 9616a9c7b..51c120241 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -562,6 +562,7 @@ mod tests { output: None, started_at: None, duration_ms: None, + live_output: None, source: ExecSource::Assistant, interaction: None, output_summary: None, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1444f1341..bd0236130 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2063,6 +2063,8 @@ async fn run_event_loop( // Expire the "Press Ctrl+C again to quit" prompt silently after its // window. Triggers a redraw if the prompt was visible. app.tick_quit_armed(); + // Poll shell manager for live output of running exec cells + app.poll_shell_progress(); app.tick_receipt(); // While the user is drag-selecting past the transcript edge, advance // the viewport on a fixed cadence and extend the selection head so a diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 41d6c8ce5..b9a4083fb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1525,6 +1525,7 @@ fn active_tool_status_label_summarizes_live_tool_group() { source: ExecSource::Assistant, interaction: None, output_summary: None, + live_output: None, })), ); active.push_tool( @@ -1567,6 +1568,7 @@ fn active_tool_status_label_strips_shell_wrappers_from_ci_polling() { source: ExecSource::Assistant, interaction: None, output_summary: None, + live_output: None, })), ); app.active_cell = Some(active); @@ -3595,6 +3597,7 @@ fn terminal_pause_has_live_owner_only_for_running_exec_cells() { source: ExecSource::Assistant, interaction: Some("interactive".to_string()), output_summary: None, + live_output: None, })), ); app.active_cell = Some(active);