From c9f5b9a6dfa635af2a2cd93bdd6da01ce540cfcd Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 16 Dec 2025 16:36:33 +0100 Subject: [PATCH 1/4] feat: do not compact on last user turn (#8060) --- codex-rs/core/src/codex.rs | 37 +++--- codex-rs/core/tests/suite/compact.rs | 161 +++++++++++++-------------- 2 files changed, 100 insertions(+), 98 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5f0e322dae..9d586b1f3b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2150,6 +2150,16 @@ pub(crate) async fn run_task( if input.is_empty() { return None; } + + let auto_compact_limit = turn_context + .client + .get_model_family() + .auto_compact_token_limit() + .unwrap_or(i64::MAX); + let total_usage_tokens = sess.get_total_token_usage().await; + if total_usage_tokens >= auto_compact_limit { + run_auto_compact(&sess, &turn_context).await; + } let event = EventMsg::TaskStarted(TaskStartedEvent { model_context_window: turn_context.client.get_model_context_window(), }); @@ -2232,25 +2242,12 @@ pub(crate) async fn run_task( needs_follow_up, last_agent_message: turn_last_agent_message, } = turn_output; - let limit = turn_context - .client - .get_model_family() - .auto_compact_token_limit() - .unwrap_or(i64::MAX); let total_usage_tokens = sess.get_total_token_usage().await; - let token_limit_reached = total_usage_tokens >= limit; + let token_limit_reached = total_usage_tokens >= auto_compact_limit; // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. - if token_limit_reached { - if should_use_remote_compact_task( - sess.as_ref(), - &turn_context.client.get_provider(), - ) { - run_inline_remote_auto_compact_task(sess.clone(), turn_context.clone()) - .await; - } else { - run_inline_auto_compact_task(sess.clone(), turn_context.clone()).await; - } + if token_limit_reached && needs_follow_up { + run_auto_compact(&sess, &turn_context).await; continue; } @@ -2292,6 +2289,14 @@ pub(crate) async fn run_task( last_agent_message } +async fn run_auto_compact(sess: &Arc, turn_context: &Arc) { + if should_use_remote_compact_task(sess.as_ref(), &turn_context.client.get_provider()) { + run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await; + } else { + run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await; + } +} + #[instrument( skip_all, fields( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index bffb601eb3..f223f4d10d 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1009,7 +1009,6 @@ async fn auto_compact_runs_after_token_limit_hit() { ev_assistant_message("m3", AUTO_SUMMARY_TEXT), ev_completed_with_tokens("r3", 200), ]); - let sse_resume = sse(vec![ev_completed("r3-resume")]); let sse4 = sse(vec![ ev_assistant_message("m4", FINAL_REPLY), ev_completed_with_tokens("r4", 120), @@ -1038,15 +1037,6 @@ async fn auto_compact_runs_after_token_limit_hit() { }; mount_sse_once_match(&server, third_matcher, sse3).await; - let resume_marker = prefixed_auto_summary; - let resume_matcher = move |req: &wiremock::Request| { - let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains(resume_marker) - && !body_contains_text(body, SUMMARIZATION_PROMPT) - && !body.contains(POST_AUTO_USER_MSG) - }; - mount_sse_once_match(&server, resume_matcher, sse_resume).await; - let fourth_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(POST_AUTO_USER_MSG) && !body_contains_text(body, SUMMARIZATION_PROMPT) @@ -1106,8 +1096,8 @@ async fn auto_compact_runs_after_token_limit_hit() { let requests = get_responses_requests(&server).await; assert_eq!( requests.len(), - 5, - "expected user turns, a compaction request, a resumed turn, and the follow-up turn; got {}", + 4, + "expected user turns, a compaction request, and the follow-up turn; got {}", requests.len() ); let is_auto_compact = |req: &wiremock::Request| { @@ -1131,19 +1121,6 @@ async fn auto_compact_runs_after_token_limit_hit() { "auto compact should add a third request" ); - let resume_summary_marker = prefixed_auto_summary; - let resume_index = requests - .iter() - .enumerate() - .find_map(|(idx, req)| { - let body = std::str::from_utf8(&req.body).unwrap_or(""); - (body.contains(resume_summary_marker) - && !body_contains_text(body, SUMMARIZATION_PROMPT) - && !body.contains(POST_AUTO_USER_MSG)) - .then_some(idx) - }) - .expect("resume request missing after compaction"); - let follow_up_index = requests .iter() .enumerate() @@ -1154,15 +1131,12 @@ async fn auto_compact_runs_after_token_limit_hit() { .then_some(idx) }) .expect("follow-up request missing"); - assert_eq!(follow_up_index, 4, "follow-up request should be last"); + assert_eq!(follow_up_index, 3, "follow-up request should be last"); let body_first = requests[0].body_json::().unwrap(); let body_auto = requests[auto_compact_index] .body_json::() .unwrap(); - let body_resume = requests[resume_index] - .body_json::() - .unwrap(); let body_follow_up = requests[follow_up_index] .body_json::() .unwrap(); @@ -1201,23 +1175,6 @@ async fn auto_compact_runs_after_token_limit_hit() { "auto compact should send the summarization prompt as a user message", ); - let input_resume = body_resume.get("input").and_then(|v| v.as_array()).unwrap(); - assert!( - input_resume.iter().any(|item| { - item.get("type").and_then(|v| v.as_str()) == Some("message") - && item.get("role").and_then(|v| v.as_str()) == Some("user") - && item - .get("content") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.first()) - .and_then(|entry| entry.get("text")) - .and_then(|v| v.as_str()) - .map(|text| text.contains(prefixed_auto_summary)) - .unwrap_or(false) - }), - "resume request should include compacted history" - ); - let input_follow_up = body_follow_up .get("input") .and_then(|v| v.as_array()) @@ -1276,6 +1233,10 @@ async fn auto_compact_persists_rollout_entries() { ev_assistant_message("m3", &auto_summary_payload), ev_completed_with_tokens("r3", 200), ]); + let sse4 = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 120), + ]); let first_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); @@ -1299,12 +1260,19 @@ async fn auto_compact_persists_rollout_entries() { }; mount_sse_once_match(&server, third_matcher, sse3).await; + let fourth_matcher = |req: &wiremock::Request| { + let body = std::str::from_utf8(&req.body).unwrap_or(""); + body.contains(POST_AUTO_USER_MSG) && !body_contains_text(body, SUMMARIZATION_PROMPT) + }; + mount_sse_once_match(&server, fourth_matcher, sse4).await; + let model_provider = non_openai_model_provider(&server); let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); + config.model_auto_compact_token_limit = Some(200_000); let conversation_manager = ConversationManager::with_models_provider( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), @@ -1335,6 +1303,16 @@ async fn auto_compact_persists_rollout_entries() { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: POST_AUTO_USER_MSG.into(), + }], + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + codex.submit(Op::Shutdown).await.unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -1731,6 +1709,8 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ ev_assistant_message("m6", FINAL_REPLY), ev_completed_with_tokens("r6", 120), ]); + let follow_up_user = "FOLLOW_UP_AUTO_COMPACT"; + let final_user = "FINAL_AUTO_COMPACT"; mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4, sse5, sse6]).await; @@ -1751,31 +1731,31 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ .unwrap() .conversation; - codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: MULTI_AUTO_MSG.into(), - }], - }) - .await - .unwrap(); - let mut auto_compact_lifecycle_events = Vec::new(); - loop { - let event = codex.next_event().await.unwrap(); - if event.id.starts_with("auto-compact-") - && matches!( - event.msg, - EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) - ) - { - auto_compact_lifecycle_events.push(event); - continue; - } - if let EventMsg::TaskComplete(_) = &event.msg - && !event.id.starts_with("auto-compact-") - { - break; + for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { text: user.into() }], + }) + .await + .unwrap(); + + loop { + let event = codex.next_event().await.unwrap(); + if event.id.starts_with("auto-compact-") + && matches!( + event.msg, + EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) + ) + { + auto_compact_lifecycle_events.push(event); + continue; + } + if let EventMsg::TaskComplete(_) = &event.msg + && !event.id.starts_with("auto-compact-") + { + break; + } } } @@ -1821,6 +1801,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let context_window = 100; let limit = context_window * 90 / 100; let over_limit_tokens = context_window * 95 / 100 + 1; + let follow_up_user = "FOLLOW_UP_AFTER_LIMIT"; let first_turn = sse(vec![ ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), @@ -1873,6 +1854,17 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: follow_up_user.into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + // Assert first request captured expected user message that triggers function call. let first_request = first_turn_mock.single_request().input(); assert!( @@ -1916,6 +1908,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { let first_user = "COUNT_PRE_LAST_REASONING"; let second_user = "TRIGGER_COMPACT_AT_LIMIT"; + let third_user = "AFTER_REMOTE_COMPACT"; let pre_last_reasoning_content = "a".repeat(2_400); let post_last_reasoning_content = "b".repeat(4_000); @@ -1928,7 +1921,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { ev_reasoning_item("post-reasoning", &["post"], &[&post_last_reasoning_content]), ev_completed_with_tokens("r2", 80), ]); - let resume_turn = sse(vec![ + let third_turn = sse(vec![ ev_assistant_message("m4", FINAL_REPLY), ev_completed_with_tokens("r4", 1), ]); @@ -1940,8 +1933,8 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { first_turn, // Turn 2: reasoning after last user (should be ignored for compaction). second_turn, - // Turn 3: resume after remote compaction. - resume_turn, + // Turn 3: next user turn after remote compaction. + third_turn, ], ) .await; @@ -1973,7 +1966,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { .expect("build codex") .codex; - for (idx, user) in [first_user, second_user].into_iter().enumerate() { + for (idx, user) in [first_user, second_user, third_user] + .into_iter() + .enumerate() + { codex .submit(Op::UserInput { items: vec![UserInput::Text { text: user.into() }], @@ -1982,10 +1978,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - if idx == 0 { + if idx < 2 { assert!( compact_mock.requests().is_empty(), - "remote compaction should not run after the first turn" + "remote compaction should not run before the next user turn" ); } } @@ -2006,20 +2002,21 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { assert_eq!( requests.len(), 3, - "conversation should include two user turns and a post-compaction resume" + "conversation should include three user turns" ); let second_request_body = requests[1].body_json().to_string(); assert!( !second_request_body.contains("REMOTE_COMPACT_SUMMARY"), "second turn should not include compacted history" ); - let resume_body = requests[2].body_json().to_string(); + let third_request_body = requests[2].body_json().to_string(); assert!( - resume_body.contains("REMOTE_COMPACT_SUMMARY") || resume_body.contains(FINAL_REPLY), - "resume request should follow remote compact and use compacted history" + third_request_body.contains("REMOTE_COMPACT_SUMMARY") + || third_request_body.contains(FINAL_REPLY), + "third turn should include compacted history" ); assert!( - resume_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), - "resume request should include compaction summary item" + third_request_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "third turn should include compaction summary item" ); } From 021c9a60e525daa5c58b66d7808747f4bc33d802 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 16 Dec 2025 17:52:36 +0100 Subject: [PATCH 2/4] 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 8516687284..e9fc5df596 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 0000000000..90bfa7600a --- /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 0000000000..0828a62efa --- /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 0000000000..80ec1fb62b --- /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 37cd004a15..d04fc5b7a9 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 362b1678f4..4447bc0b9d 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 5440040f6b..2c0a37ecea 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(); From d7482510b14a4d5affac63e4a59665cca9a76d60 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 16 Dec 2025 17:53:15 +0100 Subject: [PATCH 3/4] nit: trace span for regular task (#8053) Logs are too spammy --------- Co-authored-by: Anton Panasenko --- codex-rs/codex-api/src/endpoint/responses.rs | 2 +- codex-rs/codex-client/src/default_client.rs | 4 ++-- codex-rs/core/src/codex.rs | 14 +++++++------- codex-rs/core/src/mcp_connection_manager.rs | 2 +- codex-rs/core/src/stream_events_utils.rs | 2 +- codex-rs/core/src/tasks/regular.rs | 4 ++-- codex-rs/core/src/tools/parallel.rs | 6 +++--- codex-rs/core/src/tools/router.rs | 4 ++-- codex-rs/core/tests/suite/otel.rs | 3 +++ codex-rs/otel/src/otel_manager.rs | 4 ++-- codex-rs/otel/src/otel_provider.rs | 2 +- 11 files changed, 25 insertions(+), 22 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index a300b5a70d..310f7e57bb 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -58,7 +58,7 @@ impl ResponsesClient { self.stream(request.body, request.headers).await } - #[instrument(skip_all, err)] + #[instrument(level = "trace", skip_all, err)] pub async fn stream_prompt( &self, model: &str, diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs index e79f873cbb..efb4d5aec4 100644 --- a/codex-rs/codex-client/src/default_client.rs +++ b/codex-rs/codex-client/src/default_client.rs @@ -181,7 +181,7 @@ mod tests { use opentelemetry::trace::TracerProvider; use opentelemetry_sdk::propagation::TraceContextPropagator; use opentelemetry_sdk::trace::SdkTracerProvider; - use tracing::info_span; + use tracing::trace_span; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -195,7 +195,7 @@ mod tests { tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); let _guard = subscriber.set_default(); - let span = info_span!("client_request"); + let span = trace_span!("client_request"); let _entered = span.enter(); let span_context = span.context().span().span_context().clone(); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9d586b1f3b..bcfe8e8114 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -66,8 +66,8 @@ use tracing::debug; use tracing::error; use tracing::field; use tracing::info; -use tracing::info_span; use tracing::instrument; +use tracing::trace_span; use tracing::warn; use crate::ModelProviderInfo; @@ -2297,7 +2297,7 @@ async fn run_auto_compact(sess: &Arc, turn_context: &Arc) } } -#[instrument( +#[instrument(level = "trace", skip_all, fields( turn_id = %turn_context.sub_id, @@ -2437,7 +2437,7 @@ async fn drain_in_flight( } #[allow(clippy::too_many_arguments)] -#[instrument( +#[instrument(level = "trace", skip_all, fields( turn_id = %turn_context.sub_id, @@ -2466,7 +2466,7 @@ async fn try_run_turn( .client .clone() .stream(prompt) - .instrument(info_span!("stream_request")) + .instrument(trace_span!("stream_request")) .or_cancel(&cancellation_token) .await??; @@ -2482,9 +2482,9 @@ async fn try_run_turn( let mut last_agent_message: Option = None; let mut active_item: Option = None; let mut should_emit_turn_diff = false; - let receiving_span = info_span!("receiving_stream"); + let receiving_span = trace_span!("receiving_stream"); let outcome: CodexResult = loop { - let handle_responses = info_span!( + let handle_responses = trace_span!( parent: &receiving_span, "handle_responses", otel.name = field::Empty, @@ -2494,7 +2494,7 @@ async fn try_run_turn( let event = match stream .next() - .instrument(info_span!(parent: &handle_responses, "receiving")) + .instrument(trace_span!(parent: &handle_responses, "receiving")) .or_cancel(&cancellation_token) .await { diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 460d598a94..3213b22b71 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -398,7 +398,7 @@ impl McpConnectionManager { /// Returns a single map that contains all tools. Each key is the /// fully-qualified name for the tool. - #[instrument(skip_all)] + #[instrument(level = "trace", skip_all)] pub async fn list_all_tools(&self) -> HashMap { let mut tools = HashMap::new(); for managed_client in self.clients.values() { diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 1373fdf248..2e19a3694c 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -39,7 +39,7 @@ pub(crate) struct HandleOutputCtx { pub cancellation_token: CancellationToken, } -#[instrument(skip_all)] +#[instrument(level = "trace", skip_all)] pub(crate) async fn handle_output_item_done( ctx: &mut HandleOutputCtx, item: ResponseItem, diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 2ee598f21c..56c46ffbc9 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use tracing::Instrument; -use tracing::info_span; +use tracing::trace_span; use super::SessionTask; use super::SessionTaskContext; @@ -30,7 +30,7 @@ impl SessionTask for RegularTask { ) -> Option { let sess = session.clone_session(); let run_task_span = - info_span!(parent: sess.services.otel_manager.current_span(), "run_task"); + trace_span!(parent: sess.services.otel_manager.current_span(), "run_task"); run_task(sess, ctx, input, cancellation_token) .instrument(run_task_span) .await diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index d146507e5c..dcd3ae40ad 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -6,8 +6,8 @@ use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use tracing::Instrument; -use tracing::info_span; use tracing::instrument; +use tracing::trace_span; use crate::codex::Session; use crate::codex::TurnContext; @@ -45,7 +45,7 @@ impl ToolCallRuntime { } } - #[instrument(skip_all, fields(call = ?call))] + #[instrument(level = "trace", skip_all, fields(call = ?call))] pub(crate) fn handle_tool_call( self, call: ToolCall, @@ -60,7 +60,7 @@ impl ToolCallRuntime { let lock = Arc::clone(&self.parallel_execution); let started = Instant::now(); - let dispatch_span = info_span!( + let dispatch_span = trace_span!( "dispatch_tool_call", otel.name = call.tool_name.as_str(), tool_name = call.tool_name.as_str(), diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 66e6026b3a..9d83b5a637 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -55,7 +55,7 @@ impl ToolRouter { .any(|config| config.spec.name() == tool_name) } - #[instrument(skip_all, err)] + #[instrument(level = "trace", skip_all, err)] pub async fn build_tool_call( session: &Session, item: ResponseItem, @@ -131,7 +131,7 @@ impl ToolRouter { } } - #[instrument(skip_all, err)] + #[instrument(level = "trace", skip_all, err)] pub async fn dispatch_tool_call( &self, session: Arc, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 65e96a4fec..922c7b9cfc 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -25,6 +25,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use std::sync::Mutex; +use tracing::Level; use tracing_test::traced_test; use tracing_subscriber::fmt::format::FmtSpan; @@ -454,6 +455,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { let subscriber = tracing_subscriber::fmt() .with_level(true) .with_ansi(false) + .with_max_level(Level::TRACE) .with_span_events(FmtSpan::FULL) .with_writer(MockWriter::new(buffer)) .finish(); @@ -517,6 +519,7 @@ async fn record_responses_sets_span_fields_for_response_events() { let subscriber = tracing_subscriber::fmt() .with_level(true) .with_ansi(false) + .with_max_level(Level::TRACE) .with_span_events(FmtSpan::FULL) .with_writer(MockWriter::new(buffer)) .finish(); diff --git a/codex-rs/otel/src/otel_manager.rs b/codex-rs/otel/src/otel_manager.rs index 268897f8ae..33750d83c5 100644 --- a/codex-rs/otel/src/otel_manager.rs +++ b/codex-rs/otel/src/otel_manager.rs @@ -25,7 +25,7 @@ use std::time::Instant; use strum_macros::Display; use tokio::time::error::Elapsed; use tracing::Span; -use tracing::info_span; +use tracing::trace_span; use tracing_opentelemetry::OpenTelemetrySpanExt; #[derive(Debug, Clone, Serialize, Display)] @@ -67,7 +67,7 @@ impl OtelManager { terminal_type: String, session_source: SessionSource, ) -> OtelManager { - let session_span = info_span!("new_session", conversation_id = %conversation_id, session_source = %session_source); + let session_span = trace_span!("new_session", conversation_id = %conversation_id, session_source = %session_source); if let Some(context) = traceparent_context_from_env() { session_span.set_parent(context); diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 8e2826f834..b9d9559325 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -134,7 +134,7 @@ impl OtelProvider { self.tracer.as_ref().map(|tracer| { tracing_opentelemetry::layer() .with_tracer(tracer.clone()) - .with_filter(LevelFilter::INFO) + .with_filter(LevelFilter::TRACE) }) } From b53889aed50e6cbe125e491e069833bb07ed0bf9 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 16 Dec 2025 18:03:19 +0100 Subject: [PATCH 4/4] Revert "feat: unified exec footer" (#8109) Reverts openai/codex#8067 --- 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, 10 insertions(+), 393 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap delete 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 e9fc5df596..8516687284 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -3,7 +3,6 @@ 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; @@ -41,7 +40,6 @@ 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)] @@ -78,8 +76,6 @@ 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, @@ -127,7 +123,6 @@ 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, @@ -398,12 +393,6 @@ 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); @@ -534,14 +523,8 @@ 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.unified_exec_footer.is_empty() - || !self.queued_user_messages.messages.is_empty() - { + if self.status.is_some() || !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 deleted file mode 100644 index 90bfa7600a..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -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 deleted file mode 100644 index 0828a62efa..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -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 deleted file mode 100644 index 80ec1fb62b..0000000000 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ /dev/null @@ -1,125 +0,0 @@ -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 d04fc5b7a9..37cd004a15 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -99,7 +99,6 @@ 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; @@ -150,11 +149,6 @@ struct RunningCommand { source: ExecCommandSource, } -struct UnifiedExecSessionSummary { - key: String, - command_display: String, -} - struct UnifiedExecWaitState { command_display: String, } @@ -169,13 +163,6 @@ 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; @@ -313,7 +300,6 @@ 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, @@ -842,10 +828,6 @@ 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)); } @@ -857,18 +839,8 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } - 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_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { @@ -896,58 +868,10 @@ 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)); @@ -1395,7 +1319,6 @@ 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(), @@ -1481,7 +1404,6 @@ 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(), @@ -1949,20 +1871,12 @@ impl ChatWidget { EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } - EventMsg::ExecCommandBegin(ev) => { - if !from_replay || !is_unified_exec_source(ev.source) { - self.on_exec_command_begin(ev); - } - } + EventMsg::ExecCommandBegin(ev) => 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) => { - if !from_replay || !is_unified_exec_source(ev.source) { - self.on_exec_command_end(ev); - } - } + EventMsg::ExecCommandEnd(ev) => 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 4447bc0b9d..362b1678f4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -426,7 +426,6 @@ 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(), @@ -1220,7 +1219,7 @@ fn exec_end_without_begin_uses_event_command() { } #[test] -fn exec_history_skips_unified_exec_startup_commands() { +fn exec_history_shows_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin = begin_exec_with_source( @@ -1237,31 +1236,11 @@ fn exec_history_skips_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!( - 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" + blob.contains("• Ran echo unified exec startup"), + "expected startup command to render: {blob:?}" ); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2c0a37ecea..5440040f6b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -375,72 +375,6 @@ 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} ..."), @@ -1624,31 +1558,6 @@ 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();