From be9006c161bd16a37934b43412641c73e7326b1a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 16 Feb 2026 22:40:10 -0300 Subject: [PATCH] fix(tui): centralize usable-width guards for narrow layouts --- codex-rs/tui/src/chatwidget.rs | 2 +- .../src/chatwidget/tests/status_and_layout.rs | 15 ++++++ codex-rs/tui/src/history_cell.rs | 52 ++++++++++++++++++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/width.rs | 34 ++++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/width.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0a72278e43..c80c3fcb6c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4189,7 +4189,7 @@ impl ChatWidget { self.last_rendered_width .get() .or_else(|| terminal::size().ok().map(|(w, _)| usize::from(w))) - .map(|w| w.saturating_sub(reserved_cols)) + .and_then(|w| crate::width::usable_content_width(w, reserved_cols)) } pub(crate) fn on_terminal_resize(&mut self, width: u16) { diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..503e9d7375 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -111,6 +111,21 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { "expected /status to avoid raw config context window, got: {context_line}" ); } + +#[tokio::test] +async fn current_stream_width_returns_none_when_reserved_columns_exhaust_width() { + let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.last_rendered_width.set(Some(2)); + assert_eq!(chat.current_stream_width(2), None); + + chat.last_rendered_width.set(Some(4)); + assert_eq!(chat.current_stream_width(4), None); + + chat.last_rendered_width.set(Some(5)); + assert_eq!(chat.current_stream_width(4), Some(1)); +} + #[tokio::test] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7a9b8a1a76..4809ed23f3 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -407,7 +407,7 @@ impl ReasoningSummaryCell { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, - Some((width as usize).saturating_sub(2)), + crate::width::usable_content_width_u16(width, 2), Some(self.cwd.as_path()), &mut lines, ); @@ -509,10 +509,13 @@ impl AgentMarkdownCell { impl HistoryCell for AgentMarkdownCell { fn display_lines(&self, width: u16) -> Vec> { + let Some(wrap_width) = crate::width::usable_content_width_u16(width, 2) else { + return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); + }; + let mut lines: Vec> = Vec::new(); // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. - let wrap_width = (width as usize).saturating_sub(2); crate::markdown::append_markdown_agent_with_cwd( &self.markdown_source, Some(wrap_width), @@ -4807,6 +4810,51 @@ mod tests { } } + #[test] + fn agent_markdown_cell_narrow_width_shows_prefix_only() { + let source = "| Name | Role |\n|------|------|\n| Alice | Engineer |\n"; + let cell = AgentMarkdownCell::new(source.to_string()); + + let lines = render_lines(&cell.display_lines(2)); + assert_eq!(lines, vec!["• ".to_string()]); + } + + #[test] + fn wrapped_and_prefixed_cells_handle_tiny_widths() { + let user_cell = UserHistoryCell { + message: "tiny width coverage for wrapped user history".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + }; + let agent_message_cell = AgentMessageCell::new(vec!["tiny width agent line".into()], true); + let reasoning_cell = ReasoningSummaryCell::new( + "Plan".to_string(), + "Reasoning summary content for tiny widths.".to_string(), + false, + ); + let agent_markdown_cell = + AgentMarkdownCell::new("| A | B |\n|---|---|\n| x | y |\n".to_string()); + + for width in 1..=4 { + assert!( + !user_cell.display_lines(width).is_empty(), + "user cell should render at width {width}", + ); + assert!( + !agent_message_cell.display_lines(width).is_empty(), + "agent message cell should render at width {width}", + ); + assert!( + !reasoning_cell.display_lines(width).is_empty(), + "reasoning cell should render at width {width}", + ); + assert!( + !agent_markdown_cell.display_lines(width).is_empty(), + "agent markdown cell should render at width {width}", + ); + } + } + #[test] fn agent_markdown_cell_survives_insert_history_rewrap() { let source = "\ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5a9a2e06ff..192b843fd8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -208,6 +208,7 @@ mod voice { pub(crate) fn clear(&self) {} } } +mod width; mod wrapping; diff --git a/codex-rs/tui/src/width.rs b/codex-rs/tui/src/width.rs new file mode 100644 index 0000000000..bf7328ef5a --- /dev/null +++ b/codex-rs/tui/src/width.rs @@ -0,0 +1,34 @@ +/// Returns usable content width after reserving fixed columns. +/// +/// Guarantees a strict positive width (`Some(n)` where `n > 0`) or `None` when +/// the reserved columns consume the full width. +pub(crate) fn usable_content_width(total_width: usize, reserved_cols: usize) -> Option { + total_width + .checked_sub(reserved_cols) + .filter(|remaining| *remaining > 0) +} + +/// `u16` convenience wrapper around [`usable_content_width`]. +pub(crate) fn usable_content_width_u16(total_width: u16, reserved_cols: u16) -> Option { + usable_content_width(usize::from(total_width), usize::from(reserved_cols)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn usable_content_width_returns_none_when_reserved_exhausts_width() { + assert_eq!(usable_content_width(0, 0), None); + assert_eq!(usable_content_width(2, 2), None); + assert_eq!(usable_content_width(3, 4), None); + assert_eq!(usable_content_width(5, 4), Some(1)); + } + + #[test] + fn usable_content_width_u16_matches_usize_variant() { + assert_eq!(usable_content_width_u16(2, 2), None); + assert_eq!(usable_content_width_u16(5, 4), Some(1)); + } +}