diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8a0dd81afe3..3cc6e4ceda6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -223,7 +223,9 @@ struct RunningCommand { struct UnifiedExecProcessSummary { key: String, + call_id: String, command_display: String, + recent_chunks: Vec, } struct UnifiedExecWaitState { @@ -1376,6 +1378,8 @@ impl ChatWidget { } fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { + self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk); + let Some(cell) = self .active_cell .as_mut() @@ -1495,11 +1499,15 @@ impl ChatWidget { .iter_mut() .find(|process| process.key == key) { + existing.call_id = ev.call_id.clone(); existing.command_display = command_display; + existing.recent_chunks.clear(); } else { self.unified_exec_processes.push(UnifiedExecProcessSummary { key, + call_id: ev.call_id.clone(), command_display, + recent_chunks: Vec::new(), }); } self.sync_unified_exec_footer(); @@ -1524,6 +1532,32 @@ impl ChatWidget { self.bottom_pane.set_unified_exec_processes(processes); } + /// Record recent stdout/stderr lines for the unified exec footer. + fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { + let Some(process) = self + .unified_exec_processes + .iter_mut() + .find(|process| process.call_id == call_id) + else { + return; + }; + + let text = String::from_utf8_lossy(chunk); + for line in text + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + { + process.recent_chunks.push(line.to_string()); + } + + const MAX_RECENT_CHUNKS: usize = 3; + if process.recent_chunks.len() > MAX_RECENT_CHUNKS { + let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; + process.recent_chunks.drain(0..drop_count); + } + } + fn clear_unified_exec_processes(&mut self) { if self.unified_exec_processes.is_empty() { return; @@ -3356,7 +3390,10 @@ impl ChatWidget { let processes = self .unified_exec_processes .iter() - .map(|process| process.command_display.clone()) + .map(|process| history_cell::UnifiedExecProcessDetails { + command_display: process.command_display.clone(), + recent_chunks: process.recent_chunks.clone(), + }) .collect(); self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 570a72750b3..0d3a7f8b661 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2022,7 +2022,9 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { chat.on_task_started(); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "proc-1".to_string(), + call_id: "call-1".to_string(), command_display: "sleep 5".to_string(), + recent_chunks: Vec::new(), }); chat.on_terminal_interaction(TerminalInteractionEvent { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 687d18fa6ab..1f19c929a21 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -558,15 +558,21 @@ pub(crate) fn new_unified_exec_interaction( #[derive(Debug)] struct UnifiedExecProcessesCell { - processes: Vec, + processes: Vec, } impl UnifiedExecProcessesCell { - fn new(processes: Vec) -> Self { + fn new(processes: Vec) -> Self { Self { processes } } } +#[derive(Debug, Clone)] +pub(crate) struct UnifiedExecProcessDetails { + pub(crate) command_display: String, + pub(crate) recent_chunks: Vec, +} + impl HistoryCell for UnifiedExecProcessesCell { fn display_lines(&self, width: u16) -> Vec> { if width == 0 { @@ -589,10 +595,11 @@ impl HistoryCell for UnifiedExecProcessesCell { let truncation_suffix = " [...]"; let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); let mut shown = 0usize; - for command in &self.processes { + for process in &self.processes { if shown >= max_processes { break; } + let command = &process.command_display; let (snippet, snippet_truncated) = { let (first_line, has_more_lines) = match command.split_once('\n') { Some((first, _)) => (first, true), @@ -627,6 +634,32 @@ impl HistoryCell for UnifiedExecProcessesCell { let (truncated, _, _) = take_prefix_by_width(&snippet, budget); out.push(vec![prefix.dim(), truncated.cyan()].into()); } + + let chunk_prefix_first = " ↳ "; + let chunk_prefix_next = " "; + for (idx, chunk) in process.recent_chunks.iter().enumerate() { + let chunk_prefix = if idx == 0 { + chunk_prefix_first + } else { + chunk_prefix_next + }; + let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix); + if wrap_width <= chunk_prefix_width { + out.push(Line::from(chunk_prefix.dim())); + continue; + } + let budget = wrap_width.saturating_sub(chunk_prefix_width); + let (truncated, remainder, _) = take_prefix_by_width(chunk, budget); + if !remainder.is_empty() && budget > truncation_suffix_width { + let available = budget.saturating_sub(truncation_suffix_width); + let (shorter, _, _) = take_prefix_by_width(chunk, available); + out.push( + vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(), + ); + } else { + out.push(vec![chunk_prefix.dim(), truncated.dim()].into()); + } + } shown += 1; } @@ -650,7 +683,9 @@ impl HistoryCell for UnifiedExecProcessesCell { } } -pub(crate) fn new_unified_exec_processes_output(processes: Vec) -> CompositeHistoryCell { +pub(crate) fn new_unified_exec_processes_output( + processes: Vec, +) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); let summary = UnifiedExecProcessesCell::new(processes); CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) @@ -2022,8 +2057,14 @@ mod tests { #[test] fn ps_output_multiline_snapshot() { let cell = new_unified_exec_processes_output(vec![ - "echo hello\nand then some extra text".to_string(), - "rg \"foo\" src".to_string(), + UnifiedExecProcessDetails { + command_display: "echo hello\nand then some extra text".to_string(), + recent_chunks: vec!["hello".to_string(), "done".to_string()], + }, + UnifiedExecProcessDetails { + command_display: "rg \"foo\" src".to_string(), + recent_chunks: vec!["src/main.rs:12:foo".to_string()], + }, ]); let rendered = render_lines(&cell.display_lines(40)).join("\n"); insta::assert_snapshot!(rendered); @@ -2031,9 +2072,12 @@ mod tests { #[test] fn ps_output_long_command_snapshot() { - let cell = new_unified_exec_processes_output(vec![String::from( - "rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'", - )]); + let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails { + command_display: String::from( + "rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'", + ), + recent_chunks: vec!["searching...".to_string()], + }]); let rendered = render_lines(&cell.display_lines(36)).join("\n"); insta::assert_snapshot!(rendered); } @@ -2041,12 +2085,30 @@ mod tests { #[test] fn ps_output_many_sessions_snapshot() { let cell = new_unified_exec_processes_output( - (0..20).map(|idx| format!("command {idx}")).collect(), + (0..20) + .map(|idx| UnifiedExecProcessDetails { + command_display: format!("command {idx}"), + recent_chunks: Vec::new(), + }) + .collect(), ); let rendered = render_lines(&cell.display_lines(32)).join("\n"); insta::assert_snapshot!(rendered); } + #[test] + fn ps_output_chunk_leading_whitespace_snapshot() { + let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails { + command_display: "just fix".to_string(), + recent_chunks: vec![ + " indented first".to_string(), + " more indented".to_string(), + ], + }]); + let rendered = render_lines(&cell.display_lines(60)).join("\n"); + insta::assert_snapshot!(rendered); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap new file mode 100644 index 00000000000..e13dcf0a1ef --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • just fix + ↳ indented first + more indented diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap index b9302295d3b..2bd4bf9fb97 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap @@ -7,3 +7,4 @@ expression: rendered Background terminals • rg "foo" src --glob '**/*. [...] + ↳ searching... diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap index c073349e8ff..0e31eddfa0e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap @@ -7,4 +7,7 @@ expression: rendered Background terminals • echo hello [...] + ↳ hello + done • rg "foo" src + ↳ src/main.rs:12:foo