From ce6de831f264f57a15a660f65889a3cfeadd1c9f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 14:24:44 -0300 Subject: [PATCH] fix(tui): stabilize tab status detail updates --- codex-rs/tui/src/bottom_pane/mod.rs | 49 +---------------- .../tui/src/bottom_pane/tab_status_detail.rs | 53 +++++++++++++++++++ .../tui/src/bottom_pane/tab_status_state.rs | 1 - .../src/bottom_pane/tab_status_state_tests.rs | 4 +- codex-rs/tui/src/tab_status.rs | 6 ++- codex-rs/tui/src/tab_status_tests.rs | 10 ++++ 6 files changed, 71 insertions(+), 52 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/tab_status_detail.rs diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4ecbe630ad..11176529d0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -59,6 +59,7 @@ mod request_user_input; mod status_line_setup; mod status_line_style; mod status_surface_preview; +mod tab_status_detail; mod tab_status_state; mod title_setup; pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX; @@ -328,54 +329,6 @@ impl BottomPane { } } - fn desired_tab_status(&self) -> (TabStatus, Option) { - let class = self.desired_tab_class(); - let detail = match class { - TabStatus::Waiting => self - .active_view() - .and_then(BottomPaneView::tab_status_detail), - TabStatus::Working => self.working_tab_status_detail(), - TabStatus::Idle => self - .tab_status - .last_activity() - .map(|activity| format!("last: {activity}")), - }; - (class, detail) - } - - fn working_tab_status_detail(&self) -> Option { - let mut parts = Vec::new(); - if let Some(activity) = self.tab_status.current_activity() { - parts.push(activity.to_string()); - } - let push_if_new = |parts: &mut Vec, candidate: &str| { - let candidate = candidate.trim(); - if candidate.is_empty() - || parts - .iter() - .any(|part| part.contains(candidate) || candidate.contains(part.as_str())) - { - return; - } - parts.push(candidate.to_string()); - }; - if let Some(status) = self.status.as_ref() { - let header = status.header(); - if header != "Working" { - push_if_new(&mut parts, header); - } - if let Some(first_line) = status.details().and_then(|details| details.lines().next()) { - push_if_new(&mut parts, first_line); - } - } - if !parts.is_empty() { - return Some(parts.join(" • ")); - } - self.tab_status - .last_activity() - .map(|activity| format!("after: {activity}")) - } - fn refresh_tab_status(&mut self) { self.refresh_tab_status_at(Instant::now()); } diff --git a/codex-rs/tui/src/bottom_pane/tab_status_detail.rs b/codex-rs/tui/src/bottom_pane/tab_status_detail.rs new file mode 100644 index 0000000000..15b4f4766f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tab_status_detail.rs @@ -0,0 +1,53 @@ +use super::BottomPane; +use super::BottomPaneView; +use crate::tab_status::TabStatus; + +impl BottomPane { + pub(super) fn desired_tab_status(&self) -> (TabStatus, Option) { + let class = self.desired_tab_class(); + let detail = match class { + TabStatus::Waiting => self + .active_view() + .and_then(BottomPaneView::tab_status_detail), + TabStatus::Working => self.working_tab_status_detail(), + TabStatus::Idle => self + .tab_status + .last_activity() + .map(|activity| format!("last: {activity}")), + }; + (class, detail) + } + + fn working_tab_status_detail(&self) -> Option { + let mut parts = Vec::new(); + if let Some(activity) = self.tab_status.current_activity() { + parts.push(activity.to_string()); + } + let push_if_new = |parts: &mut Vec, candidate: &str| { + let candidate = candidate.trim(); + if candidate.is_empty() + || parts + .iter() + .any(|part| part.contains(candidate) || candidate.contains(part.as_str())) + { + return; + } + parts.push(candidate.to_string()); + }; + if let Some(status) = self.status.as_ref() { + let header = status.header(); + if header != "Working" { + push_if_new(&mut parts, header); + } + if let Some(first_line) = status.details().and_then(|details| details.lines().next()) { + push_if_new(&mut parts, first_line); + } + } + if !parts.is_empty() { + return Some(parts.join(" • ")); + } + self.tab_status + .last_activity() + .map(|activity| format!("after: {activity}")) + } +} diff --git a/codex-rs/tui/src/bottom_pane/tab_status_state.rs b/codex-rs/tui/src/bottom_pane/tab_status_state.rs index 0d283b621c..986b494ffd 100644 --- a/codex-rs/tui/src/bottom_pane/tab_status_state.rs +++ b/codex-rs/tui/src/bottom_pane/tab_status_state.rs @@ -80,7 +80,6 @@ impl TabStatusState { return; } if self.last_status.as_ref() == Some(&desired) { - self.last_emit_at = Some(now); return; } if let Err(err) = set_tab_status(desired.0, desired.1.as_deref()) { diff --git a/codex-rs/tui/src/bottom_pane/tab_status_state_tests.rs b/codex-rs/tui/src/bottom_pane/tab_status_state_tests.rs index b37c784d0d..c77e54e317 100644 --- a/codex-rs/tui/src/bottom_pane/tab_status_state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/tab_status_state_tests.rs @@ -43,7 +43,7 @@ fn throttle_defers_detail_changes_but_not_status_changes() { } #[test] -fn equal_refresh_rearms_the_detail_throttle() { +fn equal_refresh_does_not_rearm_the_detail_throttle() { let mut state = TabStatusState::new(); let started_at = Instant::now(); let unchanged_at = started_at + Duration::from_millis(/*millis*/ 300); @@ -56,6 +56,6 @@ fn equal_refresh_rearms_the_detail_throttle() { TabStatus::Working, unchanged_at + Duration::from_millis(/*millis*/ 100) ), - Some(Duration::from_millis(/*millis*/ 150)) + None ); } diff --git a/codex-rs/tui/src/tab_status.rs b/codex-rs/tui/src/tab_status.rs index 5c4daef772..4109c390dc 100644 --- a/codex-rs/tui/src/tab_status.rs +++ b/codex-rs/tui/src/tab_status.rs @@ -131,12 +131,16 @@ pub(crate) fn clear_tab_status() -> io::Result<()> { } fn sanitize_detail(detail: &str) -> String { - let mut out = String::with_capacity(detail.len()); + let mut out = String::with_capacity(detail.len().min(MAX_TAB_STATUS_DETAIL_CHARS * 2 + 1)); let mut chars_written = 0; let mut pending_space = false; let mut truncated = false; for ch in detail.chars() { + if chars_written >= MAX_TAB_STATUS_DETAIL_CHARS { + truncated = true; + break; + } if ch.is_whitespace() { pending_space = !out.is_empty(); continue; diff --git a/codex-rs/tui/src/tab_status_tests.rs b/codex-rs/tui/src/tab_status_tests.rs index 35afa4cdb2..136f370bb9 100644 --- a/codex-rs/tui/src/tab_status_tests.rs +++ b/codex-rs/tui/src/tab_status_tests.rs @@ -50,6 +50,16 @@ fn detail_is_safe_bounded_osc_text() { MAX_TAB_STATUS_DETAIL_CHARS * 2 + 1 ); assert!(sanitized.ends_with('…')); + + let sanitized = sanitize_detail(&format!( + "{}{}tail", + "x".repeat(MAX_TAB_STATUS_DETAIL_CHARS), + " \u{202E}".repeat(/*n*/ 10_000) + )); + assert_eq!( + sanitized, + format!("{}…", "x".repeat(MAX_TAB_STATUS_DETAIL_CHARS)) + ); } #[test]