From 021c9a60e525daa5c58b66d7808747f4bc33d802 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 16 Dec 2025 17:52:36 +0100 Subject: [PATCH] feat: unified exec footer (#8067) Screenshot 2025-12-15 at 17 54 44 --- codex-rs/tui/src/bottom_pane/mod.rs | 19 ++- ...c_footer__tests__render_more_sessions.snap | 22 +++ ...ec_footer__tests__render_two_sessions.snap | 21 +++ .../src/bottom_pane/unified_exec_footer.rs | 125 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 94 ++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 31 ++++- codex-rs/tui/src/history_cell.rs | 91 +++++++++++++ 7 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap create mode 100644 codex-rs/tui/src/bottom_pane/unified_exec_footer.rs diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 851668728..e9fc5df59 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; +use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -40,6 +41,7 @@ mod queued_user_messages; mod scroll_state; mod selection_popup_common; mod textarea; +mod unified_exec_footer; pub(crate) use feedback_view::FeedbackNoteView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -76,6 +78,8 @@ pub(crate) struct BottomPane { /// Inline status indicator shown above the composer while a task is running. status: Option, + /// Unified exec session summary shown above the composer. + unified_exec_footer: UnifiedExecFooter, /// Queued user messages to show above the composer while a turn is running. queued_user_messages: QueuedUserMessages, context_window_percent: Option, @@ -123,6 +127,7 @@ impl BottomPane { is_task_running: false, ctrl_c_quit_hint: false, status: None, + unified_exec_footer: UnifiedExecFooter::new(), queued_user_messages: QueuedUserMessages::new(), esc_backtrack_hint: false, animations_enabled, @@ -393,6 +398,12 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_unified_exec_sessions(&mut self, sessions: Vec) { + if self.unified_exec_footer.set_sessions(sessions) { + self.request_redraw(); + } + } + /// Update custom prompts available for the slash popup. pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.composer.set_custom_prompts(prompts); @@ -523,8 +534,14 @@ impl BottomPane { if let Some(status) = &self.status { flex.push(0, RenderableItem::Borrowed(status)); } + if !self.unified_exec_footer.is_empty() { + flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); + } flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + if self.status.is_some() + || !self.unified_exec_footer.is_empty() + || !self.queued_user_messages.messages.is_empty() + { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap new file mode 100644 index 000000000..90bfa7600 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/unified_exec_footer.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 50, height: 2 }, + content: [ + "Background terminal running: echo hello · rg "foo"", + " src · 1 more running ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 28, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 39, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 42, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 1, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 32, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap new file mode 100644 index 000000000..0828a62ef --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/unified_exec_footer.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 50, height: 2 }, + content: [ + "Background terminal running: echo hello · rg "foo"", + " src ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 28, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 39, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 42, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 1, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 32, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs new file mode 100644 index 000000000..80ec1fb62 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -0,0 +1,125 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::render::renderable::Renderable; +use crate::text_formatting::truncate_text; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +const MAX_SESSION_LABEL_GRAPHEMES: usize = 48; +const MAX_VISIBLE_SESSIONS: usize = 2; + +pub(crate) struct UnifiedExecFooter { + sessions: Vec, +} + +impl UnifiedExecFooter { + pub(crate) fn new() -> Self { + Self { + sessions: Vec::new(), + } + } + + pub(crate) fn set_sessions(&mut self, sessions: Vec) -> bool { + if self.sessions == sessions { + return false; + } + self.sessions = sessions; + true + } + + pub(crate) fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + fn render_lines(&self, width: u16) -> Vec> { + if self.sessions.is_empty() || width < 4 { + return Vec::new(); + } + + let label = "Background terminal running:"; + let mut spans = Vec::new(); + spans.push(label.dim()); + spans.push(" ".into()); + + let visible = self.sessions.iter().take(MAX_VISIBLE_SESSIONS); + let mut visible_count = 0usize; + for (idx, command) in visible.enumerate() { + if idx > 0 { + spans.push(" · ".dim()); + } + let truncated = truncate_text(command, MAX_SESSION_LABEL_GRAPHEMES); + spans.push(truncated.cyan()); + visible_count += 1; + } + + let remaining = self.sessions.len().saturating_sub(visible_count); + if remaining > 0 { + spans.push(" · ".dim()); + spans.push(format!("{remaining} more running").dim()); + } + + let indent = " ".repeat(label.len() + 1); + let line = Line::from(spans); + word_wrap_lines( + std::iter::once(line), + RtOptions::new(width as usize).subsequent_indent(Line::from(indent).dim()), + ) + } +} + +impl Renderable for UnifiedExecFooter { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + Paragraph::new(self.render_lines(area.width)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.render_lines(width).len() as u16 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + #[test] + fn desired_height_empty() { + let footer = UnifiedExecFooter::new(); + assert_eq!(footer.desired_height(40), 0); + } + + #[test] + fn render_two_sessions() { + let mut footer = UnifiedExecFooter::new(); + footer.set_sessions(vec!["echo hello".to_string(), "rg \"foo\" src".to_string()]); + let width = 50; + let height = footer.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + footer.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_two_sessions", format!("{buf:?}")); + } + + #[test] + fn render_more_sessions() { + let mut footer = UnifiedExecFooter::new(); + footer.set_sessions(vec![ + "echo hello".to_string(), + "rg \"foo\" src".to_string(), + "cat README.md".to_string(), + ]); + let width = 50; + let height = footer.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + footer.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_more_sessions", format!("{buf:?}")); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 37cd004a1..d04fc5b7a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -99,6 +99,7 @@ use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; +use crate::exec_command::strip_bash_lc_and_escape; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::AgentMessageCell; @@ -149,6 +150,11 @@ struct RunningCommand { source: ExecCommandSource, } +struct UnifiedExecSessionSummary { + key: String, + command_display: String, +} + struct UnifiedExecWaitState { command_display: String, } @@ -163,6 +169,13 @@ impl UnifiedExecWaitState { } } +fn is_unified_exec_source(source: ExecCommandSource) -> bool { + matches!( + source, + ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction + ) +} + const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; @@ -300,6 +313,7 @@ pub(crate) struct ChatWidget { suppressed_exec_calls: HashSet, last_unified_wait: Option, task_complete_pending: bool, + unified_exec_sessions: Vec, mcp_startup_status: Option>, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, @@ -828,6 +842,10 @@ impl ChatWidget { fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); + if is_unified_exec_source(ev.source) { + self.track_unified_exec_session_begin(&ev); + return; + } let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); } @@ -839,8 +857,18 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } - fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { - // TODO: Handle once design is ready + fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { + self.flush_answer_stream_with_separator(); + let key = Self::unified_exec_session_key(Some(&ev.process_id), &ev.call_id); + let command_display = self + .unified_exec_sessions + .iter() + .find(|session| session.key == key) + .map(|session| session.command_display.clone()); + self.add_to_history(history_cell::new_unified_exec_interaction( + command_display, + ev.stdin, + )); } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { @@ -868,10 +896,58 @@ impl ChatWidget { } fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { + if is_unified_exec_source(ev.source) { + self.track_unified_exec_session_end(&ev); + return; + } let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); } + fn unified_exec_session_key(process_id: Option<&str>, call_id: &str) -> String { + process_id.unwrap_or(call_id).to_string() + } + + fn track_unified_exec_session_begin(&mut self, ev: &ExecCommandBeginEvent) { + if ev.source != ExecCommandSource::UnifiedExecStartup { + return; + } + let key = Self::unified_exec_session_key(ev.process_id.as_deref(), &ev.call_id); + let command_display = strip_bash_lc_and_escape(&ev.command); + if let Some(existing) = self + .unified_exec_sessions + .iter_mut() + .find(|session| session.key == key) + { + existing.command_display = command_display; + } else { + self.unified_exec_sessions.push(UnifiedExecSessionSummary { + key, + command_display, + }); + } + self.sync_unified_exec_footer(); + } + + fn track_unified_exec_session_end(&mut self, ev: &ExecCommandEndEvent) { + let key = Self::unified_exec_session_key(ev.process_id.as_deref(), &ev.call_id); + let before = self.unified_exec_sessions.len(); + self.unified_exec_sessions + .retain(|session| session.key != key); + if self.unified_exec_sessions.len() != before { + self.sync_unified_exec_footer(); + } + } + + fn sync_unified_exec_footer(&mut self) { + let sessions = self + .unified_exec_sessions + .iter() + .map(|session| session.command_display.clone()) + .collect(); + self.bottom_pane.set_unified_exec_sessions(sessions); + } + fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); @@ -1319,6 +1395,7 @@ impl ChatWidget { suppressed_exec_calls: HashSet::new(), last_unified_wait: None, task_complete_pending: false, + unified_exec_sessions: Vec::new(), mcp_startup_status: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), @@ -1404,6 +1481,7 @@ impl ChatWidget { suppressed_exec_calls: HashSet::new(), last_unified_wait: None, task_complete_pending: false, + unified_exec_sessions: Vec::new(), mcp_startup_status: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), @@ -1871,12 +1949,20 @@ impl ChatWidget { EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } - EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::ExecCommandBegin(ev) => { + if !from_replay || !is_unified_exec_source(ev.source) { + self.on_exec_command_begin(ev); + } + } EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), - EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), + EventMsg::ExecCommandEnd(ev) => { + if !from_replay || !is_unified_exec_source(ev.source) { + self.on_exec_command_end(ev); + } + } EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 362b1678f..4447bc0b9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -426,6 +426,7 @@ fn make_chatwidget_manual( suppressed_exec_calls: HashSet::new(), last_unified_wait: None, task_complete_pending: false, + unified_exec_sessions: Vec::new(), mcp_startup_status: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), @@ -1219,7 +1220,7 @@ fn exec_end_without_begin_uses_event_command() { } #[test] -fn exec_history_shows_unified_exec_startup_commands() { +fn exec_history_skips_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin = begin_exec_with_source( @@ -1236,11 +1237,31 @@ fn exec_history_shows_unified_exec_startup_commands() { end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0); let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let blob = lines_to_single_string(&cells[0]); assert!( - blob.contains("• Ran echo unified exec startup"), - "expected startup command to render: {blob:?}" + cells.is_empty(), + "expected unified exec startup to render in footer only" + ); +} + +#[test] +fn unified_exec_end_after_task_complete_is_suppressed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let begin = begin_exec_with_source( + &mut chat, + "call-startup", + "echo unified exec startup", + ExecCommandSource::UnifiedExecStartup, + ); + drain_insert_history(&mut rx); + + chat.on_task_complete(None); + end_exec(&mut chat, begin, "", "", 0); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected unified exec end after task complete to be suppressed" ); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5440040f6..2c0a37ece 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -375,6 +375,72 @@ impl HistoryCell for PrefixedWrappedHistoryCell { } } +#[derive(Debug)] +pub(crate) struct UnifiedExecInteractionCell { + command_display: Option, + stdin: String, +} + +impl UnifiedExecInteractionCell { + pub(crate) fn new(command_display: Option, stdin: String) -> Self { + Self { + command_display, + stdin, + } + } +} + +impl HistoryCell for UnifiedExecInteractionCell { + fn display_lines(&self, width: u16) -> Vec> { + if width == 0 { + return Vec::new(); + } + let wrap_width = width as usize; + + let mut header_spans = vec!["↳ ".dim(), "Interacted with background terminal".bold()]; + if let Some(command) = &self.command_display + && !command.is_empty() + { + header_spans.push(" · ".dim()); + header_spans.push(command.clone().dim()); + } + let header = Line::from(header_spans); + + let mut out: Vec> = Vec::new(); + let header_wrapped = word_wrap_line(&header, RtOptions::new(wrap_width)); + push_owned_lines(&header_wrapped, &mut out); + + let input_lines: Vec> = if self.stdin.is_empty() { + vec![vec!["(waited)".dim()].into()] + } else { + self.stdin + .lines() + .map(|line| Line::from(line.to_string())) + .collect() + }; + + let input_wrapped = word_wrap_lines( + input_lines, + RtOptions::new(wrap_width) + .initial_indent(Line::from(" └ ".dim())) + .subsequent_indent(Line::from(" ".dim())), + ); + out.extend(input_wrapped); + out + } + + fn desired_height(&self, width: u16) -> u16 { + self.display_lines(width).len() as u16 + } +} + +pub(crate) fn new_unified_exec_interaction( + command_display: Option, + stdin: String, +) -> UnifiedExecInteractionCell { + UnifiedExecInteractionCell::new(command_display, stdin) +} + fn truncate_exec_snippet(full_cmd: &str) -> String { let mut snippet = match full_cmd.split_once('\n') { Some((first, _)) => format!("{first} ..."), @@ -1558,6 +1624,31 @@ mod tests { render_lines(&cell.transcript_lines(u16::MAX)) } + #[test] + fn unified_exec_interaction_cell_renders_input() { + let cell = + new_unified_exec_interaction(Some("echo hello".to_string()), "ls\npwd".to_string()); + let lines = render_transcript(&cell); + assert_eq!( + lines, + vec![ + "↳ Interacted with background terminal · echo hello", + " └ ls", + " pwd", + ], + ); + } + + #[test] + fn unified_exec_interaction_cell_renders_wait() { + let cell = new_unified_exec_interaction(None, String::new()); + let lines = render_transcript(&cell); + assert_eq!( + lines, + vec!["↳ Interacted with background terminal", " └ (waited)"], + ); + } + #[test] fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config();