diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 407e17955e..8074f3319c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -789,6 +789,7 @@ impl ChatComposer { goal_status_indicator: None, ide_context_active: false, status_line_value: None, + measured_status_line: Default::default(), status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, @@ -4679,17 +4680,7 @@ impl ChatComposer { .render(transcript_hint_area, buf); } None => { - let hint_rect = if let Some(warning_area) = warning_area { - Rect { - width: warning_area - .x - .saturating_sub(footer_rect.x) - .saturating_sub(/*rhs*/ 2), - ..footer_rect - } - } else { - footer_rect - }; + let hint_rect = warning_notice::before_warning(footer_rect, warning_area); if let Some(input) = self.draft.textarea.vim_query() { input.render(inset_footer_hint_area(hint_rect), buf); } else if let Some(line) = self.history_search_footer_line() { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs index d687ed06a8..bf5cffc9cb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs @@ -41,9 +41,23 @@ impl ChatComposer { area: Rect, options: ComposerRenderOptions<'_>, ) -> ComposerLayout { - let footer_hint_height = self.footer_hint_height(area.width, options); - // Preserve input and hints before allocating the persistent status row. - let status_height = self.status_surface_height(options).min( + let mut footer_hint_height = self.footer_hint_height(area.width, options); + let mut status_height = self.status_surface_height(area.width, options); + let shared = footer_hint_height > 1 && self.shows_shared_status(options); + if shared || status_height > 1 { + // Extra status rows yield to the draft, voice strip and other composer surfaces. + let desired = + self.height_with_rows(area.width, options, footer_hint_height, status_height); + let rows = if shared { + &mut footer_hint_height + } else { + &mut status_height + }; + *rows = rows + .saturating_sub(desired.saturating_sub(area.height)) + .max(1); + } + let status_height = status_height.min( area.height .saturating_sub(footer_hint_height.saturating_add(/*rhs*/ 3)), ); @@ -195,7 +209,21 @@ impl ChatComposer { width: u16, options: ComposerRenderOptions<'_>, ) -> u16 { - let footer_hint_height = self.footer_hint_height(width, options); + self.height_with_rows( + width, + options, + self.footer_hint_height(width, options), + self.status_surface_height(width, options), + ) + } + + fn height_with_rows( + &self, + width: u16, + options: ComposerRenderOptions<'_>, + footer_hint_height: u16, + status_height: u16, + ) -> u16 { const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; let inner_width = width.saturating_sub(COLS_WITH_MARGIN.saturating_add(options.textarea_right_reserve)); @@ -206,22 +234,22 @@ impl ChatComposer { .try_into() .unwrap_or(u16::MAX); let remote_images_separator = u16::from(remote_images_height > 0); - self.draft.textarea.desired_height(inner_width) - + remote_images_height - + self.thumbnail_height(inner_width, options) - + remote_images_separator - + self.status_surface_height(options) - + if self.shortcuts_above_composer(options) { + [ + self.draft.textarea.desired_height(inner_width), + remote_images_height, + self.thumbnail_height(inner_width, options), + remote_images_separator, + status_height, + if self.shortcuts_above_composer(options) { footer_height(&self.hint_footer_props(options), width) } else { 0 - } - + 2 - + self - .voice_strip - .as_ref() - .map_or(0, |strip| strip.desired_height(width) + 1) - + if self.popups.active.is_above_composer() + }, + 2, + self.voice_strip.as_ref().map_or(/*default*/ 0, |strip| { + strip.desired_height(width).saturating_add(1) + }), + if self.popups.active.is_above_composer() && options.command_popup_placement != CommandPopupPlacement::AboveComposer { footer_hint_height @@ -229,6 +257,9 @@ impl ChatComposer { self.popups .active .required_height(width, footer_hint_height) - } + }, + ] + .into_iter() + .fold(0, u16::saturating_add) } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index a0d7e70d2f..b70e740c3c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -17,6 +17,7 @@ use std::time::Instant; use super::super::footer::footer_height; use super::super::footer::reset_mode_after_activity; use super::ActivePopup; +use super::warning_notice; use crate::bottom_pane::footer::FooterKeyHints; use crate::bottom_pane::footer::FooterProps; use crate::bottom_pane::footer_content::FooterContent; @@ -283,6 +284,9 @@ impl super::ChatComposer { } pub(super) fn footer_hint_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 { + if self.shows_shared_status(options) { + return self.status_line_height(width, options); + } if self.show_warning_notice(options) || self.shortcuts_above_composer(options) { return 1; } @@ -300,11 +304,12 @@ impl super::ChatComposer { pub(super) fn render_transcript_footer( &self, - mut hint_area: Rect, + hint_area: Rect, buf: &mut Buffer, footer: &TranscriptFooter, warning_area: Option, ) { + let hint_area = warning_notice::before_warning(hint_area, warning_area); let mut text = footer.text.clone(); if footer.cursor_column.is_none() && self.footer.flash_visible() @@ -313,12 +318,6 @@ impl super::ChatComposer { { *line = flash.line.clone(); } - if let Some(warning_area) = warning_area { - hint_area.width = warning_area - .x - .saturating_sub(hint_area.x) - .saturating_sub(/*rhs*/ 2); - } if !footer.is_interactive && !self.footer.flash_visible() && self.footer_mode() == FooterMode::ComposerEmpty @@ -366,6 +365,8 @@ pub(super) struct FooterState { pub(super) goal_status_indicator: Option, pub(super) ide_context_active: bool, pub(super) status_line_value: Option, + // Available width and occupied height, shared by layout and cursor placement. + pub(super) measured_status_line: std::cell::Cell>, pub(super) status_line_enabled: bool, pub(super) side_conversation_context_label: Option, pub(super) active_agent_label: Option, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__extension_layout_uses_the_width_left_by_the_mode_indicator_after_resize.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__extension_layout_uses_the_width_left_by_the_mode_indicator_after_resize.snap index 49953ae4bf..b71c9c9963 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__extension_layout_uses_the_width_left_by_the_mode_indicator_after_resize.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__extension_layout_uses_the_width_left_by_the_mode_indicator_after_resize.snap @@ -1,6 +1,10 @@ --- source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs -expression: "format!(\"{wide}\\n{narrow}\")" +expression: shared --- -branch PR #42 Plan mode (⇧tab to cycle) -branch… Plan mode +16 | Ready PR 12345 +15 | Ready +15 | PR 12345 +16 | Ready PR 12345 +25 | Ready +25 | PR 12345 Plan mode diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap new file mode 100644 index 0000000000..b407541488 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +expression: rows +--- +[ + " ", + "› a ", + " b ", + " c ", + " ", + " one… ", + " ", +] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs index 0b772629e2..e735f0fa2c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs @@ -3,6 +3,9 @@ //! Shortcut help grows above the input, preserving status and close rows below it even when clipped. use super::*; +use ratatui::layout::Size; + +const MAX_FOOTER_ROWS: u16 = 4; impl ChatComposer { pub(crate) fn set_status_line(&mut self, status_line: Option) -> bool { @@ -12,6 +15,7 @@ impl ChatComposer { if matches!(status_line, Some(FooterContent::Extension { .. })) { self.effort_status_line_transition = None; } + self.footer.measured_status_line.set(None); self.footer.status_line_value = status_line; true } @@ -44,8 +48,44 @@ impl ChatComposer { && self.footer_mode().shows_passive_line(self.is_task_running) } - pub(super) fn status_surface_height(&self, options: ComposerRenderOptions<'_>) -> u16 { - u16::from(self.has_separate_status(options)) + pub(super) fn status_surface_height( + &self, + width: u16, + options: ComposerRenderOptions<'_>, + ) -> u16 { + if self.has_separate_status(options) { + self.status_line_height(width, options) + } else { + 0 + } + } + + pub(super) fn status_line_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 { + // The unfocused composer paints one dim status line. + if options.unfocused { + return 1; + } + let Some(FooterContent::Extension { content }) = &self.footer.status_line_value else { + return 1; + }; + let mut area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); + if !options.separate_status_line { + let warning = self.warning_notice_layout(inset_footer_hint_area(area), options); + area = warning_notice::before_warning(area, warning.map(|(rect, _)| rect)); + } + let (_, width) = self.status_line_layout(area, options, content.measure_natural().width); + let cache = &self.footer.measured_status_line; + if let Some((previous_width, height)) = cache.get() + && previous_width == width + { + return height; + } + let height = content + .measure(Size::new(width, MAX_FOOTER_ROWS)) + .height + .max(1); + cache.set(Some((width, height))); + height } pub(super) fn hint_footer_props(&self, options: ComposerRenderOptions<'_>) -> FooterProps { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs index 805f31490e..acce3a30f1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::osc8_hyperlink; use crate::terminal_hyperlinks::strip_osc8; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -59,7 +60,15 @@ fn extension_status_line_clips_links_to_footer_layout() { .iter() .filter(|cell| cell.symbol().contains("https://example.com/pr/42")) .collect::>(); - assert_eq!(linked.len(), if width == 12 { 1 } else { 6 }, "{buffer:?}"); + let linked_text: String = linked + .iter() + .map(|cell| strip_osc8(cell.symbol())) + .collect(); + assert_eq!( + linked_text, + if width == 12 { "PR#" } else { "PR #42" }, + "{buffer:?}" + ); assert!(linked.iter().all(|cell| !cell.symbol().contains('…'))); assert!(buffer.content.iter().any(|cell| cell.symbol() == "p")); } @@ -71,38 +80,237 @@ fn extension_status_line_clips_links_to_footer_layout() { assert!(!output.contains("bot"), "{output}"); } +fn status_rows( + composer: &ChatComposer, + area: Rect, + options: ComposerRenderOptions<'_>, +) -> Vec { + let layout = composer.layout_with_options(area, options); + let status = if options.separate_status_line { + layout.status + } else { + layout.footer + }; + let mut buf = Buffer::empty(area); + composer.render_with_options(area, &mut buf, /*mask_char*/ None, options); + (status.y..status.bottom()) + .map(|y| { + (status.x..status.right()) + .map(|x| buf[(x, y)].symbol()) + .collect::() + .trim() + .to_owned() + }) + .collect() +} + #[test] fn extension_layout_uses_the_width_left_by_the_mode_indicator_after_resize() { let mut composer = composer(); - composer.set_collaboration_modes_enabled(/*enabled*/ true); - composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan)); + composer.footer.show_warnings_key = None; let tree: crate::ui_tree::UiTree = - r#"{"type":"row","props":{"gap":1,"wrap":"wrap"},"children":[ - "branch", {"type":"link","props":{"href":"https://example.com/pr/42"},"children":["PR #42"]} - ]}"# - .parse() - .unwrap(); + r#"{"type":"row","props":{"wrap":"wrap","gap":1},"children":[ + "Ready", {"type":"row","props":{"gap":1},"children":["PR", + {"type":"link","props":{"href":"https://example.com/pr/12345"},"children":["12345"]}]}]}"# + .parse() + .unwrap(); composer.set_status_line(Some(FooterContent::from(tree))); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + let linked_number: String = "12345" + .chars() + .map(|digit| osc8_hyperlink("https://example.com/pr/12345", &digit.to_string())) + .collect(); + let [shared, separate] = [false, true].map(|separate_status_line| { + let mut frames = Vec::new(); + for (width, mode, warning_count) in [ + (16, None, 0), + (15, None, 0), + (16, None, 0), + (25, Some(CollaborationModeIndicator::Plan), 0), + (19, None, 1), + ] { + composer.set_collaboration_mode_indicator(mode); + let options = ComposerRenderOptions { + separate_status_line, + warning_count, + ..Default::default() + }; + let height = composer.desired_height_with_options(width, options); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let rows = status_rows(&composer, area, options); + for row in &rows { + let text = strip_osc8(row); + assert_eq!(*row, text.replace("12345", &linked_number)); + if warning_count == 0 { + frames.push(format!("{width} | {text}")); + } + } + if warning_count > 0 { + assert_eq!( + composer.footer.warning_notice_area.get().unwrap().bottom(), + height + ); + assert_eq!(rows.len(), if separate_status_line { 1 } else { 2 }); + let last = strip_osc8(rows.last().unwrap()); + assert!(last.contains("PR 12345")); + assert_eq!(last.contains("⚠ 1"), !separate_status_line); + } + } + frames.join("\n") + }); + assert_eq!(shared, separate); + insta::assert_snapshot!(shared); +} - let hosts = [false, true].map(|separate_status_line| { +#[test] +fn status_rows_yield_to_the_composer_and_shared_footer_overrides() { + let mut composer = composer(); + let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour\nfive""#.parse().unwrap(); + composer.set_status_line(Some(FooterContent::from(tree))); + composer.set_text_content("a\nb\nc".into(), Vec::new(), Vec::new()); + for separate_status_line in [false, true] { let options = ComposerRenderOptions { separate_status_line, ..Default::default() }; - [55, 24, 55].map(|width| { - let height = composer.desired_height_with_options(width, options); - let area = Rect::new(0, 0, width, height); + let short = if separate_status_line { + "one…" + } else { + "one\ntwo…" + }; + for (height, expected) in [(12, "one\ntwo\nthree\nfour…"), (7, short)] { + let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 7, height); + assert_eq!(status_rows(&composer, area, options).join("\n"), expected); + let input = composer.layout_with_options(area, options).textarea; let mut buffer = Buffer::empty(area); composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); - let y = height - 1 - u16::from(separate_status_line); - let row: String = (0..width).map(|x| buffer[(x, y)].symbol()).collect(); - strip_osc8(&row).trim().to_owned() - }) + assert!(input.height >= 3); + let draft = [0, 1, 2].map(|dy| buffer[(input.x, input.y + dy)].symbol()); + assert_eq!(draft, ["a", "b", "c"]); + let (x, y) = composer + .cursor_pos_with_options(area, options) + .expect("visible draft cursor"); + assert!(input.contains((x, y).into())); + if separate_status_line && height == 7 { + let rows: Vec = buffer + .content + .chunks(usize::from(area.width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect()) + .collect(); + insta::assert_debug_snapshot!(rows); + } + } + } + let tree: crate::ui_tree::UiTree = r#""x\ny""#.parse().unwrap(); + composer.set_status_line(Some(FooterContent::from(tree))); + assert_eq!( + composer.footer_hint_height(/*width*/ 7, ComposerRenderOptions::default()), + 2 + ); + composer.show_footer_flash("Copied".into(), Duration::from_secs(/*secs*/ 3)); + assert_eq!( + composer.footer_hint_height(/*width*/ 7, ComposerRenderOptions::default()), + 1 + ); +} + +#[test] +fn sidebar_focus_collapses_extension_status_to_its_dim_line() { + let mut composer = composer(); + composer.insert_str("draft"); + let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour""#.parse().unwrap(); + composer.set_status_line(Some(tree.into())); + let frames = [false, true, false].map(|unfocused| { + let options = ComposerRenderOptions { + separate_status_line: true, + unfocused, + ..Default::default() + }; + let height = composer.desired_height_with_options(/*width*/ 40, options); + status_rows(&composer, Rect::new(0, 0, 40, height), options).join("\n") }); - let [wide, narrow, restored] = &hosts[0]; - assert_eq!(hosts[0], hosts[1]); - assert_eq!(wide, restored); - insta::assert_snapshot!(format!("{wide}\n{narrow}")); + insta::assert_snapshot!(frames.join("\n---\n"), @" + one + two + three + four + --- + one… + --- + one + two + three + four + "); +} + +#[test] +fn extension_status_paints_with_the_current_theme_at_the_same_width() { + use crate::render::highlight::current_syntax_theme; + use crate::render::highlight::set_syntax_theme; + use ratatui::style::Color; + use syntect::highlighting::Color as ThemeColor; + + let mut composer = composer(); + let content: crate::ui_tree::UiTree = + r#"{"type":"text","props":{"color":"primary"},"children":["λ"]}"# + .parse() + .unwrap(); + composer.set_status_line(Some(FooterContent::from(content))); + let previous = current_syntax_theme(); + let colors = [1, 2].map(|index| { + let mut theme = previous.clone(); + theme.settings.foreground = Some(ThemeColor { + r: index, + g: 0, + b: 0, + a: 0, + }); + set_syntax_theme(theme); + let options = ComposerRenderOptions::default(); + let width = 24; + let area = Rect::new( + 0, + 0, + width, + composer.desired_height_with_options(width, options), + ); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + buffer + .content + .iter() + .find(|cell| cell.symbol() == "λ") + .unwrap() + .fg + }); + set_syntax_theme(previous); + assert_eq!(colors, [Color::Red, Color::Green]); +} + +#[test] +fn extension_status_keeps_its_rows_when_effort_changes() { + let mut composer = composer(); + let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree""#.parse().unwrap(); + composer.set_status_line(Some(FooterContent::from(tree))); + composer.set_active_reasoning_effort_baseline(/*effort*/ None); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Max), + /*animations_enabled*/ true, + ); + for separate_status_line in [false, true] { + let options = ComposerRenderOptions { + separate_status_line, + ..Default::default() + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 7, /*height*/ 12, + ); + assert_eq!( + status_rows(&composer, area, options), + ["one", "two", "three"] + ); + } } fn render( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs b/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs index f3a768f278..e9a98b71d5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs @@ -1,10 +1,17 @@ //! Fit warning counts and the configured warnings shortcut into the existing passive hint row. -//! Interactive hints retain priority; warning delivery never changes composer geometry. +//! Interactive hints retain priority. Shared status content wraps beside the warning. //! Only the count and warning label receive an amber accent; surrounding hints stay muted. use super::*; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +pub(super) fn before_warning(mut area: Rect, warning: Option) -> Rect { + if let Some(warning) = warning { + area.width = warning.x.saturating_sub(area.x).saturating_sub(/*rhs*/ 2); + } + area +} + impl ChatComposer { pub(crate) fn warning_notice_contains(&self, position: ratatui::layout::Position) -> bool { self.footer diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index d81af44f46..939b251375 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -48,7 +48,6 @@ impl PreparedUiTree { }) } - #[cfg_attr(not(test), expect(dead_code))] // Used by composer layout in the next change. pub(crate) fn measure(&self, bounds: Size) -> Size { layout(self.0.as_ref(), bounds, /*draw*/ None) }