diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4ad117082..7634f699a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -288,12 +288,13 @@ impl BottomPane { self.request_redraw(); } - /// Update the animated header shown to the left of the brackets in the - /// status indicator (defaults to "Working"). No-ops if the status - /// indicator is not active. - pub(crate) fn update_status_header(&mut self, header: String) { + /// Update the status indicator header (defaults to "Working") and details below it. + /// + /// Passing `None` clears any existing details. No-ops if the status indicator is not active. + pub(crate) fn update_status(&mut self, header: String, details: Option) { if let Some(status) = self.status.as_mut() { status.update_header(header); + status.update_details(details); self.request_redraw(); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aad4e44ad..fb5fad184 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -411,15 +411,22 @@ impl ChatWidget { } } - fn set_status_header(&mut self, header: String) { + /// Update the status indicator header and details. + /// + /// Passing `None` clears any existing details. + fn set_status(&mut self, header: String, details: Option) { self.current_status_header = header.clone(); - self.bottom_pane.update_status_header(header); + self.bottom_pane.update_status(header, details); + } + + /// Convenience wrapper around [`Self::set_status`]; + /// updates the status indicator header and clears any existing details. + fn set_status_header(&mut self, header: String) { + self.set_status(header, None); } fn restore_retry_status_header_if_present(&mut self) { - if let Some(header) = self.retry_status_header.take() - && self.current_status_header != header - { + if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5aa0e5d06..ae3e7abd6 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3292,6 +3292,7 @@ async fn stream_recovery_restores_previous_status_header() { .status_widget() .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); + assert_eq!(status.details(), None); assert!(chat.retry_status_header.is_none()); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap new file mode 100644 index 000000000..565d5451f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"• Working (0s) " +" └ A man a plan a canal " +" panama " diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index e8dc689a6..3fae3ac29 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -1,4 +1,5 @@ use crate::chatwidget::get_limits_duration; +use crate::text_formatting::capitalize_first; use super::helpers::format_reset_timestamp; use chrono::DateTime; @@ -221,15 +222,3 @@ fn format_credit_balance(raw: &str) -> Option { None } - -fn capitalize_first(label: &str) -> String { - let mut chars = label.chars(); - match chars.next() { - Some(first) => { - let mut capitalized = first.to_uppercase().collect::(); - capitalized.push_str(chars.as_str()); - capitalized - } - None => String::new(), - } -} diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 642b9ca2b..bef0d0328 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -10,7 +10,11 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -18,11 +22,18 @@ use crate::exec_cell::spinner; use crate::key_hint; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; +use crate::text_formatting::capitalize_first; use crate::tui::FrameRequester; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +const DETAILS_MAX_LINES: usize = 3; +const DETAILS_PREFIX: &str = " └ "; pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, + details: Option, show_interrupt_hint: bool, elapsed_running: Duration, @@ -58,6 +69,7 @@ impl StatusIndicatorWidget { ) -> Self { Self { header: String::from("Working"), + details: None, show_interrupt_hint: true, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), @@ -78,11 +90,23 @@ impl StatusIndicatorWidget { self.header = header; } + /// Update the details text shown below the header. + pub(crate) fn update_details(&mut self, details: Option) { + self.details = details + .filter(|details| !details.is_empty()) + .map(|details| capitalize_first(details.trim_start())); + } + #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.header } + #[cfg(test)] + pub(crate) fn details(&self) -> Option<&str> { + self.details.as_deref() + } + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { self.show_interrupt_hint = visible; } @@ -132,11 +156,43 @@ impl StatusIndicatorWidget { pub fn elapsed_seconds(&self) -> u64 { self.elapsed_seconds_at(Instant::now()) } + + /// Wrap the details text into a fixed width and return the lines, truncating if necessary. + fn wrapped_details_lines(&self, width: u16) -> Vec> { + let Some(details) = self.details.as_deref() else { + return Vec::new(); + }; + if width == 0 { + return Vec::new(); + } + + let prefix_width = UnicodeWidthStr::width(DETAILS_PREFIX); + let opts = RtOptions::new(usize::from(width)) + .initial_indent(Line::from(DETAILS_PREFIX.dim())) + .subsequent_indent(Line::from(Span::from(" ".repeat(prefix_width)).dim())) + .break_words(true); + + let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts); + + if out.len() > DETAILS_MAX_LINES { + out.truncate(DETAILS_MAX_LINES); + let content_width = usize::from(width).saturating_sub(prefix_width).max(1); + let max_base_len = content_width.saturating_sub(1); + if let Some(last) = out.last_mut() + && let Some(span) = last.spans.last_mut() + { + let trimmed: String = span.content.as_ref().chars().take(max_base_len).collect(); + *span = format!("{trimmed}…").dim(); + } + } + + out + } } impl Renderable for StatusIndicatorWidget { - fn desired_height(&self, _width: u16) -> u16 { - 1 + fn desired_height(&self, width: u16) -> u16 { + 1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0) } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -170,7 +226,16 @@ impl Renderable for StatusIndicatorWidget { spans.push(format!("({pretty_elapsed})").dim()); } - Line::from(spans).render_ref(area, buf); + let mut lines = Vec::new(); + lines.push(Line::from(spans)); + if area.height > 1 { + // If there is enough space, add the details lines below the header. + let details = self.wrapped_details_lines(area.width); + let max_details = usize::from(area.height.saturating_sub(1)); + lines.extend(details.into_iter().take(max_details)); + } + + Paragraph::new(Text::from(lines)).render_ref(area, buf); } } @@ -229,6 +294,27 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + #[test] + fn renders_wrapped_details_panama_two_lines() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); + w.update_details(Some("A man a plan a canal panama".to_string())); + w.set_interrupt_hint_visible(false); + + // Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable. + w.is_paused = true; + w.elapsed_running = Duration::ZERO; + + // Prefix is 4 columns, so a width of 30 yields a content width of 26: one column + // short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis. + let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal"); + terminal + .draw(|f| w.render(f.area(), f.buffer_mut())) + .expect("draw"); + insta::assert_snapshot!(terminal.backend()); + } + #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::(); @@ -250,4 +336,20 @@ mod tests { let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); assert_eq!(after_resume, before_pause + 3); } + + #[test] + fn details_overflow_adds_ellipsis() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + w.update_details(Some("abcd abcd abcd abcd".to_string())); + + let lines = w.wrapped_details_lines(6); + assert_eq!(lines.len(), DETAILS_MAX_LINES); + let last = lines.last().expect("expected last details line"); + assert!( + last.spans[1].content.as_ref().ends_with("…"), + "expected ellipsis in last line: {last:?}" + ); + } } diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index 91d1c84f2..f747b2fef 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -2,6 +2,18 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; +pub(crate) fn capitalize_first(input: &str) -> String { + let mut chars = input.chars(); + match chars.next() { + Some(first) => { + let mut capitalized = first.to_uppercase().collect::(); + capitalized.push_str(chars.as_str()); + capitalized + } + None => String::new(), + } +} + /// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating. /// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells. pub(crate) fn format_and_truncate_tool_result( diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index c6ea991c8..961254def 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -266,12 +266,13 @@ impl BottomPane { self.composer.current_text() } - /// Update the animated header shown to the left of the brackets in the - /// status indicator (defaults to "Working"). No-ops if the status - /// indicator is not active. - pub(crate) fn update_status_header(&mut self, header: String) { + /// Update the status indicator header (defaults to "Working") and details below it. + /// + /// Passing `None` clears any existing details. No-ops if the status indicator is not active. + pub(crate) fn update_status(&mut self, header: String, details: Option) { if let Some(status) = self.status.as_mut() { status.update_header(header); + status.update_details(details); self.request_redraw(); } } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ee0dd75dd..1e1ee37d0 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -377,15 +377,22 @@ impl ChatWidget { } } - fn set_status_header(&mut self, header: String) { + /// Update the status indicator header and details. + /// + /// Passing `None` clears any existing details. + fn set_status(&mut self, header: String, details: Option) { self.current_status_header = header.clone(); - self.bottom_pane.update_status_header(header); + self.bottom_pane.update_status(header, details); + } + + /// Convenience wrapper around [`Self::set_status`]; + /// updates the status indicator header and clears any existing details. + fn set_status_header(&mut self, header: String) { + self.set_status(header, None); } fn restore_retry_status_header_if_present(&mut self) { - if let Some(header) = self.retry_status_header.take() - && self.current_status_header != header - { + if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); } } diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index ff6b79b89..a71be3a63 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -2944,6 +2944,7 @@ async fn stream_recovery_restores_previous_status_header() { .status_widget() .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); + assert_eq!(status.details(), None); assert!(chat.retry_status_header.is_none()); } diff --git a/codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap b/codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap new file mode 100644 index 000000000..94c0fc308 --- /dev/null +++ b/codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"• Working (0s) " +" └ A man a plan a canal " +" panama " diff --git a/codex-rs/tui2/src/status/rate_limits.rs b/codex-rs/tui2/src/status/rate_limits.rs index e8dc689a6..3fae3ac29 100644 --- a/codex-rs/tui2/src/status/rate_limits.rs +++ b/codex-rs/tui2/src/status/rate_limits.rs @@ -1,4 +1,5 @@ use crate::chatwidget::get_limits_duration; +use crate::text_formatting::capitalize_first; use super::helpers::format_reset_timestamp; use chrono::DateTime; @@ -221,15 +222,3 @@ fn format_credit_balance(raw: &str) -> Option { None } - -fn capitalize_first(label: &str) -> String { - let mut chars = label.chars(); - match chars.next() { - Some(first) => { - let mut capitalized = first.to_uppercase().collect::(); - capitalized.push_str(chars.as_str()); - capitalized - } - None => String::new(), - } -} diff --git a/codex-rs/tui2/src/status_indicator_widget.rs b/codex-rs/tui2/src/status_indicator_widget.rs index 642b9ca2b..8d3098a6f 100644 --- a/codex-rs/tui2/src/status_indicator_widget.rs +++ b/codex-rs/tui2/src/status_indicator_widget.rs @@ -10,7 +10,11 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -18,11 +22,18 @@ use crate::exec_cell::spinner; use crate::key_hint; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; +use crate::text_formatting::capitalize_first; use crate::tui::FrameRequester; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +const DETAILS_MAX_LINES: usize = 3; +const DETAILS_PREFIX: &str = " └ "; pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, + details: Option, show_interrupt_hint: bool, elapsed_running: Duration, @@ -58,6 +69,7 @@ impl StatusIndicatorWidget { ) -> Self { Self { header: String::from("Working"), + details: None, show_interrupt_hint: true, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), @@ -78,11 +90,23 @@ impl StatusIndicatorWidget { self.header = header; } + /// Update the details text shown below the header. + pub(crate) fn update_details(&mut self, details: Option) { + self.details = details + .filter(|details| !details.is_empty()) + .map(|details| capitalize_first(details.trim_start())); + } + #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.header } + #[cfg(test)] + pub(crate) fn details(&self) -> Option<&str> { + self.details.as_deref() + } + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { self.show_interrupt_hint = visible; } @@ -132,11 +156,43 @@ impl StatusIndicatorWidget { pub fn elapsed_seconds(&self) -> u64 { self.elapsed_seconds_at(Instant::now()) } + + /// Wrap the details text into a fixed width and return the lines, truncating if necessary. + fn wrapped_details_lines(&self, width: u16) -> Vec> { + let Some(details) = self.details.as_deref() else { + return Vec::new(); + }; + if width == 0 { + return Vec::new(); + } + + let prefix_width = UnicodeWidthStr::width(DETAILS_PREFIX); + let opts = RtOptions::new(usize::from(width)) + .initial_indent(Line::from(DETAILS_PREFIX.dim())) + .subsequent_indent(Line::from(Span::from(" ".repeat(prefix_width)).dim())) + .break_words(true); + + let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts); + + if out.len() > DETAILS_MAX_LINES { + out.truncate(DETAILS_MAX_LINES); + let content_width = usize::from(width).saturating_sub(prefix_width).max(1); + let max_base_len = content_width.saturating_sub(1); + if let Some(last) = out.last_mut() + && let Some(span) = last.spans.last_mut() + { + let trimmed: String = span.content.as_ref().chars().take(max_base_len).collect(); + *span = format!("{trimmed}…").dim(); + } + } + + out + } } impl Renderable for StatusIndicatorWidget { - fn desired_height(&self, _width: u16) -> u16 { - 1 + fn desired_height(&self, width: u16) -> u16 { + 1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0) } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -170,7 +226,16 @@ impl Renderable for StatusIndicatorWidget { spans.push(format!("({pretty_elapsed})").dim()); } - Line::from(spans).render_ref(area, buf); + let mut lines = Vec::new(); + lines.push(Line::from(spans)); + // If there is enough space, add the details lines below the header. + if area.height > 1 { + let details = self.wrapped_details_lines(area.width); + let max_details = usize::from(area.height.saturating_sub(1)); + lines.extend(details.into_iter().take(max_details)); + } + + Paragraph::new(Text::from(lines)).render_ref(area, buf); } } @@ -229,6 +294,27 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + #[test] + fn renders_wrapped_details_panama_two_lines() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); + w.update_details(Some("A man a plan a canal panama".to_string())); + w.set_interrupt_hint_visible(false); + + // Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable. + w.is_paused = true; + w.elapsed_running = Duration::ZERO; + + // Prefix is 4 columns, so a width of 30 yields a content width of 26: one column + // short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis. + let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal"); + terminal + .draw(|f| w.render(f.area(), f.buffer_mut())) + .expect("draw"); + insta::assert_snapshot!(terminal.backend()); + } + #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::(); @@ -250,4 +336,20 @@ mod tests { let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); assert_eq!(after_resume, before_pause + 3); } + + #[test] + fn details_overflow_adds_ellipsis() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + w.update_details(Some("abcd abcd abcd abcd".to_string())); + + let lines = w.wrapped_details_lines(6); + assert_eq!(lines.len(), DETAILS_MAX_LINES); + let last = lines.last().expect("expected last details line"); + assert!( + last.spans[1].content.as_ref().ends_with("…"), + "expected ellipsis in last line: {last:?}" + ); + } } diff --git a/codex-rs/tui2/src/text_formatting.rs b/codex-rs/tui2/src/text_formatting.rs index 91d1c84f2..f747b2fef 100644 --- a/codex-rs/tui2/src/text_formatting.rs +++ b/codex-rs/tui2/src/text_formatting.rs @@ -2,6 +2,18 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; +pub(crate) fn capitalize_first(input: &str) -> String { + let mut chars = input.chars(); + match chars.next() { + Some(first) => { + let mut capitalized = first.to_uppercase().collect::(); + capitalized.push_str(chars.as_str()); + capitalized + } + None => String::new(), + } +} + /// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating. /// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells. pub(crate) fn format_and_truncate_tool_result(