From 1a2f5babe83bc4b8d4d89701a01b7044a0ea6135 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 6 Nov 2026 21:16:13 +0000 Subject: [PATCH] Add inherited styling to TUI display trees (#59988) ## What changed Support `bold`, `italic`, and `underline` nodes, plus text properties for color, weight, italics, and underlining. Nested text can override inherited styles, including disabling modifiers. Add row backgrounds that apply to children and gaps. Text colors and row backgrounds accept the eight standard ANSI color names or `#RRGGBB` values, with RGB colors resolved through the terminal palette. Preserve the last visible span's style on overflow ellipses while keeping them unlinked. ## Testing Add coverage for style serialization and invalid values, nested modifier depth limits, inherited styles and overrides, row backgrounds, styled truncation, and foreground/background palette resolution, including a rendered buffer snapshot. GitOrigin-RevId: 6b19b6b740cd754890c8adb489fba414a6ee62d3 --- .../src/bottom_pane/status_line_content.rs | 2 +- codex-rs/tui/src/line_truncation.rs | 6 ++ ...iers_and_row_surfaces_follow_the_tree.snap | 20 ++++ codex-rs/tui/src/ui_tree.rs | 46 ++++++++- codex-rs/tui/src/ui_tree/decode.rs | 13 +++ codex-rs/tui/src/ui_tree/render.rs | 76 ++++++++++---- codex-rs/tui/src/ui_tree/theme.rs | 70 +++++++++++++ codex-rs/tui/src/ui_tree_tests.rs | 99 ++++++++++++++++++- 8 files changed, 305 insertions(+), 27 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap create mode 100644 codex-rs/tui/src/ui_tree/theme.rs diff --git a/codex-rs/tui/src/bottom_pane/status_line_content.rs b/codex-rs/tui/src/bottom_pane/status_line_content.rs index faf4929d32..5095708551 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_content.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_content.rs @@ -54,7 +54,7 @@ impl StatusLineRenderer { children.push(Some(node)); } let tree = UiNode::Row { - props: RowProps { gap: 0 }, + props: RowProps::default(), children, }; Some(FooterContent::Extension { diff --git a/codex-rs/tui/src/line_truncation.rs b/codex-rs/tui/src/line_truncation.rs index b62ab97fd2..42248714d9 100644 --- a/codex-rs/tui/src/line_truncation.rs +++ b/codex-rs/tui/src/line_truncation.rs @@ -88,6 +88,12 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow( return line; } + truncate_line_with_ellipsis(line, max_width) +} + +/// Fit an already overflowing line into a nonempty area, including the ellipsis. +pub(crate) fn truncate_line_with_ellipsis(line: Line<'static>, max_width: usize) -> Line<'static> { + debug_assert!(max_width > 0); let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); let Line { style, diff --git a/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap new file mode 100644 index 0000000000..9c8b18a1f4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/ui_tree_tests.rs +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 12, height: 1 }, + content: [ + "abc d e ]8;;https://example.comf]8;; ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: BOLD | ITALIC | UNDERLINED, + x: 1, y: 0, fg: Cyan, bg: Gray, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: BOLD | ITALIC, + x: 3, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: NONE, + x: 4, y: 0, fg: Reset, bg: Blue, underline: Reset, modifier: NONE, + x: 7, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: NONE, + x: 8, y: 0, fg: Reset, bg: Gray, 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 index 45de0fc8ba..ead40c6883 100644 --- a/codex-rs/tui/src/ui_tree.rs +++ b/codex-rs/tui/src/ui_tree.rs @@ -2,6 +2,10 @@ mod decode; mod render; +mod theme; + +use theme::Color; +use theme::TextWeight; #[cfg(test)] #[path = "ui_tree_tests.rs"] @@ -21,7 +25,7 @@ pub(crate) struct UiTree(pub(crate) Option); #[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] pub(crate) enum UiNode { Text { - #[serde(default, skip_serializing)] + #[serde(default, skip_serializing_if = "is_default")] props: TextProps, children: Vec>, }, @@ -33,13 +37,37 @@ pub(crate) enum UiNode { props: RowProps, children: Vec>, }, + Bold { + children: Vec>, + }, + Italic { + children: Vec>, + }, + Underline { + children: Vec>, + }, #[serde(untagged)] String(String), } -#[derive(Clone, Debug, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct TextProps {} +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct TextProps { + #[serde( + deserialize_with = "decode::optional_token", + skip_serializing_if = "Option::is_none" + )] + color: Option, + #[serde( + deserialize_with = "decode::optional_token", + skip_serializing_if = "Option::is_none" + )] + weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + italic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + underline: Option, +} #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -79,4 +107,14 @@ pub(crate) struct RowProps { /// Horizontal spacing in terminal columns, expressed as a non-negative JavaScript-safe integer. #[serde(deserialize_with = "decode::gap")] pub(crate) gap: u64, + #[serde( + default, + deserialize_with = "decode::optional_token", + skip_serializing_if = "Option::is_none" + )] + background: Option, +} + +fn is_default(value: &T) -> bool { + *value == T::default() } diff --git a/codex-rs/tui/src/ui_tree/decode.rs b/codex-rs/tui/src/ui_tree/decode.rs index 0377563bcf..01052cd1a3 100644 --- a/codex-rs/tui/src/ui_tree/decode.rs +++ b/codex-rs/tui/src/ui_tree/decode.rs @@ -6,7 +6,9 @@ use super::UiNode; use super::UiTree; use serde::Deserialize; use serde::Deserializer; +use serde::de::DeserializeOwned; use serde::de::Error; +use serde::de::IntoDeserializer; impl std::str::FromStr for UiTree { type Err = serde_json::Error; @@ -35,6 +37,9 @@ impl std::str::FromStr for UiTree { } } UiNode::Text { children, .. } + | UiNode::Bold { children } + | UiNode::Italic { children } + | UiNode::Underline { children } | UiNode::Link { children, .. } | UiNode::Row { children, .. } => { pending.extend(children.iter().flatten().map(|child| (child, depth + 1))); @@ -45,6 +50,14 @@ impl std::str::FromStr for UiTree { } } +pub(super) fn optional_token<'de, D: Deserializer<'de>, T: DeserializeOwned>( + d: D, +) -> Result, D::Error> { + Option::::deserialize(d)? + .map(|s| T::deserialize(s.into_deserializer())) + .transpose() +} + pub(super) fn gap<'de, D: Deserializer<'de>>(de: D) -> Result { let gap = u64::deserialize(de)?; if gap > MAX_GAP { diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index a2553d035a..2704a76420 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -1,13 +1,17 @@ //! Render the visible prefix of a horizontal display tree into one terminal line. use super::MAX_TEXT_BYTES; +use super::TextProps; +use super::TextWeight; use super::UiNode; use super::UiTree; use crate::history_cell::sanitize_user_text; -use crate::line_truncation::truncate_line_to_width; +use crate::line_truncation::truncate_line_with_ellipsis; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::TerminalHyperlink; use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Span; impl UiTree { /// Render one row. On overflow, fit an unlinked ellipsis inside the supplied width. @@ -16,47 +20,52 @@ impl UiTree { let Some(root) = self.0.as_ref().filter(|_| width > 0) else { return HyperlinkLine::default(); }; - let mut line = node_line(root, width); + let mut line = node_line(root, width, Style::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.line = truncate_line_with_ellipsis(line.line, width); + line.clip_hyperlinks_to_width(line.width() - 1); } line } } -fn node_line(node: &UiNode, width: usize) -> HyperlinkLine { - match node { +fn node_line(node: &UiNode, width: usize, style: Style) -> HyperlinkLine { + let (children, gap, style) = match node { UiNode::String(raw) => { // Built-in values are constructed directly, bypassing the wire decoder. let raw = &raw[..raw.floor_char_boundary(MAX_TEXT_BYTES)]; let first = raw.split('\n').next().unwrap_or_default(); - sanitize_user_text(first.into()) - .replace('\t', " ") - .into() + let visible = sanitize_user_text(first.into()).replace('\t', " "); + return HyperlinkLine::new(Span::styled(visible, style).into()); } UiNode::Link { props, children } => { - let mut line = row_line(children, /*gap*/ 0, width); - for span in &mut line.line.spans { - span.style = span.style.add_modifier(Modifier::UNDERLINED); - } + let mut line = row_line(children, /*gap*/ 0, width, style.underlined()); let width = line.width(); if width > 0 { line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())]; } - line + return line; } - UiNode::Row { props, children } => row_line(children, props.gap, width), - UiNode::Text { children, .. } => row_line(children, /*gap*/ 0, width), - } + UiNode::Row { props, children } => { + let style = props + .background + .as_ref() + .map_or(style, |color| style.bg(color.resolve())); + (children, props.gap, style) + } + UiNode::Text { props, children } => (children, 0, props.style(style)), + UiNode::Bold { children } => (children, 0, style.bold()), + UiNode::Italic { children } => (children, 0, style.italic()), + UiNode::Underline { children } => (children, 0, style.underlined()), + }; + row_line(children, gap, width, style) } -fn row_line(children: &[Option], gap: u64, width: usize) -> HyperlinkLine { +fn row_line(children: &[Option], gap: u64, width: usize, style: Style) -> HyperlinkLine { let mut result = HyperlinkLine::default(); let mut used = 0; for child in children.iter().flatten() { - let child = node_line(child, width); + let child = node_line(child, width, style); let child_width = child.width(); if child_width == 0 { continue; @@ -64,7 +73,9 @@ fn row_line(children: &[Option], gap: u64, width: usize) -> HyperlinkLin if used > 0 && gap > 0 { // One column past the viewport is sufficient to detect overflow. let spacing = gap.min((width + 1 - used) as u64) as usize; - result.line.push_span(" ".repeat(spacing)); + result + .line + .push_span(Span::styled(" ".repeat(spacing), style)); used += spacing; } let offset = used; @@ -82,3 +93,26 @@ fn row_line(children: &[Option], gap: u64, width: usize) -> HyperlinkLin } result } + +impl TextProps { + fn style(&self, mut style: Style) -> Style { + if let Some(color) = &self.color { + style = style.fg(color.resolve()); + } + for (enabled, modifier) in [ + ( + self.weight.map(|weight| weight == TextWeight::Bold), + Modifier::BOLD, + ), + (self.italic, Modifier::ITALIC), + (self.underline, Modifier::UNDERLINED), + ] { + match enabled { + Some(true) => style = style.add_modifier(modifier), + Some(false) => style = style.remove_modifier(modifier), + None => {} + } + } + style + } +} diff --git a/codex-rs/tui/src/ui_tree/theme.rs b/codex-rs/tui/src/ui_tree/theme.rs new file mode 100644 index 0000000000..1efa59949d --- /dev/null +++ b/codex-rs/tui/src/ui_tree/theme.rs @@ -0,0 +1,70 @@ +//! Literal provider colors use the terminal's palette at paint time. + +use crate::terminal_palette; +use ratatui::style::Color as TerminalColor; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum Color { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + #[serde(untagged)] + Rgb(HexRgb), +} + +impl Color { + pub(super) fn resolve(&self) -> TerminalColor { + match self { + Self::Rgb(rgb) => terminal_palette::best_color(rgb.0), + Self::Black => TerminalColor::Black, + Self::Red => TerminalColor::Red, + Self::Green => TerminalColor::Green, + Self::Yellow => TerminalColor::Yellow, + Self::Blue => TerminalColor::Blue, + Self::Magenta => TerminalColor::Magenta, + Self::Cyan => TerminalColor::Cyan, + // Ratatui maps Gray to ANSI slot 7 and White to slot 15. + Self::White => TerminalColor::Gray, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub(super) struct HexRgb((u8, u8, u8)); + +impl TryFrom for HexRgb { + type Error = &'static str; + + fn try_from(source: String) -> Result { + const INVALID: &str = "expected #RRGGBB"; + if source.len() != 7 { + return Err(INVALID); + } + let color = source + .parse::() + .map_err(|_| INVALID)?; + Ok(Self((color.r, color.g, color.b))) + } +} + +impl From for String { + fn from(HexRgb((r, g, b)): HexRgb) -> Self { + format!("#{r:02x}{g:02x}{b:02x}") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum TextWeight { + Normal, + Bold, +} diff --git a/codex-rs/tui/src/ui_tree_tests.rs b/codex-rs/tui/src/ui_tree_tests.rs index 9aa2574033..28ed4227ed 100644 --- a/codex-rs/tui/src/ui_tree_tests.rs +++ b/codex-rs/tui/src/ui_tree_tests.rs @@ -56,7 +56,14 @@ fn rejects_invalid_gaps_and_elements() { fn deserialization_checks_limits_across_the_whole_tree() { let parent = |children: Vec| json!({"type":"text","children":children}); assert!(parse(parent(vec![Value::Null; 257])).is_ok()); - let nested = (0..16).fold(json!("X"), |child, _| parent(vec![child])); + let nested = ["text", "bold", "italic", "underline"] + .into_iter() + .cycle() + .take(16) + .fold( + json!("X"), + |child, kind| json!({"type":kind,"children":[child]}), + ); assert!(parse(nested.clone()).is_ok()); assert!(parse(parent(vec![nested])).is_err()); let mut leaves = vec![json!(""); 255]; @@ -159,3 +166,93 @@ fn directly_constructed_text_keeps_a_bounded_utf8_prefix() { let text = UiTree(Some(UiNode::String(format!("{prefix}界tail")))); assert_eq!(text.render_line(u16::MAX).line.to_string(), prefix); } + +#[test] +fn text_and_row_share_the_literal_color_contract() { + for color in ["red", "white", "#ab12f0"] { + let value = json!({"type":"row","props":{"gap":0,"background":color},"children":[ + {"type":"text","props":{"color":color,"weight":"bold","italic":true,"underline":false},"children":["ok"]} + ]}); + assert_eq!(serde_json::to_value(tree(value.clone())).unwrap(), value); + } + for props in [ + json!({"weight":"semibold"}), + json!({"weight":{"bold":null}}), + json!({"color":{"red":null}}), + json!({"color":"#abcdef00"}), + json!({"color":"#+12345"}), + json!({"color":"#123"}), + json!({"color":"teal"}), + json!({"underline":"dotted"}), + ] { + assert!( + serde_json::from_value::(json!({"type":"text","props":props,"children":[]})) + .is_err(), + "{props}" + ); + } + assert!( + serde_json::from_value::(json!({"type":"italic","props":{},"children":[]})) + .is_err() + ); +} + +#[test] +fn inline_modifiers_and_row_surfaces_follow_the_tree() { + let node = tree(json!({ + "type":"row", "props":{"gap":1,"background":"white"}, "children":[ + {"type":"bold","children":[{"type":"italic","children":[ + {"type":"underline","children":["a"]}, + {"type":"text","props":{"weight":"normal","italic":false,"color":"cyan"},"children":["b"]}, + "c" + ]}]}, + {"type":"row","props":{"gap":1,"background":"blue"},"children":["d","e"]}, + {"type":"link","props":{"href":"https://example.com"},"children":["f"]} + ] + })); + let clipped = node.render_line(/*width*/ 6); + assert_eq!( + clipped.line.spans.last().unwrap(), + &ratatui::text::Span::styled( + "…", + ratatui::style::Style::default().bg(ratatui::style::Color::Blue), + ) + ); + let line = node.render_line(/*width*/ 12); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + line.render_fitted(area, &mut buffer); + insta::assert_snapshot!(format!("{buffer:?}")); +} + +#[test] +fn hex_colors_use_the_terminal_palette_for_both_channels() { + let tree = tree(json!({ + "type":"row", "props":{"gap":1,"background":"#1020F0"}, "children":[ + {"type":"text","props":{"color":"#AB12f0"},"children":["a"]}, "b" + ] + })); + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (255, 255, 255), + bg: (0, 0, 0), + }, + || { + let line = tree.render_line(/*width*/ 3); + let colors: Vec<_> = line + .line + .spans + .iter() + .map(|s| (s.style.fg, s.style.bg)) + .collect(); + let surface = Some(crate::terminal_palette::rgb_color((16, 32, 240))); + let foreground = Some(crate::terminal_palette::rgb_color((171, 18, 240))); + assert_eq!( + colors, + [(foreground, surface), (None, surface), (None, surface)] + ); + }, + ); +}