From 797bab474649e6df315f4dc3fec4d1486029fdde Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Thu, 29 Oct 2026 02:57:10 +0000 Subject: [PATCH] Add an experimental UI tree renderer for the status line (#57970) ## What changed - Add the disabled-by-default `tui_extensions_statusline` feature to render configured status items and their picker preview through a shared UI tree renderer. - Support text, HTTP(S) links, rows, spacers, dividers, and gaps with rendering budgets, text sanitization, and width-aware truncation that keeps the ellipsis outside hyperlinks. - Use plain text for the extension status line and hide the picker's theme color toggle while preserving the saved color preference. ## Testing Add coverage for tree serialization and URL validation, Unicode widths, hyperlink clipping, picker previews, color preference preservation, and token usage updates across renderer switches. GitOrigin-RevId: a6b87c2b1096765d61296d73e142834fcd53ceee --- codex-rs/core/config.schema.json | 6 + codex-rs/features/src/lib.rs | 8 ++ codex-rs/tui/src/app/tests.rs | 42 +++++-- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 +- .../bottom_pane/chat_composer/footer_state.rs | 10 -- .../chat_composer/status_surface_tests.rs | 48 +++++++ codex-rs/tui/src/bottom_pane/footer.rs | 11 +- .../src/bottom_pane/footer/footer_content.rs | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 6 +- .../src/bottom_pane/multi_select_picker.rs | 29 ++--- .../src/bottom_pane/status_line_content.rs | 64 ++++++++++ .../tui/src/bottom_pane/status_line_setup.rs | 86 +++++++++---- .../src/bottom_pane/status_surface_preview.rs | 28 +++-- codex-rs/tui/src/chatwidget.rs | 11 +- .../tui/src/chatwidget/status_controls.rs | 8 +- .../tui/src/chatwidget/status_surfaces.rs | 21 +++- .../tests/status_surface_previews.rs | 8 +- codex-rs/tui/src/lib.rs | 1 + ...__links_only_annotate_their_own_cells.snap | 16 +++ codex-rs/tui/src/ui_tree.rs | 110 ++++++++++++++++ codex-rs/tui/src/ui_tree/render.rs | 116 +++++++++++++++++ codex-rs/tui/src/ui_tree_tests.rs | 118 ++++++++++++++++++ 22 files changed, 670 insertions(+), 86 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/status_line_content.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__links_only_annotate_their_own_cells.snap create mode 100644 codex-rs/tui/src/ui_tree.rs create mode 100644 codex-rs/tui/src/ui_tree/render.rs create mode 100644 codex-rs/tui/src/ui_tree_tests.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 62b87d2e08..6596cd18e9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1395,6 +1395,9 @@ "tui_app_server": { "type": "boolean" }, + "tui_extensions_statusline": { + "type": "boolean" + }, "tui_multi_backend": { "type": "boolean" }, @@ -8050,6 +8053,9 @@ "tui_app_server": { "type": "boolean" }, + "tui_extensions_statusline": { + "type": "boolean" + }, "tui_multi_backend": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f76b7118fa..56a263d58e 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -123,6 +123,8 @@ pub enum Feature { DaemonAutoStart, /// Allow the TUI to discover and select among multiple conversation backends. TuiMultiBackend, + /// Render the configured status line through the experimental TUI Extensions renderer. + TuiExtensionsStatusLine, /// Enable negotiated zstd compression for app-server WebSocket and UDS connections. AppServerWebSocketCompression, @@ -1098,6 +1100,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::TuiExtensionsStatusLine, + key: "tui_extensions_statusline", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::AppServerWebSocketCompression, key: "app_server_websocket_compression", diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a2d219157e..b459395737 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2174,12 +2174,17 @@ async fn replayed_interrupted_turn_restores_queued_input_to_composer() { #[tokio::test] async fn token_usage_update_refreshes_status_line_with_runtime_context_window() { + use crate::bottom_pane::StatusLineItem; + use crate::bottom_pane::footer_content::FooterContent; + let mut app = make_test_app().await; app.chat_widget.setup_status_line( - vec![crate::bottom_pane::StatusLineItem::ContextWindowSize], + vec![ + StatusLineItem::ContextWindowSize, + StatusLineItem::UsedTokens, + ], /*use_theme_colors*/ true, ); - assert_eq!( app.chat_widget.config_panel_tui.status_line, app.chat_widget.local_settings.tui.status_line @@ -2187,14 +2192,31 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window() assert!(app.chat_widget.config_panel_tui.status_line_use_colors); assert_eq!(app.chat_widget.status_line_text(), None); - app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( - token_usage_notification(ThreadId::new(), "turn-1", Some(950_000)), - ))); - - assert_eq!( - app.chat_widget.status_line_text(), - Some("950K window".into()) - ); + let thread_id = ThreadId::new(); + for (extensions, window, expected) in [ + (false, 950_000, "950K window · 8 used"), + (true, 800_000, "800K window · 8 used"), + (false, 900_000, "900K window · 8 used"), + ] { + let before = render_bottom_popup(&app.chat_widget, /*width*/ 60); + app.chat_widget + .set_feature_enabled(Feature::TuiExtensionsStatusLine, extensions); + app.refresh_status_line(); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 60), before); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + token_usage_notification(thread_id, "turn-1", Some(window)), + ))); + assert!(matches!( + (extensions, app.chat_widget.status_line_content()), + (false, Some(FooterContent::HyperlinkLine(_))) + | (true, Some(FooterContent::Extension { .. })) + )); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 60); + assert!( + rendered.lines().any(|row| row.trim() == expected), + "{rendered}" + ); + } } #[tokio::test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c1df1987b4..e34e57ad9b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1843,8 +1843,8 @@ impl ChatComposer { } #[cfg(test)] - pub(crate) fn status_line_text(&self) -> Option { - self.footer.status_line_text() + pub(crate) fn status_line_content(&self) -> Option<&FooterContent> { + self.footer.status_line_value.as_ref() } pub(crate) fn local_images(&self) -> Vec { 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 2cd33dc94d..c0bee40bdf 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 @@ -398,16 +398,6 @@ impl FooterState { .unwrap_or_else(Instant::now); self.flash = Some(FooterFlash { line, expires_at }); } - - #[cfg(test)] - pub(super) fn status_line_text(&self) -> Option { - self.status_line_value - .as_ref() - .map(|content| match content { - FooterContent::Line(line) => line.to_string(), - FooterContent::HyperlinkLine(line) => line.line.to_string(), - }) - } } #[cfg(test)] 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 273f7bc2b6..3d429c11fa 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 @@ -22,6 +22,54 @@ fn composer() -> ChatComposer { composer } +#[test] +fn extension_status_line_clips_links_to_footer_layout() { + let mut composer = composer(); + let tree = serde_json::from_value(serde_json::json!({ + "type": "row", "props": {}, "children": [ + { "type": "text", "props": { "text": "plain" } }, + { "type": "row", "props": {}, "children": [ + { "type": "text", "props": { "text": " · " } }, + { "type": "link", "props": { "href": "https://example.com/pr/42" }, "children": [ + { "type": "text", "props": { "text": "PR #42" } } + ] } + ] } + ] + })) + .unwrap(); + composer.set_status_line(Some(FooterContent::Extension { content: tree })); + + for separate_status_line in [false, true] { + for width in [12, 40] { + let options = composer.resolve_render_options(ComposerRenderOptions { + separate_status_line, + ..ComposerRenderOptions::default() + }); + let area = Rect::new( + /*x*/ 0, + /*y*/ 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); + let linked = buffer + .content + .iter() + .filter(|cell| cell.symbol().contains("https://example.com/pr/42")) + .collect::>(); + assert_eq!(linked.len(), if width == 12 { 1 } else { 6 }, "{buffer:?}"); + assert!(linked.iter().all(|cell| !cell.symbol().contains('…'))); + assert!(buffer.content.iter().any(|cell| cell.symbol() == "p")); + } + } + + composer.set_active_agent_label(Some("bot".to_string())); + let (output, _) = render(&composer, /*width*/ 40, /*footer*/ None); + assert!(output.contains("plain"), "{output}"); + assert!(!output.contains("bot"), "{output}"); +} + fn render( composer: &ChatComposer, width: u16, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 74dcc68471..9d6a9830b2 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -818,15 +818,16 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option HyperlinkLine::from(line.clone()), + Some(FooterContent::HyperlinkLine(line)) => line.clone(), + Some(content @ FooterContent::Extension { .. }) => return Some(content.clone()), + None => HyperlinkLine::default(), + }; let suffix = passive_footer_suffix(props, status.is_some()); if status.is_none() && suffix.spans.is_empty() { return None; } - let mut line = match status { - Some(FooterContent::Line(line)) => HyperlinkLine::from(line.clone()), - Some(FooterContent::HyperlinkLine(line)) => line.clone(), - None => HyperlinkLine::default(), - }; if line.width() > 0 && suffix.width() > 0 { line.line .push_span(STATUS_LINE_SEPARATOR.set_style(secondary_text_style())); 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 81563a11e5..32d56b518c 100644 --- a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs @@ -8,12 +8,14 @@ use ratatui::widgets::Widget; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::terminal_hyperlinks::HyperlinkLine; +use crate::ui_tree::UiNode; /// Owned content for the footer's negotiated left-hand area. #[derive(Clone, Debug, PartialEq)] pub(crate) enum FooterContent { Line(Line<'static>), HyperlinkLine(HyperlinkLine), + Extension { content: UiNode }, } impl FooterContent { @@ -22,6 +24,7 @@ impl FooterContent { let width = match self { Self::Line(line) => line.width(), Self::HyperlinkLine(line) => line.width(), + Self::Extension { content } => content.render_line(u16::MAX).width(), }; u16::try_from(width).unwrap_or(u16::MAX) } @@ -30,6 +33,7 @@ impl FooterContent { pub(crate) fn render(self, area: Rect, buf: &mut Buffer) { match self { Self::Line(line) => Widget::render(line, area, buf), + Self::Extension { content } => content.render_line(area.width).render_fitted(area, buf), Self::HyperlinkLine(mut line) => { let area = Rect { height: area.height.min(1), @@ -51,6 +55,7 @@ impl FooterContent { match self { Self::Line(line) => line.into(), Self::HyperlinkLine(line) => line, + Self::Extension { content } => content.render_line(u16::MAX), } } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5c19ba286d..26d42ab5d7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -90,6 +90,7 @@ pub(crate) use multi_select_picker::MultiSelectPicker; mod question_tests; mod questions; mod request_user_input; +pub(crate) mod status_line_content; mod status_line_setup; mod status_line_style; mod status_surface_preview; @@ -120,6 +121,7 @@ pub(crate) use footer::footer_content; pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; +pub(crate) use status_line_style::STATUS_LINE_SEPARATOR; pub(crate) use status_line_style::status_line_from_segments; pub(crate) use voice_strip::VoiceStripPhase; pub(crate) use voice_strip::VoiceStripState; @@ -1375,8 +1377,8 @@ impl BottomPane { } #[cfg(test)] - pub(crate) fn status_line_text(&self) -> Option { - self.composer.status_line_text() + pub(crate) fn status_line_content(&self) -> Option<&footer_content::FooterContent> { + self.composer.status_line_content() } pub(crate) fn show_esc_backtrack_hint(&mut self) { 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 a16920a926..738f3913b2 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -58,6 +58,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; +use super::footer_content::FooterContent; use super::picker_rows; use super::picker_style; use super::selection_popup_common::ColumnWidthConfig; @@ -75,7 +76,6 @@ use crate::key_hint::is_plain_text_key_event; use crate::keymap::ListAction; use crate::keymap::ListKeymap; use crate::keymap::RuntimeKeymap; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::ColumnRenderable; @@ -110,7 +110,7 @@ pub type CancelCallback = Box; /// Callback to generate an optional preview line based on current item states. /// Returns `None` to hide the preview area. -pub type PreviewCallback = Box Option> + Send + Sync>; +type PreviewCallback = Box Option + Send + Sync>; /// A single selectable item in the multi-select picker. /// @@ -201,7 +201,7 @@ pub(crate) struct MultiSelectPicker { preview_builder: Option, /// Cached preview line (updated on item changes). - preview_line: Option>, + preview_line: Option, /// Callback invoked when items change (toggle or reorder). on_change: Option, @@ -620,16 +620,8 @@ impl Renderable for MultiSelectPicker { let [preview_area, hint_area] = Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)]) .areas(footer_area); - let preview_area = Rect { - x: preview_area.x + 2, - y: preview_area.y, - width: preview_area.width.saturating_sub(2), - height: preview_area.height, - }; - let max_preview_width = preview_area.width.saturating_sub(2) as usize; - let preview_line = - truncate_line_with_ellipsis_if_overflow(preview_line.clone(), max_preview_width); - preview_line.render(preview_area, buf); + let preview_area = preview_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2)); + preview_line.clone().render(preview_area, buf); hint_area } else { footer_area @@ -715,10 +707,19 @@ impl MultiSelectPickerBuilder { /// /// The callback receives all items and should return a [`Line`] to display, /// or `None` to hide the preview area. - pub fn on_preview(mut self, callback: F) -> Self + pub fn on_preview(self, callback: F) -> Self where F: Fn(&[MultiSelectItem]) -> Option> + Send + Sync + 'static, { + self.on_preview_content(move |items| { + callback(items).map(|line| FooterContent::HyperlinkLine(line.into())) + }) + } + + pub(crate) fn on_preview_content( + mut self, + callback: impl Fn(&[MultiSelectItem]) -> Option + Send + Sync + 'static, + ) -> Self { self.preview_builder = Some(Box::new(callback)); self } diff --git a/codex-rs/tui/src/bottom_pane/status_line_content.rs b/codex-rs/tui/src/bottom_pane/status_line_content.rs new file mode 100644 index 0000000000..47c52f5d5d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_line_content.rs @@ -0,0 +1,64 @@ +//! Builds the configured status line for both the footer and its live picker preview. + +use super::STATUS_LINE_SEPARATOR; +use super::footer_content::FooterContent; +use super::status_line_from_segments; +use super::status_line_setup::StatusLineItem; +use crate::ui_tree::Key; +use crate::ui_tree::LinkHref; +use crate::ui_tree::LinkProps; +use crate::ui_tree::StackProps; +use crate::ui_tree::UiNode; +use codex_protocol::ThreadId; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StatusLineRenderer { + Native, + Extension, +} + +impl StatusLineRenderer { + pub(crate) fn content( + self, + segments: impl IntoIterator, + use_theme_colors: bool, + thread_id: Option, + pull_request_url: Option<&str>, + ) -> Option { + if self == Self::Native { + return status_line_from_segments( + segments, + use_theme_colors, + thread_id, + pull_request_url, + ) + .map(FooterContent::HyperlinkLine); + } + + let mut children = Vec::new(); + for (item, text) in segments { + if !children.is_empty() { + children.push(Some(UiNode::text(/*key*/ None, STATUS_LINE_SEPARATOR))); + } + let key = Some(Key::String(item.to_string())); + let node = if item == StatusLineItem::PullRequestNumber + && let Some(href) = pull_request_url.and_then(|url| LinkHref::try_from(url).ok()) + { + UiNode::Link { + key, + props: LinkProps { href, title: None }, + children: vec![Some(UiNode::text(/*key*/ None, text))], + } + } else { + UiNode::text(key, text) + }; + children.push(Some(node)); + } + let tree = UiNode::Row { + key: Some(Key::String("status-line".to_string())), + props: StackProps::default(), + children, + }; + Some(FooterContent::Extension { content: tree }) + } +} diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index c4ca02c6dd..f99e49868c 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -306,14 +306,19 @@ impl StatusLineSetupView { list_keymap: ListKeymap, ) -> Self { let mut used_ids = HashSet::new(); - let mut items = vec![MultiSelectItem { - id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(), - name: "Use theme colors".to_string(), - description: Some("Apply colors from the active /theme".to_string()), - enabled: use_theme_colors, - orderable: false, - section_break_after: true, - }]; + let show_theme_colors = + preview_data.renderer == super::status_line_content::StatusLineRenderer::Native; + let mut items = Vec::new(); + if show_theme_colors { + items.push(MultiSelectItem { + id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(), + name: "Use theme colors".to_string(), + description: Some("Apply colors from the active /theme".to_string()), + enabled: use_theme_colors, + orderable: false, + section_break_after: true, + }); + } if let Some(selected_items) = status_line_items.as_ref() { for id in *selected_items { @@ -347,18 +352,25 @@ impl StatusLineSetupView { Self { picker: MultiSelectPicker::builder( "Configure Status Line".to_string(), - Some("Select which items to display in the status line.".to_string()), + Some( + if show_theme_colors { + "Select which items to display in the status line." + } else { + "Select items to display. The extension renderer uses plain text." + } + .to_string(), + ), app_event_tx, ) .list_keymap(list_keymap) .items(items) .enable_ordering() - .on_preview(move |items| { + .on_preview_content(move |items| { let use_theme_colors = items .iter() .find(|item| item.id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID) .map(|item| item.enabled) - .unwrap_or(true); + .unwrap_or(use_theme_colors); preview_data.status_line_for_items( items .iter() @@ -367,10 +379,13 @@ impl StatusLineSetupView { use_theme_colors, ) }) - .on_confirm(|ids, app_event| { - let use_theme_colors = ids - .iter() - .any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID); + .on_confirm(move |ids, app_event| { + let use_theme_colors = if show_theme_colors { + ids.iter() + .any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID) + } else { + use_theme_colors + }; let items = ids .iter() .filter_map(|id| id.parse::().ok()) @@ -451,11 +466,11 @@ impl Renderable for StatusLineSetupView { mod tests { use super::*; use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::footer_content::FooterContent; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; - use ratatui::text::Line; use tokio::sync::mpsc::unbounded_channel; use crate::app_event::AppEvent; @@ -699,6 +714,36 @@ mod tests { ); } + #[test] + fn extension_picker_previews_the_pr_link_and_preserves_the_color_preference() { + let url = "https://example.com/pr/42"; + for use_theme_colors in [false, true] { + let (tx, mut rx) = unbounded_channel(); + let mut preview = StatusSurfacePreviewData::from_iter([( + StatusLineItem::PullRequestNumber.preview_item(), + "PR #42", + )]); + preview.renderer = + crate::bottom_pane::status_line_content::StatusLineRenderer::Extension; + preview.pull_request_url = Some(url.to_owned()); + let mut view = StatusLineSetupView::new( + Some(&[StatusLineItem::PullRequestNumber.to_string()]), + use_theme_colors, + preview, + AppEventSender::new(tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + let output = render_lines(&view, /*width*/ 80); + assert!(output.contains(&crate::terminal_hyperlinks::osc8_hyperlink(url, "P"))); + assert!(!output.contains("Use theme colors")); + view.handle_key_event(crossterm::event::KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::StatusLineSetup { use_theme_colors: saved, .. }) if saved == use_theme_colors + )); + } + } + #[test] fn setup_view_snapshot_uses_runtime_preview_values() { let (tx_raw, _rx) = unbounded_channel::(); @@ -815,12 +860,7 @@ mod tests { .join("\n") } - fn line_text(line: Option>) -> Option { - line.map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + fn line_text(content: Option) -> Option { + content.map(|content| content.into_hyperlink_line().line.to_string()) } } 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 d7a5ec8447..3e56a1d49d 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -1,9 +1,9 @@ use std::collections::BTreeMap; +use super::footer_content::FooterContent; use codex_protocol::ThreadId; -use ratatui::text::Line; -use super::status_line_from_segments; +use super::status_line_content::StatusLineRenderer; use super::status_line_setup::StatusLineItem; #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] @@ -137,6 +137,8 @@ struct PreviewValue { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct StatusSurfacePreviewData { pub(crate) thread_id: Option, + pub(crate) renderer: StatusLineRenderer, + pub(crate) pull_request_url: Option, values: BTreeMap, } @@ -144,6 +146,8 @@ impl Default for StatusSurfacePreviewData { fn default() -> Self { let mut data = Self { thread_id: None, + renderer: StatusLineRenderer::Native, + pull_request_url: None, values: BTreeMap::new(), }; for item in StatusSurfacePreviewItem::iter() { @@ -246,21 +250,25 @@ impl StatusSurfacePreviewData { &self, items: I, use_theme_colors: bool, - ) -> Option> + ) -> Option where I: IntoIterator, { - let segments = items.into_iter().filter_map(|item| { - self.value_for(item.preview_item()) - .map(|value| (item, value.to_string())) - }); - status_line_from_segments( + let mut segments = items + .into_iter() + .filter_map(|item| { + self.value_for(item.preview_item()) + .map(|value| (item, value.to_string())) + }) + .peekable(); + segments.peek()?; + self.renderer.content( segments, use_theme_colors, self.thread_id, - /*pull_request_url*/ None, + self.live_value_for(StatusSurfacePreviewItem::PullRequestNumber) + .and(self.pull_request_url.as_deref()), ) - .map(|line| line.line) } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68868be0c7..e6fcd2d185 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2073,7 +2073,16 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { - self.bottom_pane.status_line_text() + self.status_line_content() + .cloned() + .map(|content| content.into_hyperlink_line().line.to_string()) + } + + #[cfg(test)] + pub(crate) fn status_line_content( + &self, + ) -> Option<&crate::bottom_pane::footer_content::FooterContent> { + self.bottom_pane.status_line_content() } pub(crate) fn clear_token_usage(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 7d4eed3d3b..d08632d79e 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -5,6 +5,7 @@ //! history-facing `/status` surface. use super::*; +use crate::bottom_pane::footer_content::FooterContent; use crate::status::thread_cost_micros; impl ChatWidget { @@ -83,9 +84,8 @@ impl ChatWidget { } /// Sets the currently rendered footer status-line value. - pub(crate) fn set_status_line(&mut self, status_line: Option) { - self.bottom_pane - .set_status_line(status_line.map(Into::into)); + pub(crate) fn set_status_line(&mut self, status_line: Option) { + self.bottom_pane.set_status_line(status_line); } /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. @@ -368,6 +368,8 @@ impl ChatWidget { }), ); preview_data.thread_id = self.thread_id; + preview_data.renderer = self.status_line_renderer(); + preview_data.pull_request_url = self.status_line_pull_request_url().map(str::to_owned); if self.rate_limit_snapshots_by_limit_id.contains_key("codex") { for item in [ diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index ea9a32dd03..9e6baf1412 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -4,7 +4,7 @@ //! behavior easier to review without paging through the rest of `chatwidget.rs`. use super::*; -use crate::bottom_pane::status_line_from_segments; +use crate::bottom_pane::status_line_content::StatusLineRenderer; use crate::branch_summary; use crate::chatwidget::limit_label_for_window; use crate::chatwidget::rate_limits::get_limits_duration; @@ -216,12 +216,25 @@ impl ChatWidget { } } - self.set_status_line(status_line_from_segments( + let content = self.status_line_renderer().content( segments, self.local_settings.tui.status_line_use_colors, self.thread_id, self.status_line_pull_request_url(), - )); + ); + self.set_status_line(content); + } + + pub(super) fn status_line_renderer(&self) -> StatusLineRenderer { + if self + .config + .features + .enabled(Feature::TuiExtensionsStatusLine) + { + StatusLineRenderer::Extension + } else { + StatusLineRenderer::Native + } } /// Clears the terminal title Codex most recently wrote, if any. @@ -846,7 +859,7 @@ impl ChatWidget { } } - fn status_line_pull_request_url(&self) -> Option<&str> { + pub(super) fn status_line_pull_request_url(&self) -> Option<&str> { self.status_line_git_summary .as_ref() .and_then(|summary| summary.pull_request.as_ref()) diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 67d2dc9a08..64245f3f3e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -28,7 +28,11 @@ async fn thread_color_preview_matches_footer() { let mut snapshot = Vec::new(); let preview = chat.status_surface_preview_data(); for use_colors in [true, false] { - let line = preview.status_line_for_items(items, use_colors).unwrap(); + let line = preview + .status_line_for_items(items, use_colors) + .unwrap() + .into_hyperlink_line() + .line; let footer = crate::bottom_pane::status_line_from_segments( items .into_iter() @@ -50,7 +54,7 @@ fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) - let preview_data = chat.status_surface_preview_data(); preview_data .status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true) - .map(line_text) + .map(|content| line_text(content.into_hyperlink_line().line)) } fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f4f8093dbb..6789b65f20 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -248,6 +248,7 @@ mod transcript_view; mod tui; mod turn_tip; mod ui_consts; +mod ui_tree; mod unarchive_prompt; pub(crate) mod update_action; mod worktree_startup; diff --git a/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__links_only_annotate_their_own_cells.snap b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__links_only_annotate_their_own_cells.snap new file mode 100644 index 0000000000..d6b215f7e8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__links_only_annotate_their_own_cells.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/ui_tree_tests.rs +assertion_line: 42 +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 16, height: 1 }, + content: [ + "界 ]8;;https://example.com/pr/42P]8;;]8;;https://example.com/pr/42R]8;;]8;;https://example.com/pr/42 ]8;;]8;;https://example.com/pr/42#]8;;]8;;https://example.com/pr/424]8;;]8;;https://example.com/pr/422]8;; ", // hidden by multi-width symbols: [(1, " ")] + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, + x: 9, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/ui_tree.rs b/codex-rs/tui/src/ui_tree.rs new file mode 100644 index 0000000000..e5b9225081 --- /dev/null +++ b/codex-rs/tui/src/ui_tree.rs @@ -0,0 +1,110 @@ +//! Bounded, display-only subset of the `codex-tui/1` tree contract. +//! +//! The wire tree is independent of its producer. Producers normalize text into leaf nodes and +//! arrays into composite nodes; unsupported primitives and properties fail deserialization. This +//! first slice renders one horizontal line of plain text, links, rows, spacers, dividers, and +//! numeric gaps. Links underline their text. Keys are preserved for future reconciliation. + +mod render; + +#[cfg(test)] +#[path = "ui_tree_tests.rs"] +mod tests; + +use serde::Deserialize; +use serde::Serialize; + +/// A single display node, or no content. +pub(crate) type UiTree = Option; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) enum UiNode { + Text { + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, + props: TextProps, + }, + Link { + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, + props: LinkProps, + children: Vec, + }, + Row { + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, + props: StackProps, + children: Vec, + }, + Spacer { + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, + }, + Divider { + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, + }, +} + +impl UiNode { + pub(crate) fn text(key: Option, text: impl Into) -> Self { + Self::Text { + key, + props: TextProps { text: text.into() }, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LinkProps { + pub(crate) href: LinkHref, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) title: Option, +} + +/// An absolute HTTP(S) destination safe to embed in a terminal hyperlink. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub(crate) struct LinkHref(String); + +impl TryFrom for LinkHref { + type Error = &'static str; + + fn try_from(value: String) -> Result { + Self::try_from(value.as_str()) + } +} + +impl TryFrom<&str> for LinkHref { + type Error = &'static str; + + fn try_from(value: &str) -> Result { + crate::terminal_hyperlinks::web_destination(value) + .filter(|destination| destination == value) + .map(Self) + .ok_or("link href must be an absolute HTTP(S) URL without control characters") + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum Key { + String(String), + Number(serde_json::Number), +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TextProps { + pub(crate) text: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StackProps { + /// Numeric spacing uses the profile's four-point scale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) gap: Option, +} diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs new file mode 100644 index 0000000000..a232478ef8 --- /dev/null +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -0,0 +1,116 @@ +//! Render the visible prefix of a horizontal display tree into one terminal line. + +use super::UiNode; +use super::UiTree; +use crate::history_cell::sanitize_user_text; +use crate::line_truncation::truncate_line_to_width; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::TerminalHyperlink; +use ratatui::style::Modifier; + +const MAX_DEPTH: usize = 16; +const MAX_NODES: usize = 256; +const MAX_TEXT_BYTES: usize = 16 * 1024; + +struct Budget { + nodes: usize, + text_bytes: usize, +} + +impl UiNode { + /// Render one row. On overflow, fit an unlinked ellipsis inside the supplied width. + pub(crate) fn render_line(&self, width: u16) -> HyperlinkLine { + let width = usize::from(width); + if width == 0 { + return HyperlinkLine::default(); + } + let mut budget = Budget { + nodes: MAX_NODES, + text_bytes: MAX_TEXT_BYTES, + }; + let mut line = node_line(Some(self), /*depth*/ 0, &mut budget).unwrap_or_default(); + if line.width() > width { + line.line = truncate_line_to_width(line.line, width - 1); + line.clip_hyperlinks_to_width(line.width()); + line.line.push_span("…"); + } + line + } +} + +fn node_line(node: Option<&UiNode>, depth: usize, budget: &mut Budget) -> Option { + if budget.nodes == 0 { + return None; + } + budget.nodes -= 1; + if depth > MAX_DEPTH { + return None; + } + match node? { + UiNode::Text { props, .. } => { + let raw = &props.text; + if budget.text_bytes == 0 && !raw.is_empty() { + return None; + } + let end = raw.floor_char_boundary(raw.len().min(budget.text_bytes)); + let first = raw[..end].split('\n').next().unwrap_or_default(); + budget.text_bytes -= first.len(); + let visible = sanitize_user_text(first.into()).replace('\t', " "); + Some(visible.into()) + } + UiNode::Link { + props, children, .. + } => { + let mut line = row_line(children, /*gap*/ 0, depth, budget)?; + for span in &mut line.line.spans { + span.style = span.style.add_modifier(Modifier::UNDERLINED); + } + let width = line.width(); + if width > 0 { + line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())]; + } + Some(line) + } + UiNode::Row { + props, children, .. + } => { + // A spacing unit is four points; a terminal cell is eight points across. + let gap = (props.gap.unwrap_or(0.0).clamp(0.0, 32.0) / 2.0).ceil() as usize; + row_line(children, gap, depth, budget) + } + UiNode::Spacer { .. } => Some(" ".into()), + UiNode::Divider { .. } => Some("│".into()), + } +} + +fn row_line( + children: &[UiTree], + gap: usize, + depth: usize, + budget: &mut Budget, +) -> Option { + let mut result: Option = None; + let mut used = 0; + for child in children { + if budget.nodes == 0 { + break; + } + let Some(child) = node_line(child.as_ref(), depth + 1, budget) else { + continue; + }; + let spacing = if result.is_some() { gap } else { 0 }; + let line = result.get_or_insert_default(); + if spacing > 0 { + line.line.push_span(" ".repeat(spacing)); + } + let offset = used + spacing; + used = offset + child.width(); + line.line.spans.extend(child.line.spans); + line.hyperlinks + .extend(child.hyperlinks.into_iter().map(|mut link| { + link.columns = link.columns.start + offset..link.columns.end + offset; + link + })); + } + result +} diff --git a/codex-rs/tui/src/ui_tree_tests.rs b/codex-rs/tui/src/ui_tree_tests.rs new file mode 100644 index 0000000000..4380046c78 --- /dev/null +++ b/codex-rs/tui/src/ui_tree_tests.rs @@ -0,0 +1,118 @@ +#![cfg(test)] + +use super::*; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use serde_json::json; + +fn tree(value: serde_json::Value) -> UiNode { + serde_json::from_value::(value).unwrap().unwrap() +} + +fn text(key: &str, value: &str) -> serde_json::Value { + json!({"type":"text","key":key,"props":{"text":value}}) +} + +#[test] +fn normalized_tree_round_trips_and_enforces_leaf_shape() { + let value = json!({"type":"row","key":7,"props":{"gap":2.0},"children":[{"type":"link","props":{"href":"https://example.com/pr/42"},"children":[text("usage", "12%")]}, null, {"type":"spacer"}]}); + assert_eq!(serde_json::to_value(tree(value.clone())).unwrap(), value); + assert!( + serde_json::from_value::( + json!({"type":"text","props":{"text":"hello"},"children":[]}) + ) + .is_err() + ); + assert_eq!(serde_json::from_value::(json!(null)).unwrap(), None); + for href in [ + "relative/path", + "file:///tmp/example", + "https://example.com/\u{001b}", + ] { + assert!( + serde_json::from_value::( + json!({"type":"link","props":{"href":href},"children":[]}) + ) + .is_err() + ); + } +} + +#[test] +fn links_only_annotate_their_own_cells() { + let tree = tree( + json!({"type":"row","props":{"gap":2},"children":[text("prefix", "界"),{"type":"link","props":{"href":"https://example.com/pr/42"},"children":[{"type":"row","props":{},"children":[text("pr", "PR #42")]}]}]}), + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 16, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + tree.render_line(area.width) + .render_fitted(area, &mut buffer); + assert_eq!(buffer[(0, 0)].symbol(), "界"); + assert!( + buffer[(3, 0)] + .symbol() + .contains("https://example.com/pr/42") + ); + insta::assert_snapshot!(format!("{buffer:?}")); + + assert_eq!(tree.render_line(/*width*/ 1).line.to_string(), "…"); + let narrow = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 6, /*height*/ 2, + ); + let mut buffer = Buffer::empty(narrow); + tree.render_line(narrow.width) + .render_fitted(narrow, &mut buffer); + assert!( + buffer[(4, 0)] + .symbol() + .contains("https://example.com/pr/42") + ); + assert_eq!(buffer[(5, 0)].symbol(), "…"); + assert!((0..narrow.width).all(|x| buffer[(x, 1)].symbol() == " ")); +} + +#[test] +fn extension_status_line_keeps_exactly_fitting_halfwidth_voiced_katakana() { + let status = tree(text("name", "ガ")); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 2, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + status + .render_line(area.width) + .render_fitted(area, &mut buffer); + assert_eq!(buffer[(0, 0)].symbol(), "ガ"); +} + +#[test] +fn ellipsis_counts_toward_width_only_when_there_is_overflow() { + let status = + tree(json!({"type":"row","props":{"gap":2},"children":[text("label","abcde"), null]})); + for (width, expected) in [(0, ""), (1, "…"), (4, "abc…"), (5, "abcde")] { + let line = status.render_line(width); + assert_eq!(line.line.to_string(), expected); + assert!(line.width() <= usize::from(width)); + } + let wide = tree(text("label", "a界界")); + assert_eq!(wide.render_line(/*width*/ 4).line.to_string(), "a界…"); + let long = "x".repeat(513); + assert_eq!( + tree(text("label", &long)) + .render_line(/*width*/ 513) + .line + .to_string(), + long + ); +} + +#[test] +fn first_line_skips_hidden_text_before_rendering_its_sibling() { + let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(16 * 1024)); + let tree = tree(json!({"type":"row","props":{},"children":[ + text("first", &multiline), text("sibling", "!") + ]})); + assert_eq!(tree.render_line(/*width*/ 4).line.to_string(), "ok!"); +}