From d0edb0b1fa55e74486c5dd8a0a4fbcf75410ec79 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 12 Nov 2026 14:59:00 +0000 Subject: [PATCH] Preserve multiline status lines in unfocused chat panes (#61141) ## Why Unfocused chat panes reduced status content to a single dim line, hiding rows that were visible while focused. ## What changed - Share status measurement, wrapping, rendering, and footer row allocation between the main conversation and side chats. - Retain structured status content in inactive side chats so native and extension status lines can use the available rows and reflow when resized. - Keep unfocused status content dimmed with hyperlinks suppressed, while respecting space reserved for the draft and footer hints. ## Testing Add regression coverage for status reflow across split-pane focus switches and resizing, and update extension status coverage to verify multiple dimmed rows when unfocused. GitOrigin-RevId: f4def37259fe106d6a3fac9fe4c6cc2d4972f244 --- codex-rs/tui/src/app/owned_transcript.rs | 5 +- codex-rs/tui/src/app/side_companion.rs | 2 +- codex-rs/tui/src/app/side_pane.rs | 46 +++-- codex-rs/tui/src/app/tests.rs | 113 +++++++++++ codex-rs/tui/src/app_event.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 9 +- .../chat_composer/composer_layout.rs | 107 ++++++---- .../bottom_pane/chat_composer/footer_state.rs | 5 +- .../chat_composer/native_status.rs | 133 ------------- .../chat_composer/status_surface.rs | 107 ++++------ .../chat_composer/status_surface_tests.rs | 15 +- codex-rs/tui/src/bottom_pane/footer.rs | 14 +- .../src/bottom_pane/footer/footer_content.rs | 96 ++++++--- .../footer/footer_content_tests.rs | 5 +- codex-rs/tui/src/bottom_pane/mod.rs | 4 +- .../src/bottom_pane/multi_select_picker.rs | 6 +- codex-rs/tui/src/bottom_pane/status_line.rs | 187 ++++++++++++++++++ .../src/bottom_pane/status_surface_preview.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/streaming.rs | 2 +- .../tui/src/terminal_hyperlinks/paragraph.rs | 11 -- codex-rs/tui/src/ui_tree/render.rs | 21 +- codex-rs/tui/src/ui_tree_tests.rs | 18 +- 23 files changed, 572 insertions(+), 339 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/native_status.rs create mode 100644 codex-rs/tui/src/bottom_pane/status_line.rs diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 37e406c141..8a42f0af62 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -581,10 +581,7 @@ impl App { // Painting those rows tells us whether newer activity is still hidden, // which can change the footer hint and the composer's measured height. let footer = view.footer_with_navigation(footer_width, motion, latest_navigation); - // Allow up to two-thirds of the pane, or eight rows on short panes when they fit. - let max_composer_height = ((u32::from(area.height) * 2 / 3) as u16) - .max(8) - .min(area.height); + let max_composer_height = side_pane::composer_height_limit(area.height); let unfocused = matches!( self.conversation_frame.sidebar, Some(sidebar::SidebarLayout::Chooser { diff --git a/codex-rs/tui/src/app/side_companion.rs b/codex-rs/tui/src/app/side_companion.rs index a1b739cc1a..4cd96f1a43 100644 --- a/codex-rs/tui/src/app/side_companion.rs +++ b/codex-rs/tui/src/app/side_companion.rs @@ -57,7 +57,7 @@ impl SideCompanion { active: None, auxiliary: Vec::new(), draft: Box::default(), - status: None, + status_line: None, work: None, last_completed_agent_message: None, cwd, diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index c274b1a82a..34964976d5 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -7,7 +7,9 @@ use super::sidebar; use super::sidebar::PaneFocus; use super::sidebar::SidebarContent; use super::sidebar::SidebarLayout; -use crate::bottom_pane::render_unfocused_status; +use crate::bottom_pane::FooterLayout; +use crate::bottom_pane::StatusLine; +use crate::bottom_pane::footer_content::StatusLineFocus; use crate::custom_terminal::Frame; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; @@ -428,6 +430,13 @@ impl App { } } +/// Allow up to two-thirds of the pane, or eight rows on short panes when they fit. +pub(super) fn composer_height_limit(pane_height: u16) -> u16 { + ((u32::from(pane_height) * 2 / 3) as u16) + .max(8) + .min(pane_height) +} + impl SidePaneLayout { /// Paint the divider, retained transcript and draft without claiming the terminal cursor. pub(super) fn paint_companion( @@ -440,11 +449,19 @@ impl SidePaneLayout { companion.flush_answer(); let area = self.passive; // Keep the live composer's bottom hint row blank on the inactive side. - let available = area.height.saturating_sub(1); - let composer_height = available.min(3); - let status_height = - u16::from(companion.display.status.is_some() && available > composer_height); - let above = available - composer_height - status_height; + let hint_rows = 1; + let composer_height = area.height.saturating_sub(hint_rows).min(3); + let available = area.height - composer_height; + let footer = FooterLayout::new( + Some(composer_height_limit(area.height)), + hint_rows, + /*priority_hint_rows*/ hint_rows, + ); + let status = companion.display.status_line.as_ref(); + let status_line = StatusLine::default(); + let wanted_status = status_line.height(status, area.width, footer.status_budget(available)); + let footer = footer.allocate(available, wanted_status); + let above = available - footer.height(); let mut transcript = Rect { height: above.saturating_sub(1), ..area @@ -495,17 +512,12 @@ impl SidePaneLayout { &mut Default::default(), ); } - if let Some(status) = &companion.display.status { - render_unfocused_status( - status.clone(), - Rect { - y: composer.bottom(), - height: status_height, - ..area - }, - frame.buffer, - ); - } + status_line.render( + status, + footer.status_area(area), + frame.buffer, + StatusLineFocus::Unfocused, + ); } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a77607b4e6..c268fcfe50 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5654,6 +5654,119 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() Ok(()) } +#[tokio::test] +async fn split_status_lines_reflow_across_focus_switches() -> Result<()> { + use crate::bottom_pane::StatusLineItem; + use ratatui::style::Modifier; + use ratatui::text::Line; + + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let primary = server.start_thread(&app.config).await?; + let main = primary.session.thread_id; + app.enqueue_primary_thread_session(primary.session, primary.turns) + .await?; + let side = ThreadId::new(); + let channel = ThreadEventChannel::new(/*capacity*/ 1); + channel.store.lock().await.set_session( + test_thread_session(side, app.config.cwd.to_path_buf()), + Vec::new(), + ); + app.thread_event_channels.insert(side, channel); + app.side_threads.insert(side, SideThreadState::new(main)); + app.sidebar_mut().show( + sidebar::SidebarContent::Chat(side), + sidebar::PaneFocus::Main, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + + let main_status = "MainStatusSummaryForThePrimaryWorkspaceContext MainStatusDetailsForThePrimaryWorkspaceContext"; + let side_status = "SideStatusSummaryText SideStatusDetails"; + let mut frames = Vec::new(); + for (index, (focused, width, height)) in [ + (main, 100, 24), + (side, 100, 24), + (main, 100, 24), + (main, 130, 24), + ] + .into_iter() + .enumerate() + { + if app.current_displayed_thread_id() != Some(focused) { + app.select_agent_thread(&mut tui, &mut server, focused) + .await?; + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + } + assert_eq!(app.current_displayed_thread_id(), Some(focused)); + enable_side_retention(&mut app)?; + app.local_settings.tui.animations = false; + let size = Size::new(width, height); + // Resize refreshes the active native status, so install the fixture after reflow. + side_frame(&mut app, &mut tui, size)?; + let (name, draft, status) = if focused == main { + ("Main", "main draft", main_status) + } else { + ("Side", "side draft", side_status) + }; + app.chat_widget.setup_status_line( + vec![StatusLineItem::ModelName], + /*use_theme_colors*/ false, + ); + app.chat_widget + .set_status_line(Some(Line::from(status).into())); + app.chat_widget.apply_external_edit(draft.into()); + if index == 0 { + // Seed the main conversation before focusing the side chat. + continue; + } + + let frame = side_frame(&mut app, &mut tui, size)?; + assert!( + frame.contains("main draft") && frame.contains("side draft"), + "{frame}" + ); + let divider = app.conversation_frame.divider_area().expect("split pane"); + let buffer = tui.terminal.previous_buffer(); + let mut rows = vec![format!("{name} focused at {width}x{height}")]; + for (prefix, start, end) in [ + ("MainStatus", 0, divider.x), + ("SideStatus", divider.right(), width), + ] { + for y in 0..height { + let text: String = (start..end).map(|x| buffer[(x, y)].symbol()).collect(); + if text.trim_start().starts_with(prefix) { + let muted = buffer[(start + 2, y)].modifier.contains(Modifier::DIM); + let style = if muted { "muted" } else { "normal" }; + rows.push(format!("{style} {}", text.trim())); + } + } + } + frames.push(rows.join("\n")); + } + insta::assert_snapshot!(frames.join("\n\n"), @" + Side focused at 100x24 + muted MainStatusSummaryForThePrimaryWorkspaceContext + muted MainStatusDetailsForThePrimaryWorkspaceContext + normal SideStatusSummaryText + normal SideStatusDetails + + Main focused at 100x24 + normal MainStatusSummaryForThePrimaryWorkspaceContext + normal MainStatusDetailsForThePrimaryWorkspaceContext + muted SideStatusSummaryText + muted SideStatusDetails + + Main focused at 130x24 + normal MainStatusSummaryForThePrimaryWorkspaceContext + normal MainStatusDetailsForThePrimaryWorkspaceContext + muted SideStatusSummaryText SideStatusDetails + "); + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn side_close_key_cannot_accept_a_replacing_approval() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 2c01a4fab9..c5a017d7f2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -317,7 +317,7 @@ pub(crate) struct CapturedSideDisplay { pub(crate) active: Option>, pub(crate) auxiliary: Vec, pub(crate) draft: Box, - pub(crate) status: Option, + pub(crate) status_line: Option, pub(crate) work: Option, pub(crate) last_completed_agent_message: Option<(String, String)>, pub(crate) cwd: AbsolutePathBuf, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index db3b79fb62..61d93baa7e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -380,6 +380,7 @@ use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; use super::voice_strip::VoiceStrip; use crate::bottom_pane::footer_content::FooterContent; +use crate::bottom_pane::footer_content::StatusLineFocus; use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; @@ -401,7 +402,7 @@ use codex_protocol::user_input::TextElement; mod agents_navigation; mod attachment_state; mod completion_target; -mod composer_layout; +pub(super) mod composer_layout; mod draft_state; mod footer_state; mod history_search; @@ -413,7 +414,6 @@ mod paste_input; mod popup_state; mod reconnect; pub(crate) use reconnect::RestrictedInputMode; -mod native_status; mod slash_input; mod sparkle; mod status_surface; @@ -795,8 +795,7 @@ impl ChatComposer { goal_status_indicator: None, ide_context_active: false, status_line_value: None, - measured_status_line: Default::default(), - wrapped_native_status: Default::default(), + status_layout: Default::default(), status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, @@ -909,7 +908,7 @@ impl ChatComposer { let style = IgnitionStyle::random(self.effort_animation_style); self.effort_ignition = Some(EffortIgnition::new(tier, style)); self.effort_animation_style = Some(style); - if let Some(previous) = self.status_surface_content() + if let Some(previous) = self.status_surface_content(StatusLineFocus::Focused) && !matches!(previous.as_ref(), FooterContent::Extension { .. }) { self.effort_status_line_transition = Some(EffortStatusLineTransition::new( 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 848a15c02e..1fe742e0cd 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 @@ -8,16 +8,67 @@ use crate::tui::FrameRequester; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -pub(super) const MAX_FOOTER_ROWS: u16 = 4; - struct ComposerRows { input: u16, above_input: u16, thumbnails: u16, + footer: FooterRows, +} + +/// Allocates status and hint rows from the space left by the composer. +pub(crate) struct FooterLayout { + cap: u16, + wanted_hints: u16, + reserved_hints: u16, +} + +pub(crate) struct FooterRows { status: u16, hints: u16, } +impl FooterLayout { + pub(crate) fn new( + workspace_height: Option, + wanted_hints: u16, + priority_hint_rows: u16, + ) -> Self { + Self { + cap: workspace_height.map_or(2, |height| (height.saturating_add(2) / 4).clamp(2, 4)), + wanted_hints, + reserved_hints: wanted_hints.min(priority_hint_rows), + } + } + + pub(crate) fn status_budget(&self, available: u16) -> u16 { + self.cap.min(available).saturating_sub(self.reserved_hints) + } + + pub(crate) fn allocate(&self, available: u16, wanted_status: u16) -> FooterRows { + let limit = self.cap.min(available); + let reserved_status = wanted_status.min(1).min(self.status_budget(available)); + let hints = self.wanted_hints.min(limit - reserved_status); + FooterRows { + status: wanted_status.min(limit - hints), + hints, + } + } +} + +impl FooterRows { + pub(crate) fn height(&self) -> u16 { + self.status + self.hints + } + + pub(crate) fn status_area(&self, area: Rect) -> Rect { + Rect { + y: area.bottom().saturating_sub(self.height()).max(area.y), + height: self.status, + ..area + } + } +} + impl ChatComposer { pub(crate) fn set_voice_strip( &mut self, @@ -54,21 +105,13 @@ impl ChatComposer { let ComposerRows { above_input, thumbnails: thumbnail_height, - status: status_height, - hints: footer_hint_height, + footer, .. } = self.composer_rows(area.width, area.height, options); - let status = Rect { - y: area - .bottom() - .saturating_sub(footer_hint_height) - .saturating_sub(status_height) - .max(area.y), - height: status_height, - ..area - }; + let status = footer.status_area(area); + let footer_hint_height = footer.hints; let area = Rect { - height: area.height - status_height, + height: area.height - status.height, ..area }; let voice_rows = self @@ -186,8 +229,7 @@ impl ChatComposer { rows.input, rows.above_input, rows.thumbnails, - rows.status, - rows.hints, + rows.footer.height(), ] .into_iter() .fold(0, u16::saturating_add) @@ -241,25 +283,22 @@ impl ChatComposer { let mut available = height .saturating_sub(input_height) .max(u16::from(editing_hint && height > 3)); - let cap = if options.separate_status_line { - // Use the frame's budget so measurement and painting choose the same cap. - let budget = options.max_height.unwrap_or(u16::MAX); - (budget.saturating_add(2) / 4).clamp(2, MAX_FOOTER_ROWS) - } else { - 2 - }; - let wanted_hints = self.footer_hint_height(width, options); + // Use the frame's budget so measurement and painting choose the same cap. + let workspace_height = options + .separate_status_line + .then_some(options.max_height.unwrap_or(u16::MAX)); // Find needs its query and result before the status line can use a row. let find_active = options .footer .is_some_and(|footer| footer.cursor_column.is_some()); - let reserved_hints = wanted_hints.min(if find_active { 2 } else { 1 }); - let wanted_status = self.status_surface_height( - width, - available.min(cap).saturating_sub(reserved_hints), - options, + let footer = FooterLayout::new( + workspace_height, + self.footer_hint_height(width, options), + /*priority_hint_rows*/ if find_active { 2 } else { 1 }, ); - let basic = (reserved_hints + wanted_status.min(1)).min(2); + let wanted_status = + self.status_surface_height(width, footer.status_budget(available), options); + let basic = (footer.reserved_hints + wanted_status.min(1)).min(2); let above_input = above_input.min(available.saturating_sub(basic)); available -= above_input; let mut thumbnails = options.image_previews.map_or(0, |previews| { @@ -270,17 +309,11 @@ impl ChatComposer { } else { thumbnails = 0; } - let limit = cap.min(available); - let reserved_status = wanted_status - .min(1) - .min(limit.saturating_sub(reserved_hints)); - let hints = wanted_hints.min(limit - reserved_status); ComposerRows { input: input_height, above_input, thumbnails, - status: wanted_status.min(limit - hints), - hints, + footer: footer.allocate(available, wanted_status), } } } 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 92124a0fbc..c7a70df106 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 @@ -384,10 +384,7 @@ pub(super) struct FooterState { pub(super) goal_status_indicator: Option, pub(super) ide_context_active: bool, pub(super) status_line_value: Option, - // Measurement bounds and occupied height, shared by layout and cursor placement. - pub(super) measured_status_line: std::cell::Cell>, - pub(super) wrapped_native_status: - std::cell::RefCell>, + pub(super) status_layout: crate::bottom_pane::status_line::StatusLine, 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/native_status.rs b/codex-rs/tui/src/bottom_pane/chat_composer/native_status.rs deleted file mode 100644 index 80cb11f6fd..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/native_status.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Wrap the built-in status line using its painted grapheme widths and native hyperlink ranges. -//! Measurement and paint share the visible rows at the current width. - -use super::ChatComposer; -use super::FooterContent; -use super::composer_layout::MAX_FOOTER_ROWS; -use crate::render::line_utils::line_to_static; -use crate::terminal_hyperlinks::HyperlinkLine; -use crate::terminal_hyperlinks::TerminalHyperlink; -use crate::terminal_hyperlinks::project_hyperlink_columns; -use crate::width::display_width; -use crate::wrapping::adaptive_wrap_line_to_width; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::text::Line; -use ratatui::text::Span; -use std::cell::Ref; -use unicode_segmentation::UnicodeSegmentation; - -pub(super) struct WrappedNativeStatus { - width: u16, - suffix: Vec>, - rows: Vec, -} - -impl WrappedNativeStatus { - pub(super) fn height(&self) -> u16 { - self.rows.len().max(1) as u16 - } - - pub(super) fn render(&self, area: Rect, buf: &mut Buffer) { - let height = usize::from(area.height); - for (index, line) in self.rows.iter().take(height).enumerate() { - let row = Rect::new(area.x, area.y + index as u16, area.width, 1); - if (index + 1 == height && self.rows.len() > height) - || line.width() > usize::from(area.width) - { - line.clone().render_with_ellipsis(row, buf); - } else { - line.render_fitted(row, buf); - } - } - } -} - -impl ChatComposer { - pub(super) fn wrapped_native_status( - &self, - content: &FooterContent, - width: u16, - ) -> Option> { - let (line, links) = content.native_parts()?; - if width == 0 { - return None; - } - // The setter invalidates configured spans. The styled agent suffix can change independently. - let configured = self - .footer - .status_line_value - .as_ref() - .and_then(FooterContent::native_parts) - .map_or(0, |(line, _)| line.spans.len()); - let suffix = &line.spans[configured..]; - let cache = &self.footer.wrapped_native_status; - if cache - .borrow() - .as_ref() - .is_none_or(|entry| entry.width != width || entry.suffix != suffix) - { - if content.width() <= width { - return None; - } - cache.replace(Some(WrappedNativeStatus { - width, - suffix: suffix.to_vec(), - rows: wrap_rows(line, links, width), - })); - } - Ref::filter_map(cache.borrow(), Option::as_ref).ok() - } -} - -fn wrap_rows(line: &Line<'_>, links: &[TerminalHyperlink], width: u16) -> Vec { - // One row of lookahead settles the last visible break. Whitespace discarded by - // wrapping cannot count toward that budget or it could hide a later word. - let limit = usize::from(MAX_FOOTER_ROWS) + 1; - let mut remaining = usize::from(width) * limit; - let mut prefix = Line::default().style(line.style); - for span in &line.spans { - let mut end = 0; - for grapheme in span.content.graphemes(true) { - end += grapheme.len(); - if !grapheme.chars().all(char::is_whitespace) { - remaining = - remaining.saturating_sub(display_width(grapheme).min(usize::from(width))); - if remaining == 0 { - break; - } - } - } - prefix.push_span(Span::styled(&span.content[..end], span.style)); - if remaining == 0 { - break; - } - } - // Native link columns are counted per grapheme within each painted span. - let mut graphemes = prefix - .spans - .iter() - .flat_map(|span| span.content.graphemes(true)); - let (mut byte, mut column) = (0, 0); - let mut column_at = |target| { - while byte < target - && let Some(grapheme) = graphemes.next() - { - byte += grapheme.len(); - column += display_width(grapheme); - } - column - }; - adaptive_wrap_line_to_width(&prefix, usize::from(width).into()) - .into_iter() - .take(limit) - .map(|row| { - let source = column_at(row.range.start)..column_at(row.range.end); - HyperlinkLine { - line: line_to_static(&row.line), - hyperlinks: project_hyperlink_columns(links, source, /*prefix*/ 0), - source: None, - } - }) - .collect() -} 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 f7baf4a125..4b85ae30b4 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,7 @@ //! Shortcut help grows above the input, preserving status and close rows below it even when clipped. use super::*; +use crate::bottom_pane::footer_content::StatusLineFocus; use std::borrow::Cow; @@ -14,8 +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.wrapped_native_status.take(); + self.footer.status_layout = Default::default(); self.footer.status_line_value = status_line; true } @@ -42,40 +42,18 @@ impl ChatComposer { available: u16, options: ComposerRenderOptions<'_>, ) -> u16 { - // The unfocused composer paints only the raw status, as one dim line. - let (available, status) = if options.unfocused { - let raw = self - .status_line_content() - .filter(|_| self.footer.status_line_enabled); - (available.min(1), raw.map(Cow::Borrowed)) - } else { - (available, self.status_surface_content()) - }; - let bounds = inset_footer_hint_area(Rect::new(0, 0, width, available)).as_size(); - if bounds.width == 0 || bounds.height == 0 { - return 0; - } - match status.as_deref() { - Some(FooterContent::Extension { content }) => { - let cache = &self.footer.measured_status_line; - if let Some((previous, height)) = cache.get() - && previous == bounds - { - return height; - } - let height = content.measure(bounds).height; - cache.set(Some((bounds, height))); - height - } - Some(native) if bounds.height > 1 && self.active_status_transition().is_none() => self - .wrapped_native_status(native, bounds.width) - .map_or_else( - || u16::from(native.width() > 0), - |rows| rows.height().min(bounds.height), - ), - Some(line) => u16::from(line.width() > 0), - None => 0, - } + let focus = options.status_line_focus(); + let available = + if focus == StatusLineFocus::Focused && self.active_status_transition().is_some() { + available.min(1) + } else { + available + }; + self.footer.status_layout.height( + self.status_surface_content(focus).as_deref(), + width, + available, + ) } pub(super) fn hint_footer_props(&self) -> FooterProps { @@ -88,13 +66,17 @@ impl ChatComposer { props } - pub(super) fn status_surface_content(&self) -> Option> { + pub(super) fn status_surface_content( + &self, + focus: StatusLineFocus, + ) -> Option> { if !self.footer.status_line_enabled { return None; } let status = self.footer.status_line_value.as_ref(); if matches!(status, Some(FooterContent::Extension { .. })) || self.footer.active_agent_label.is_none() + || focus == StatusLineFocus::Unfocused { return status.map(Cow::Borrowed); } @@ -120,36 +102,33 @@ impl ChatComposer { if area.is_empty() { return; } - if options.unfocused { - if let Some(content) = self.status_line_content() { - crate::bottom_pane::footer::render_unfocused_status( - content.clone().into_hyperlink_line().line.to_string(), - area, - buf, - ); - } - return; - } - let area = inset_footer_hint_area(area); - let line = self.status_surface_content(); - if let Some(transition) = self.active_status_transition() { - if let Some(frame) = transition.render_line( - line.map(|line| line.into_owned().into_hyperlink_line()), - area.width, - ) { - FooterContent::HyperlinkLine(frame).render(area, buf); - } + let focus = options.status_line_focus(); + let mut content = self.status_surface_content(focus); + if focus == StatusLineFocus::Focused + && let Some(transition) = self.active_status_transition() + { + content = transition + .render_line( + content.map(|line| line.into_owned().into_hyperlink_line()), + inset_footer_hint_area(area).width, + ) + .map(|frame| Cow::Owned(frame.into())); if let Some(requester) = &self.frame_requester { requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK); } - } else if let Some(line) = line { - if area.height > 1 - && let Some(wrapped) = self.wrapped_native_status(&line, area.width) - { - wrapped.render(area, buf); - } else { - line.into_owned().render(area, buf); - } + } + self.footer + .status_layout + .render(content.as_deref(), area, buf, focus); + } +} + +impl ComposerRenderOptions<'_> { + fn status_line_focus(self) -> StatusLineFocus { + if self.unfocused { + StatusLineFocus::Unfocused + } else { + StatusLineFocus::Focused } } } 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 3350903ac8..06d2d358bb 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 @@ -266,7 +266,7 @@ fn status_rows_respect_host_budget_and_yield_to_the_draft() { } #[test] -fn sidebar_focus_collapses_extension_status_to_its_dim_line() { +fn sidebar_focus_keeps_dim_extension_status_rows() { let mut composer = composer(); composer.insert_str("draft"); let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour""#.parse().unwrap(); @@ -283,6 +283,15 @@ fn sidebar_focus_collapses_extension_status_to_its_dim_line() { if unfocused { assert_eq!(composer.footer.warning_notice_area.get(), None); assert!(!buffer.content.iter().any(|cell| cell.symbol() == "⚠")); + let status = + inset_footer_hint_area(composer.layout_with_options(buffer.area, options).status); + for y in status.y..status.bottom() { + assert!( + buffer[(status.x, y)] + .modifier + .contains(ratatui::style::Modifier::DIM) + ); + } } rows.join("\n") }); @@ -292,7 +301,9 @@ fn sidebar_focus_collapses_extension_status_to_its_dim_line() { three four --- - one… + one + two + three… --- one two diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index e0fa990d86..78b99f3a15 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -712,15 +712,6 @@ pub(crate) fn render_context_right(area: Rect, buf: &mut Buffer, line: &Line<'st } } -pub(crate) fn render_unfocused_status(text: String, area: Rect, buf: &mut Buffer) { - let area = inset_footer_hint_area(area); - crate::line_truncation::truncate_line_with_ellipsis_if_overflow( - text.dim().into(), - usize::from(area.width), - ) - .render(area, buf); -} - pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { if area.width > 2 { area.x += 2; @@ -814,8 +805,7 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option HyperlinkLine::from(line.clone()), - Some(FooterContent::HyperlinkLine(line)) => line.clone(), + Some(FooterContent::HyperlinkLine(line)) => line.as_ref().clone(), Some(content @ FooterContent::Extension { .. }) => return Some(content.clone()), None => HyperlinkLine::default(), }; @@ -828,7 +818,7 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option Line<'static> { diff --git a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs index a3db27dab2..c4cdf30534 100644 --- a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs @@ -1,22 +1,28 @@ //! Native and extension status content measured and painted inside the status rectangle. -use crate::line_truncation::line_width; +use crate::line_truncation::truncate_line_with_ellipsis; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::text::Line; +use std::borrow::Cow; use std::sync::Arc; use crate::render::highlight::with_syntax_theme; use crate::terminal_hyperlinks::HyperlinkLine; -use crate::terminal_hyperlinks::TerminalHyperlink; use crate::ui_tree::PreparedUiTree; use crate::ui_tree::UiTree; +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum StatusLineFocus { + Focused, + Unfocused, +} + /// Owned status content displayed below the composer. #[derive(Clone, Debug, PartialEq)] pub(crate) enum FooterContent { - Line(Line<'static>), - HyperlinkLine(HyperlinkLine), + HyperlinkLine(Box), Extension { content: Arc }, } @@ -29,50 +35,48 @@ impl From for FooterContent { } impl FooterContent { - pub(crate) fn native_parts(&self) -> Option<(&Line<'static>, &[TerminalHyperlink])> { - match self { - Self::Line(line) => Some((line, &[])), - Self::HyperlinkLine(line) => Some((&line.line, &line.hyperlinks)), - Self::Extension { .. } => None, - } - } - /// Natural display width in terminal cells. pub(crate) fn width(&self) -> u16 { let width = match self { - Self::Line(line) => line_width(line), Self::HyperlinkLine(line) => line.width(), Self::Extension { content } => return content.measure_natural().width, }; u16::try_from(width).unwrap_or(u16::MAX) } - /// Paint only inside the final area supplied by the parent. - pub(crate) fn render(self, area: Rect, buf: &mut Buffer) { + /// Paint a focused preview in its allotted rectangle. + pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_with_focus(area, buf, StatusLineFocus::Focused); + } + + pub(in crate::bottom_pane) fn render_with_focus( + &self, + area: Rect, + buf: &mut Buffer, + focus: StatusLineFocus, + ) { match self { Self::Extension { content } => { - with_syntax_theme(|theme| content.render(area, theme, buf)); + let plain_style = + (focus == StatusLineFocus::Unfocused).then(|| Style::reset().dim()); + with_syntax_theme(|theme| content.render(area, theme, buf, plain_style)); } - native => { - let line = native.into_hyperlink_line(); - let area = Rect { + Self::HyperlinkLine(line) => render_status_row( + line, + Rect { height: area.height.min(1), ..area - }; - let width = usize::from(area.width); - if line.width() > width { - line.render_with_ellipsis(area, buf); - } else { - line.render_fitted(area, buf); - } - } + }, + buf, + focus, + /*more_rows*/ false, + ), } } pub(crate) fn into_hyperlink_line(self) -> HyperlinkLine { match self { - Self::Line(line) => line.into(), - Self::HyperlinkLine(line) => line, + Self::HyperlinkLine(line) => *line, Self::Extension { content } => { with_syntax_theme(|theme| content.render_line(u16::MAX, theme)) } @@ -80,21 +84,49 @@ impl FooterContent { } } +pub(in crate::bottom_pane) fn render_status_row( + line: &HyperlinkLine, + area: Rect, + buf: &mut Buffer, + focus: StatusLineFocus, + more_rows: bool, +) { + if area.is_empty() { + return; + } + let mut line = Cow::Borrowed(line); + let width = usize::from(area.width); + if more_rows || line.width() > width { + let line = line.to_mut(); + line.line = truncate_line_with_ellipsis(std::mem::take(&mut line.line), width); + line.clip_hyperlinks_to_width(line.width().saturating_sub(1)); + } + if focus == StatusLineFocus::Unfocused { + let line = line.to_mut(); + line.hyperlinks.clear(); + line.line.style = Style::reset().dim(); + for span in &mut line.line.spans { + span.style = Style::default(); + } + } + line.render_fitted(area, buf); +} + impl From for FooterContent { fn from(line: HyperlinkLine) -> Self { - Self::HyperlinkLine(line) + Self::HyperlinkLine(Box::new(line)) } } impl From> for FooterContent { fn from(line: Line<'static>) -> Self { - Self::Line(line) + Self::from(HyperlinkLine::from(line)) } } impl From<&'static str> for FooterContent { fn from(text: &'static str) -> Self { - Self::Line(Line::from(text)) + Self::from(Line::from(text)) } } diff --git a/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs b/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs index 9ca45d4006..246465dd5a 100644 --- a/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs @@ -20,7 +20,7 @@ fn clipped_links_keep_their_cells_after_a_halfwidth_katakana_grapheme() { /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 1, ); let mut buf = Buffer::empty(area); - FooterContent::HyperlinkLine(line).render(area, &mut buf); + FooterContent::from(line).render(area, &mut buf); assert_eq!(buf[(0, 0)].symbol(), osc8_hyperlink(project_url, "ガ")); for (x, ch) in (2..5).zip(" · ".chars()) { assert_eq!(buf[(x, 0)].symbol(), ch.to_string()); @@ -40,11 +40,12 @@ fn arabic_prefix_keeps_link_on_the_painted_cells_at_exact_fit_and_overflow() { let mut line = HyperlinkLine::from("لا · "); line.push_span("PR #42".into(), Some(url)); assert_eq!(line.width(), 11); + let content = FooterContent::from(line); for (width, visible_link) in [(11, "PR #42"), (10, "PR #")] { let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); let mut buf = Buffer::empty(area); - FooterContent::HyperlinkLine(line.clone()).render(area, &mut buf); + content.render(area, &mut buf); assert_eq!(buf[(4, 0)].symbol(), " "); for (x, ch) in (5..width).zip(visible_link.chars()) { assert_eq!(buf[(x, 0)].symbol(), osc8_hyperlink(url, &ch.to_string())); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a165ca11b9..226dbee602 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -23,10 +23,11 @@ pub(crate) use chat_composer::CommandPopupPlacement; pub(crate) use chat_composer::ComposerRenderOptions; pub(crate) use chat_composer::TranscriptFooter; +pub(crate) use chat_composer::composer_layout::FooterLayout; pub(crate) use composer_gap::ComposerGap; pub(crate) use footer::footer_hint_items_line; pub(crate) use footer::inset_footer_hint_area; -pub(crate) use footer::render_unfocused_status; +pub(crate) use status_line::StatusLine; use std::collections::VecDeque; use std::path::PathBuf; @@ -93,6 +94,7 @@ pub(crate) use multi_select_picker::MultiSelectPicker; mod question_tests; mod questions; mod request_user_input; +mod status_line; mod status_line_setup; mod status_line_style; mod status_surface_preview; diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index 738f3913b2..3608d060ae 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -621,7 +621,7 @@ impl Renderable for MultiSelectPicker { Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)]) .areas(footer_area); let preview_area = preview_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2)); - preview_line.clone().render(preview_area, buf); + preview_line.render(preview_area, buf); hint_area } else { footer_area @@ -711,9 +711,7 @@ impl MultiSelectPickerBuilder { where F: Fn(&[MultiSelectItem]) -> Option> + Send + Sync + 'static, { - self.on_preview_content(move |items| { - callback(items).map(|line| FooterContent::HyperlinkLine(line.into())) - }) + self.on_preview_content(move |items| callback(items).map(FooterContent::from)) } pub(crate) fn on_preview_content( diff --git a/codex-rs/tui/src/bottom_pane/status_line.rs b/codex-rs/tui/src/bottom_pane/status_line.rs new file mode 100644 index 0000000000..07b931173c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_line.rs @@ -0,0 +1,187 @@ +//! Measure and paint status lines for the main conversation and side chats. + +use super::footer::inset_footer_hint_area; +use super::footer_content::FooterContent; +use super::footer_content::StatusLineFocus; +use super::footer_content::render_status_row; +use crate::render::line_utils::line_to_static; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::project_hyperlink_columns; +use crate::width::display_width; +use crate::wrapping::adaptive_wrap_line_to_width; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::layout::Size; +use ratatui::text::Line; +use ratatui::text::Span; +use std::cell::Cell; +use std::cell::Ref; +use std::cell::RefCell; +use unicode_segmentation::UnicodeSegmentation; + +/// The owning composer resets this when its configured status changes. +#[derive(Default)] +pub(crate) struct StatusLine { + measured_extension: Cell>, + wrapped_native: RefCell>, +} + +struct WrappedNativeStatus { + bounds: Size, + content: HyperlinkLine, + rows: Vec, +} + +impl StatusLine { + pub(crate) fn height( + &self, + content: Option<&FooterContent>, + width: u16, + available: u16, + ) -> u16 { + let bounds = inset_footer_hint_area(Rect::new(0, 0, width, available)).as_size(); + if bounds.width == 0 || bounds.height == 0 { + return 0; + } + match content { + Some(FooterContent::Extension { content }) => { + if let Some((previous, height)) = self.measured_extension.get() + && previous == bounds + { + return height; + } + let height = content.measure(bounds).height; + self.measured_extension.set(Some((bounds, height))); + height + } + Some(native) => self.wrapped_rows(native, bounds).map_or_else( + || u16::from(native.width() > 0), + |rows| rows.len().max(1).min(usize::from(bounds.height)) as u16, + ), + None => 0, + } + } + + pub(crate) fn render( + &self, + content: Option<&FooterContent>, + area: Rect, + buf: &mut Buffer, + focus: StatusLineFocus, + ) { + let area = inset_footer_hint_area(area); + let Some(content) = content.filter(|_| !area.is_empty()) else { + return; + }; + if let Some(rows) = self.wrapped_rows(content, area.as_size()) { + let height = usize::from(area.height); + for (index, line) in rows.iter().take(height).enumerate() { + let row = Rect::new(area.x, area.y + index as u16, area.width, 1); + render_status_row( + line, + row, + buf, + focus, + index + 1 == height && rows.len() > height, + ); + } + } else { + content.render_with_focus(area, buf, focus); + } + } + + fn wrapped_rows( + &self, + content: &FooterContent, + bounds: Size, + ) -> Option> { + let FooterContent::HyperlinkLine(line) = content else { + return None; + }; + if bounds.height <= 1 { + return None; + } + // Reuse the same wrapped prefix across small height changes. + let bounds = Size { + height: bounds.height.max(4), + ..bounds + }; + let line = line.as_ref(); + let cache = &self.wrapped_native; + if cache + .borrow() + .as_ref() + .is_none_or(|entry| entry.bounds != bounds || &entry.content != line) + { + if line.width() <= usize::from(bounds.width) { + return None; + } + cache.replace(Some(WrappedNativeStatus { + bounds, + content: line.clone(), + rows: wrap_rows(line, bounds), + })); + } + Ref::filter_map(cache.borrow(), |entry| { + entry.as_ref().map(|entry| entry.rows.as_slice()) + }) + .ok() + } +} + +fn wrap_rows(status: &HyperlinkLine, bounds: Size) -> Vec { + let line = &status.line; + let width = usize::from(bounds.width); + // One row of lookahead settles the last visible break. Whitespace discarded by + // wrapping cannot count toward that budget or it could hide a later word. + let limit = usize::from(bounds.height) + 1; + let mut remaining = width * limit; + let mut prefix = Line::default().style(line.style); + for span in &line.spans { + let mut end = 0; + for grapheme in span.content.graphemes(true) { + end += grapheme.len(); + if !grapheme.chars().all(char::is_whitespace) { + remaining = remaining.saturating_sub(display_width(grapheme).min(width)); + if remaining == 0 { + break; + } + } + } + prefix.push_span(Span::styled(&span.content[..end], span.style)); + if remaining == 0 { + break; + } + } + // Native link columns are counted per grapheme within each painted span. + let mut graphemes = prefix + .spans + .iter() + .flat_map(|span| span.content.graphemes(true)); + let (mut byte, mut column) = (0, 0); + let mut column_at = |target| { + while byte < target + && let Some(grapheme) = graphemes.next() + { + byte += grapheme.len(); + column += display_width(grapheme); + } + column + }; + adaptive_wrap_line_to_width(&prefix, width.into()) + .into_iter() + .take(limit) + .map(|row| { + let source = column_at(row.range.start)..column_at(row.range.end); + HyperlinkLine { + line: line_to_static(&row.line), + hyperlinks: project_hyperlink_columns( + &status.hyperlinks, + source, + /*prefix*/ 0, + ), + source: None, + } + }) + .collect() +} diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 3018d12749..f559bbadeb 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -263,7 +263,7 @@ impl StatusSurfacePreviewData { self.live_value_for(StatusSurfacePreviewItem::PullRequestNumber) .and(self.pull_request_url.as_deref()), ) - .map(FooterContent::HyperlinkLine) + .map(FooterContent::from) } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1863569006..e2243343b9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2115,6 +2115,7 @@ impl ChatWidget { self.bottom_pane.new_status_indicator() } + #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { self.status_line_content() .cloned() diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 6f784435a4..1934c3d750 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -269,7 +269,7 @@ impl ChatWidget { draft.set_text_with_elements(&snapshot.text, &snapshot.text_elements); draft }, - status: self.status_line_text(), + status_line: self.status_line_content().cloned(), work: self .last_turn_id() .filter(|_| self.is_agent_turn_running()) diff --git a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs index 30b81fd636..748a3a192c 100644 --- a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs +++ b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs @@ -113,17 +113,6 @@ impl HyperlinkLine { self.mark_cells(area, buf); } - /// Reserve the last visible grapheme for an unlinked overflow marker. - pub(crate) fn render_with_ellipsis(mut self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - self.line = - crate::line_truncation::truncate_line_with_ellipsis(self.line, usize::from(area.width)); - self.clip_hyperlinks_to_width(self.width().saturating_sub(1)); - self.render_fitted(area, buf); - } - fn mark_cells(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index 939b251375..7502882293 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -21,6 +21,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::layout::Size; use ratatui::style::Modifier; +use ratatui::style::Style; use ratatui::text::Span; use std::ops::Range; use syntect::highlighting::Theme; @@ -52,7 +53,14 @@ impl PreparedUiTree { layout(self.0.as_ref(), bounds, /*draw*/ None) } - pub(crate) fn render(&self, area: Rect, theme: &Theme, buf: &mut Buffer) { + /// A plain style paints the same layout with theme styling and hyperlinks suppressed. + pub(crate) fn render( + &self, + area: Rect, + theme: &Theme, + buf: &mut Buffer, + plain_style: Option