From 2157f986489f36a0cd4c866b17e8f8ff418cf959 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Sat, 7 Nov 2026 01:18:45 +0000 Subject: [PATCH] Add theme-aware semantic colors to extension UI trees (#60019) ## Why Extension UI trees previously accepted only named terminal colors and literal RGB values. Semantic colors let extension content follow the active syntax theme and terminal color capabilities. ## What changed - Accept `primary`, `surface`, `accent`, and `status-*` color roles for text foregrounds and row backgrounds. - Resolve semantic colors against the active theme when rendering extension footer content. Prefer the `codex.accent` scope for `accent`, falling back to the theme's accent setting. - Reuse status-line scope mappings and fallback colors, with terminal-default fallbacks for unavailable primary, surface, and accent colors. ## Testing Add color serialization coverage and rendering snapshots for dark, light, and empty themes in truecolor and ANSI 16-color terminals, including nested foreground and background styles. GitOrigin-RevId: 156f137859d74178f9ae6aa46a02eecb815e9802 --- .../src/bottom_pane/footer/footer_content.rs | 14 +- codex-rs/tui/src/bottom_pane/mod.rs | 1 + .../tui/src/bottom_pane/status_line_style.rs | 29 +++- codex-rs/tui/src/render/highlight.rs | 24 ++- ...ee__tests__semantic_statusline_ansi16.snap | 37 ++++ ..._tests__semantic_statusline_truecolor.snap | 39 +++++ codex-rs/tui/src/ui_tree/render.rs | 30 ++-- codex-rs/tui/src/ui_tree/theme.rs | 77 ++++++++- codex-rs/tui/src/ui_tree_tests.rs | 162 ++++++++++++++++-- 9 files changed, 366 insertions(+), 47 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__semantic_statusline_ansi16.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__semantic_statusline_truecolor.snap 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 05ce7ddd51..5fff31b0bd 100644 --- a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs @@ -7,6 +7,7 @@ use ratatui::text::Line; use ratatui::widgets::Widget; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::highlight::with_syntax_theme; use crate::terminal_hyperlinks::HyperlinkLine; use crate::ui_tree::UiTree; @@ -24,7 +25,9 @@ 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(), + Self::Extension { content } => { + with_syntax_theme(|theme| content.render_line(u16::MAX, theme)).width() + } }; u16::try_from(width).unwrap_or(u16::MAX) } @@ -33,7 +36,10 @@ 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::Extension { content } => { + with_syntax_theme(|theme| content.render_line(area.width, theme)) + .render_fitted(area, buf) + } Self::HyperlinkLine(mut line) => { let area = Rect { height: area.height.min(1), @@ -55,7 +61,9 @@ impl FooterContent { match self { Self::Line(line) => line.into(), Self::HyperlinkLine(line) => line, - Self::Extension { content } => content.render_line(u16::MAX), + Self::Extension { content } => { + with_syntax_theme(|theme| content.render_line(u16::MAX, theme)) + } } } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 006c777a9d..a6f2e71312 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -125,6 +125,7 @@ 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::StatusLineAccent; pub(crate) use status_line_style::status_line_from_segments; pub(crate) use voice_strip::VoiceStripPhase; pub(crate) use voice_strip::VoiceStripState; diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 571530d1c4..8fa1b120c2 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -19,17 +19,27 @@ pub(crate) const STATUS_LINE_SEPARATOR: &str = " · "; const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85; const STATUS_LINE_COLOR_BRIGHTNESS_PERCENT: u16 = 100; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StatusLineAccent { +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub(crate) enum StatusLineAccent { + #[serde(rename = "status-model")] Model, + #[serde(rename = "status-path")] Path, + #[serde(rename = "status-branch")] Branch, + #[serde(rename = "status-state")] State, + #[serde(rename = "status-usage")] Usage, + #[serde(rename = "status-limit")] Limit, + #[serde(rename = "status-metadata")] Metadata, + #[serde(rename = "status-mode")] Mode, + #[serde(rename = "status-thread")] Thread, + #[serde(rename = "status-progress")] Progress, } @@ -70,7 +80,7 @@ impl StatusLineAccent { } } - fn scopes(self) -> &'static [&'static str] { + pub(crate) fn scopes(self) -> &'static [&'static str] { match self { Self::Model => &["entity.name.type", "support.type", "variable"], Self::Path => &["string", "markup.underline.link"], @@ -85,11 +95,11 @@ impl StatusLineAccent { } } - fn fallback_style(self) -> Style { + pub(crate) fn fallback_color(self) -> Color { match self { - Self::Model | Self::State | Self::Metadata | Self::Mode => Style::default().cyan(), - Self::Path | Self::Usage | Self::Progress => Style::default().green(), - Self::Branch | Self::Limit | Self::Thread => Style::default().magenta(), + Self::Model | Self::State | Self::Metadata | Self::Mode => Color::Cyan, + Self::Path | Self::Usage | Self::Progress => Color::Green, + Self::Branch | Self::Limit | Self::Thread => Color::Magenta, } } } @@ -142,7 +152,8 @@ where } else if use_theme_colors { let accent = StatusLineAccent::for_item(item); soften_status_line_style( - theme_style_for_accent(accent).unwrap_or_else(|| accent.fallback_style()), + theme_style_for_accent(accent) + .unwrap_or_else(|| Style::default().fg(accent.fallback_color())), ) } else { secondary_text_style() @@ -152,7 +163,7 @@ where if effective_stdout_color_level() == StdoutColorLevel::Ansi16 && matches!(foreground, Color::Rgb(..) | Color::Indexed(16..)) { - style.patch(StatusLineAccent::for_item(item).fallback_style()) + style.fg(StatusLineAccent::for_item(item).fallback_color()) } else { style.fg(readable_color_on(foreground, /*background*/ None)) } diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index 41b1d239c1..26c21acabe 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -290,11 +290,16 @@ pub(crate) fn syntax_theme_revision() -> u64 { /// Clone the current syntax theme (e.g. to save for cancel-restore). pub(crate) fn current_syntax_theme() -> Theme { + with_syntax_theme(Theme::clone) +} + +/// Borrow the active theme for one synchronous render operation. +pub(crate) fn with_syntax_theme(f: impl FnOnce(&Theme) -> T) -> T { let active_theme = theme_lock(); - match active_theme.read() { - Ok(theme) => theme.clone(), - Err(poisoned) => poisoned.into_inner().clone(), - } + let theme = active_theme + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&theme) } /// An explicit diff background, including the ANSI terminal-default marker. @@ -366,11 +371,18 @@ pub(crate) fn foreground_style_for_scopes_with_theme( theme: &Theme, scope_names: &[&str], ) -> Option