From 907b751eabc07631cd041cd701fb5e6ce73f4c03 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 18 Sep 2026 05:15:43 +0000 Subject: [PATCH] Add six bundled TUI themes and theme-aware accents (#46504) ## What changed - Bundle `ada`, `babbage`, `curie`, `cushman`, `dali`, and `davinci` themes for configuration and the theme picker, preserving precedence and invalid-file warnings for custom themes with the same names. - Use `codex.accent` for active and selected controls on truecolor and 256-color terminals, retaining existing fallbacks at lower color depths. - Honor terminal-default diff backgrounds and clear matching gutter fills, allowing `dali` and `davinci` to show diffs without background fills. - Restrict Windows native palette fallback to `ConsoleWindowClass` so a ConPTY palette is not mistaken for the renderer's colors when OSC probing fails. ## Testing Add tests and snapshots for theme preview, selection, cancellation, custom-theme precedence, invalid-file warnings, accent color depth, and disabling individual diff fills. GitOrigin-RevId: 72f3ef08f82626436dd2d80d40210cc77e1a5616 --- codex-rs/tui/assets/themes/ada.tmTheme | 53 ++++++++ codex-rs/tui/assets/themes/babbage.tmTheme | 53 ++++++++ codex-rs/tui/assets/themes/curie.tmTheme | 53 ++++++++ codex-rs/tui/assets/themes/cushman.tmTheme | 53 ++++++++ codex-rs/tui/assets/themes/dali.tmTheme | 53 ++++++++ codex-rs/tui/assets/themes/davinci.tmTheme | 53 ++++++++ codex-rs/tui/src/diff_render.rs | 83 +++++++++---- codex-rs/tui/src/render/highlight.rs | 114 ++++++++++-------- .../tui/src/render/highlight_model_tests.rs | 52 ++++++++ codex-rs/tui/src/render/mod.rs | 1 + codex-rs/tui/src/render/model_themes.rs | 28 +++++ ...heme_files_preview_select_and_restore.snap | 47 ++++++++ codex-rs/tui/src/style.rs | 48 ++++++++ codex-rs/tui/src/terminal_probe/windows.rs | 22 +++- codex-rs/tui/src/theme_picker.rs | 4 + codex-rs/tui/src/theme_picker_model_tests.rs | 91 ++++++++++++++ 16 files changed, 737 insertions(+), 71 deletions(-) create mode 100644 codex-rs/tui/assets/themes/ada.tmTheme create mode 100644 codex-rs/tui/assets/themes/babbage.tmTheme create mode 100644 codex-rs/tui/assets/themes/curie.tmTheme create mode 100644 codex-rs/tui/assets/themes/cushman.tmTheme create mode 100644 codex-rs/tui/assets/themes/dali.tmTheme create mode 100644 codex-rs/tui/assets/themes/davinci.tmTheme create mode 100644 codex-rs/tui/src/render/highlight_model_tests.rs create mode 100644 codex-rs/tui/src/render/model_themes.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__theme_picker__model_tests__model_theme_files_preview_select_and_restore.snap create mode 100644 codex-rs/tui/src/theme_picker_model_tests.rs diff --git a/codex-rs/tui/assets/themes/ada.tmTheme b/codex-rs/tui/assets/themes/ada.tmTheme new file mode 100644 index 0000000000..6f92da95da --- /dev/null +++ b/codex-rs/tui/assets/themes/ada.tmTheme @@ -0,0 +1,53 @@ + + + + + nameAda + settings + + settingsforeground#D0D0D0 + + scopecodex.accent + settingsforeground#5FAFFF + + + scopecomment, punctuation.definition.comment + settingsforeground#808080 + + + scopestring, punctuation.definition.string + settingsforeground#87FF87 + + + scopekeyword, storage, markup.heading + settingsforeground#5FAFFF + + + scopesupport.type, support.class, support.variable, entity.name.type + settingsforeground#5FD7FF + + + scopeconstant, constant.numeric + settingsforeground#AF87FF + + + scopepunctuation, keyword.operator + settingsforeground#BCBCBC + + + scopemarkup.inserted + settings + foreground#00AF00 + background#213A2B + + + + scopemarkup.deleted + settings + foreground#FF5F5F + background#4A221D + + + + + diff --git a/codex-rs/tui/assets/themes/babbage.tmTheme b/codex-rs/tui/assets/themes/babbage.tmTheme new file mode 100644 index 0000000000..b6663b9a65 --- /dev/null +++ b/codex-rs/tui/assets/themes/babbage.tmTheme @@ -0,0 +1,53 @@ + + + + + nameBabbage + settings + + settingsforeground#444444 + + scopecodex.accent + settingsforeground#008700 + + + scopecomment, punctuation.definition.comment + settingsforeground#626262 + + + scopestring, punctuation.definition.string + settingsforeground#008700 + + + scopekeyword, storage, markup.heading + settingsforeground#008700 + + + scopesupport.type, support.class, support.variable, entity.name.type + settingsforeground#0087AF + + + scopeconstant, constant.numeric + settingsforeground#870087 + + + scopepunctuation, keyword.operator + settingsforeground#585858 + + + scopemarkup.inserted + settings + foreground#00AF00 + background#DAFBE1 + + + + scopemarkup.deleted + settings + foreground#FF5F5F + background#FFEBE9 + + + + + diff --git a/codex-rs/tui/assets/themes/curie.tmTheme b/codex-rs/tui/assets/themes/curie.tmTheme new file mode 100644 index 0000000000..ad9975f362 --- /dev/null +++ b/codex-rs/tui/assets/themes/curie.tmTheme @@ -0,0 +1,53 @@ + + + + + nameCurie + settings + + settingsforeground#D0D0D0 + + scopecodex.accent + settingsforeground#D7AF00 + + + scopecomment, punctuation.definition.comment + settingsforeground#808080 + + + scopestring, punctuation.definition.string + settingsforeground#FFAF5F + + + scopekeyword, storage, markup.heading + settingsforeground#D7AF00 + + + scopesupport.type, support.class, support.variable, entity.name.type + settingsforeground#87AFD7 + + + scopeconstant, constant.numeric + settingsforeground#D7AF5F + + + scopepunctuation, keyword.operator + settingsforeground#BCBCBC + + + scopemarkup.inserted + settings + foreground#00AF00 + background#213A2B + + + + scopemarkup.deleted + settings + foreground#FF5F5F + background#4A221D + + + + + diff --git a/codex-rs/tui/assets/themes/cushman.tmTheme b/codex-rs/tui/assets/themes/cushman.tmTheme new file mode 100644 index 0000000000..6ae0188668 --- /dev/null +++ b/codex-rs/tui/assets/themes/cushman.tmTheme @@ -0,0 +1,53 @@ + + + + + nameCushman + settings + + settingsforeground#444444 + + scopecodex.accent + settingsforeground#0087FF + + + scopecomment, punctuation.definition.comment + settingsforeground#626262 + + + scopestring, punctuation.definition.string + settingsforeground#008700 + + + scopekeyword, storage, markup.heading + settingsforeground#005FAF + + + scopesupport.type, support.class, support.variable, entity.name.type + settingsforeground#0087AF + + + scopeconstant, constant.numeric + settingsforeground#870087 + + + scopepunctuation, keyword.operator + settingsforeground#585858 + + + scopemarkup.inserted + settings + foreground#00AF00 + background#DAFBE1 + + + + scopemarkup.deleted + settings + foreground#FF5F5F + background#FFEBE9 + + + + + diff --git a/codex-rs/tui/assets/themes/dali.tmTheme b/codex-rs/tui/assets/themes/dali.tmTheme new file mode 100644 index 0000000000..9bd4cceafa --- /dev/null +++ b/codex-rs/tui/assets/themes/dali.tmTheme @@ -0,0 +1,53 @@ + + + + + nameDali + settings + + settingsforeground#D0D0D0 + + scopecodex.accent + settingsforeground#D7AF00 + + + scopecomment, punctuation.definition.comment + settingsforeground#808080 + + + scopestring, punctuation.definition.string + settingsforeground#FF87FF + + + scopekeyword, storage, markup.heading + settingsforeground#D7AF00 + + + scopesupport.type, support.class, support.variable, entity.name.type + settingsforeground#5FD7FF + + + scopeconstant, constant.numeric + settingsforeground#FF5FFF + + + scopepunctuation, keyword.operator + settingsforeground#BCBCBC + + + scopemarkup.inserted + settings + foreground#00AF00 + background#00000001 + + + + scopemarkup.deleted + settings + foreground#FF5F5F + background#00000001 + + + + + diff --git a/codex-rs/tui/assets/themes/davinci.tmTheme b/codex-rs/tui/assets/themes/davinci.tmTheme new file mode 100644 index 0000000000..38c20d6a96 --- /dev/null +++ b/codex-rs/tui/assets/themes/davinci.tmTheme @@ -0,0 +1,53 @@ + + + + + nameDavinci + settings + + settingsforeground#444444 + + scopecodex.accent + settingsforeground#D75F00 + + + scopecomment, punctuation.definition.comment + settingsforeground#626262 + + + scopestring, punctuation.definition.string + settingsforeground#AF5F00 + + + scopekeyword, storage, markup.heading + settingsforeground#AF5F00 + + + scopesupport.type, support.class, support.variable, entity.name.type + settingsforeground#005FAF + + + scopeconstant, constant.numeric + settingsforeground#AF8700 + + + scopepunctuation, keyword.operator + settingsforeground#585858 + + + scopemarkup.inserted + settings + foreground#00AF00 + background#00000001 + + + + scopemarkup.deleted + settings + foreground#FF5F5F + background#00000001 + + + + + diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index f9e3b034a1..ef7a8a4249 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -82,8 +82,9 @@ use crate::color::perceptual_distance; use crate::diff_model::FileChange; use crate::exec_command::relativize_to_home; use crate::render::Insets; -use crate::render::highlight::DiffScopeBackgroundRgbs; -use crate::render::highlight::diff_scope_background_rgbs; +use crate::render::highlight::DiffScopeBackground; +use crate::render::highlight::DiffScopeBackgrounds; +use crate::render::highlight::diff_scope_backgrounds; use crate::render::highlight::exceeds_highlight_limits; use crate::render::highlight::highlight_code_to_styled_spans; use crate::render::line_utils::prefix_lines; @@ -202,7 +203,7 @@ fn resolve_diff_backgrounds( theme: DiffTheme, color_level: DiffColorLevel, ) -> ResolvedDiffBackgrounds { - resolve_diff_backgrounds_for(theme, color_level, diff_scope_background_rgbs()) + resolve_diff_backgrounds_for(theme, color_level, diff_scope_backgrounds()) } /// Snapshot the current terminal environment into a reusable style context. @@ -230,22 +231,28 @@ pub(crate) fn current_diff_render_style_context() -> DiffRenderStyleContext { /// Starts from the hardcoded fallback palette and then overrides with theme /// scope backgrounds when both (a) the color level is rich enough and (b) the /// theme defines a matching scope. This means the fallback palette is always -/// the baseline and theme scopes are strictly additive. +/// the baseline. An explicit terminal-default marker disables that scope's fill. fn resolve_diff_backgrounds_for( theme: DiffTheme, color_level: DiffColorLevel, - scope_backgrounds: DiffScopeBackgroundRgbs, + scope_backgrounds: DiffScopeBackgrounds, ) -> ResolvedDiffBackgrounds { let mut resolved = fallback_diff_backgrounds(theme, color_level); let Some(level) = RichDiffColorLevel::from_diff_color_level(color_level) else { return resolved; }; - if let Some(rgb) = scope_backgrounds.inserted { - resolved.add = Some(color_from_rgb_for_level(rgb, level)); - } - if let Some(rgb) = scope_backgrounds.deleted { - resolved.del = Some(color_from_rgb_for_level(rgb, level)); + for (target, background) in [ + (&mut resolved.add, scope_backgrounds.inserted), + (&mut resolved.del, scope_backgrounds.deleted), + ] { + match background { + Some(DiffScopeBackground::Rgb(rgb)) => { + *target = Some(color_from_rgb_for_level(rgb, level)) + } + Some(DiffScopeBackground::TerminalDefault) => *target = None, + None => {} + } } resolved } @@ -878,7 +885,10 @@ fn push_wrapped_diff_line_inner_with_theme_and_color_level( }; let line_bg = style_line_bg_for(kind, diff_backgrounds); - let gutter_style = style_gutter_for(kind, theme, color_level); + let mut gutter_style = style_gutter_for(kind, theme, color_level); + if line_bg.bg.is_none() { + gutter_style.bg = None; + } // When we have syntax spans, compose them with the diff style for a richer // view. The sign character keeps the diff color; content gets syntax colors @@ -1939,9 +1949,9 @@ mod tests { let backgrounds = resolve_diff_backgrounds_for( DiffTheme::Dark, DiffColorLevel::TrueColor, - DiffScopeBackgroundRgbs { - inserted: Some((1, 2, 3)), - deleted: Some((4, 5, 6)), + DiffScopeBackgrounds { + inserted: Some(DiffScopeBackground::Rgb((1, 2, 3))), + deleted: Some(DiffScopeBackground::Rgb((4, 5, 6))), }, ); assert_eq!( @@ -1954,13 +1964,44 @@ mod tests { ); } + #[test] + fn explicit_default_scope_disables_only_its_diff_fill() { + for theme in [DiffTheme::Light, DiffTheme::Dark] { + for color_level in [ + DiffColorLevel::TrueColor, + DiffColorLevel::Ansi256, + DiffColorLevel::Ansi16, + ] { + let backgrounds = resolve_diff_backgrounds_for( + theme, + color_level, + DiffScopeBackgrounds { + inserted: Some(DiffScopeBackground::TerminalDefault), + deleted: None, + }, + ); + assert_eq!( + style_line_bg_for(DiffLineType::Insert, backgrounds), + Style::default() + ); + assert_eq!( + style_line_bg_for(DiffLineType::Delete, backgrounds), + style_line_bg_for( + DiffLineType::Delete, + fallback_diff_backgrounds(theme, color_level) + ) + ); + } + } + } + #[test] fn theme_scope_backgrounds_quantize_to_ansi256() { let backgrounds = resolve_diff_backgrounds_for( DiffTheme::Dark, DiffColorLevel::Ansi256, - DiffScopeBackgroundRgbs { - inserted: Some((0, 95, 0)), + DiffScopeBackgrounds { + inserted: Some(DiffScopeBackground::Rgb((0, 95, 0))), deleted: None, }, ); @@ -1979,8 +2020,8 @@ mod tests { let backgrounds = resolve_diff_backgrounds_for( DiffTheme::Dark, DiffColorLevel::TrueColor, - DiffScopeBackgroundRgbs { - inserted: Some((12, 34, 56)), + DiffScopeBackgrounds { + inserted: Some(DiffScopeBackground::Rgb((12, 34, 56))), deleted: None, }, ); @@ -2027,9 +2068,9 @@ mod tests { let themed_backgrounds = resolve_diff_backgrounds_for( DiffTheme::Light, DiffColorLevel::Ansi16, - DiffScopeBackgroundRgbs { - inserted: Some((8, 9, 10)), - deleted: Some((11, 12, 13)), + DiffScopeBackgrounds { + inserted: Some(DiffScopeBackground::Rgb((8, 9, 10))), + deleted: Some(DiffScopeBackground::Rgb((11, 12, 13))), }, ); assert_eq!( diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index 59ade8c1a8..f78e45db01 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -1,7 +1,7 @@ //! Syntax highlighting engine for the TUI. //! //! Wraps [syntect] with the [two_face] grammar and theme bundles to provide -//! ~250-language syntax highlighting and 32 bundled color themes. The module +//! ~250-language syntax highlighting and bundled color themes. The module //! owns five process-global singletons: //! //! | Singleton | Type | Purpose | @@ -24,6 +24,7 @@ //! prevent pathological CPU/memory usage. Callers must fall back to plain //! unstyled text. +use super::model_themes; use ratatui::style::Color as RtColor; use ratatui::style::Modifier; use ratatui::style::Style; @@ -132,18 +133,14 @@ pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>) let custom_theme_path_display = codex_home .map(|home| custom_theme_path(name, home).display().to_string()) .unwrap_or_else(|| format!("$CODEX_HOME/themes/{name}.tmTheme")); - // Bundled themes always resolve. - if parse_theme_name(name).is_some() { + if resolve_theme_by_name(name, codex_home).is_some() { return None; } // Custom themes must parse successfully; an unreadable/invalid file should // still surface a startup warning so users can diagnose configuration issues. if let Some(home) = codex_home { let custom_path = custom_theme_path(name, home); - if custom_path.is_file() { - if load_custom_theme(name, home).is_some() { - return None; - } + if custom_path.try_exists().unwrap_or(true) { return Some(format!( "Custom theme \"{name}\" at {custom_theme_path_display} could not \ be loaded (invalid .tmTheme format). Falling back to the default theme." @@ -232,14 +229,7 @@ fn resolve_theme_with_override(name: Option<&str>, codex_home: Option<&Path>) -> // Honor user-configured theme if valid. if let Some(name) = name { - // 1. Try bundled theme by kebab-case name. - if let Some(theme_name) = parse_theme_name(name) { - return ts.get(theme_name).clone(); - } - // 2. Try loading {CODEX_HOME}/themes/{name}.tmTheme from disk. - if let Some(home) = codex_home - && let Some(theme) = load_custom_theme(name, home) - { + if let Some(theme) = resolve_theme_by_name(name, codex_home) { return theme; } tracing::debug!("Theme \"{name}\" not recognized; using default theme"); @@ -304,7 +294,14 @@ pub(crate) fn current_syntax_theme() -> Theme { } } -/// Raw RGB background colors extracted from syntax theme diff/markup scopes. +/// An explicit diff background, including the ANSI terminal-default marker. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiffScopeBackground { + Rgb((u8, u8, u8)), + TerminalDefault, +} + +/// Background colors extracted from syntax theme diff/markup scopes. /// /// These are theme-provided colors, not yet adapted for any particular color /// depth. [`diff_render`](crate::diff_render) converts them to ratatui @@ -315,9 +312,9 @@ pub(crate) fn current_syntax_theme() -> Theme { /// backgrounds, in which case the diff renderer falls back to its hardcoded /// palette. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) struct DiffScopeBackgroundRgbs { - pub inserted: Option<(u8, u8, u8)>, - pub deleted: Option<(u8, u8, u8)>, +pub(crate) struct DiffScopeBackgrounds { + pub inserted: Option, + pub deleted: Option, } /// Query the active syntax theme for diff-scope background colors. @@ -325,27 +322,34 @@ pub(crate) struct DiffScopeBackgroundRgbs { /// Prefers `markup.inserted` / `markup.deleted` (the TextMate convention used /// by most VS Code themes) and falls back to `diff.inserted` / `diff.deleted` /// (used by some older `.tmTheme` files). -pub(crate) fn diff_scope_background_rgbs() -> DiffScopeBackgroundRgbs { +pub(crate) fn diff_scope_backgrounds() -> DiffScopeBackgrounds { let theme = current_syntax_theme(); - diff_scope_background_rgbs_for_theme(&theme) + diff_scope_backgrounds_for_theme(&theme) } /// Pure extraction helper, separated from the global theme singleton so tests /// can pass arbitrary themes. -fn diff_scope_background_rgbs_for_theme(theme: &Theme) -> DiffScopeBackgroundRgbs { +fn diff_scope_backgrounds_for_theme(theme: &Theme) -> DiffScopeBackgrounds { let highlighter = Highlighter::new(theme); - let inserted = scope_background_rgb(&highlighter, "markup.inserted") - .or_else(|| scope_background_rgb(&highlighter, "diff.inserted")); - let deleted = scope_background_rgb(&highlighter, "markup.deleted") - .or_else(|| scope_background_rgb(&highlighter, "diff.deleted")); - DiffScopeBackgroundRgbs { inserted, deleted } + let inserted = scope_background(&highlighter, "markup.inserted") + .or_else(|| scope_background(&highlighter, "diff.inserted")); + let deleted = scope_background(&highlighter, "markup.deleted") + .or_else(|| scope_background(&highlighter, "diff.deleted")); + DiffScopeBackgrounds { inserted, deleted } } /// Extract the background color for a single TextMate scope, if defined. -fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Option<(u8, u8, u8)> { +fn scope_background( + highlighter: &Highlighter<'_>, + scope_name: &str, +) -> Option { let scope = Scope::new(scope_name).ok()?; let bg = highlighter.style_mod_for_stack(&[scope]).background?; - Some((bg.r, bg.g, bg.b)) + Some(if bg.a == ANSI_ALPHA_DEFAULT { + DiffScopeBackground::TerminalDefault + } else { + DiffScopeBackground::Rgb((bg.r, bg.g, bg.b)) + }) } /// Query the active syntax theme for the first foreground style provided by the @@ -375,12 +379,8 @@ pub(crate) fn foreground_style_for_scopes_with_theme( pub(crate) fn configured_theme_name() -> String { // Explicit user override? if let Some(Some(name)) = THEME_OVERRIDE.get() { - if parse_theme_name(name).is_some() { - return name.clone(); - } - if let Some(Some(home)) = CODEX_HOME.get() - && load_custom_theme(name, home).is_some() - { + let home = CODEX_HOME.get().and_then(|home| home.as_deref()); + if resolve_theme_by_name(name, home).is_some() { return name.clone(); } } @@ -397,11 +397,11 @@ pub(crate) fn resolve_theme_by_name(name: &str, codex_home: Option<&Path>) -> Op } // Custom .tmTheme file? if let Some(home) = codex_home - && let Some(theme) = load_custom_theme(name, home) + && custom_theme_path(name, home).try_exists().unwrap_or(true) { - return Some(theme); + return load_custom_theme(name, home); } - None + model_themes::resolve(name) } /// A theme available in the picker, either bundled or loaded from a custom @@ -447,6 +447,18 @@ pub(crate) fn list_available_themes(codex_home: Option<&Path>) -> Vec>(); + assert_eq!(entries.len(), 1); + assert!(entries[0].is_custom); + + let uppercase_path = custom_theme_path("Ada", home.path()); + std::fs::rename(&path, &uppercase_path).unwrap(); + let entries = list_available_themes(Some(home.path())); + let lowercase = entries.iter().find(|entry| entry.name == "ada").unwrap(); + assert_eq!(lowercase.is_custom, path.exists()); + assert!( + entries + .iter() + .any(|entry| entry.name == "Ada" && entry.is_custom) + ); + std::fs::rename(&uppercase_path, &path).unwrap(); + + std::fs::write(&path, "invalid theme").unwrap(); + assert!( + validate_theme_name(Some("ada"), Some(home.path())) + .unwrap() + .contains("could not be loaded") + ); + assert!(resolve_theme_by_name("ada", Some(home.path())).is_none()); + assert_eq!( + resolve_theme_with_override(Some("ada"), Some(home.path())), + resolve_theme_with_override(/*name*/ None, Some(home.path())) + ); + assert!( + !list_available_themes(Some(home.path())) + .iter() + .any(|entry| entry.name == "ada") + ); +} diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index 02c19fc9f7..53ab199d99 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -2,6 +2,7 @@ use ratatui::layout::Rect; pub(crate) mod highlight; pub(crate) mod line_utils; +mod model_themes; pub(crate) mod renderable; #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/codex-rs/tui/src/render/model_themes.rs b/codex-rs/tui/src/render/model_themes.rs new file mode 100644 index 0000000000..971641e7ce --- /dev/null +++ b/codex-rs/tui/src/render/model_themes.rs @@ -0,0 +1,28 @@ +//! Fixed model-named theme assets loaded through the standard TextMate parser. + +use std::io::Cursor; +use syntect::highlighting::Theme; +use syntect::highlighting::ThemeSet; + +pub(super) const THEMES: &[(&str, &str)] = &[ + ("ada", include_str!("../../assets/themes/ada.tmTheme")), + ( + "babbage", + include_str!("../../assets/themes/babbage.tmTheme"), + ), + ("curie", include_str!("../../assets/themes/curie.tmTheme")), + ( + "cushman", + include_str!("../../assets/themes/cushman.tmTheme"), + ), + ("dali", include_str!("../../assets/themes/dali.tmTheme")), + ( + "davinci", + include_str!("../../assets/themes/davinci.tmTheme"), + ), +]; + +pub(super) fn resolve(name: &str) -> Option { + let (_, source) = THEMES.iter().find(|(key, _)| *key == name)?; + ThemeSet::load_from_reader(&mut Cursor::new(source.as_bytes())).ok() +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__theme_picker__model_tests__model_theme_files_preview_select_and_restore.snap b/codex-rs/tui/src/snapshots/codex_tui__theme_picker__model_tests__model_theme_files_preview_select_and_restore.snap new file mode 100644 index 0000000000..779bd7afcc --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__theme_picker__model_tests__model_theme_files_preview_select_and_restore.snap @@ -0,0 +1,47 @@ +--- +source: tui/src/theme_picker_model_tests.rs +assertion_line: 64 +expression: snapshot +--- +ada: accent=Style::new().fg(Color::Rgb(95, 175, 255)).bold() +12 fn greet(name: &str) -> String { +13 - format!("Hello, {}!", name) +13 + format!("Hello, {name}!") +14 } +preview styles: {"Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Reset).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Reset).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(135, 255, 135)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(135, 255, 135)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(175, 135, 255)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(175, 135, 255)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(95, 175, 255)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().green().bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().red().bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset)"} +syntax: [Line::from_iter([Span::from("let").fg(Color::Rgb(95, 175, 255)), Span::from(" answer ").fg(Color::Rgb(208, 208, 208)), Span::from("=").fg(Color::Rgb(188, 188, 188)), Span::from(" ").fg(Color::Rgb(208, 208, 208)), Span::from("42").fg(Color::Rgb(175, 135, 255)), Span::from(";").fg(Color::Rgb(188, 188, 188))])] +babbage: accent=Style::new().fg(Color::Rgb(0, 135, 0)).bold() +12 fn greet(name: &str) -> String { +13 - format!("Hello, {}!", name) +13 + format!("Hello, {name}!") +14 } +preview styles: {"Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Reset).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(0, 135, 0)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(0, 135, 0)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(0, 135, 0)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(135, 0, 135)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(135, 0, 135)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(31, 35, 40)).bg(Color::Rgb(172, 238, 187)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(31, 35, 40)).bg(Color::Rgb(255, 206, 203)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().green().bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().red().bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset)"} +syntax: [Line::from_iter([Span::from("let").fg(Color::Rgb(0, 135, 0)), Span::from(" answer ").fg(Color::Rgb(68, 68, 68)), Span::from("=").fg(Color::Rgb(88, 88, 88)), Span::from(" ").fg(Color::Rgb(68, 68, 68)), Span::from("42").fg(Color::Rgb(135, 0, 135)), Span::from(";").fg(Color::Rgb(88, 88, 88))])] +curie: accent=Style::new().fg(Color::Rgb(215, 175, 0)).bold() +12 fn greet(name: &str) -> String { +13 - format!("Hello, {}!", name) +13 + format!("Hello, {name}!") +14 } +preview styles: {"Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Reset).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Reset).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(215, 175, 0)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(215, 175, 95)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(215, 175, 95)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(255, 175, 95)).bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(255, 175, 95)).bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset).dim()", "Style::new().green().bg(Color::Rgb(33, 58, 43)).underline_color(Color::Reset)", "Style::new().red().bg(Color::Rgb(74, 34, 29)).underline_color(Color::Reset)"} +syntax: [Line::from_iter([Span::from("let").fg(Color::Rgb(215, 175, 0)), Span::from(" answer ").fg(Color::Rgb(208, 208, 208)), Span::from("=").fg(Color::Rgb(188, 188, 188)), Span::from(" ").fg(Color::Rgb(208, 208, 208)), Span::from("42").fg(Color::Rgb(215, 175, 95)), Span::from(";").fg(Color::Rgb(188, 188, 188))])] +cushman: accent=Style::new().fg(Color::Rgb(0, 135, 255)).bold() +12 fn greet(name: &str) -> String { +13 - format!("Hello, {}!", name) +13 + format!("Hello, {name}!") +14 } +preview styles: {"Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Reset).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(0, 135, 0)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(0, 135, 0)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(0, 95, 175)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(135, 0, 135)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(135, 0, 135)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(31, 35, 40)).bg(Color::Rgb(172, 238, 187)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(31, 35, 40)).bg(Color::Rgb(255, 206, 203)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset).dim()", "Style::new().green().bg(Color::Rgb(218, 251, 225)).underline_color(Color::Reset)", "Style::new().red().bg(Color::Rgb(255, 235, 233)).underline_color(Color::Reset)"} +syntax: [Line::from_iter([Span::from("let").fg(Color::Rgb(0, 95, 175)), Span::from(" answer ").fg(Color::Rgb(68, 68, 68)), Span::from("=").fg(Color::Rgb(88, 88, 88)), Span::from(" ").fg(Color::Rgb(68, 68, 68)), Span::from("42").fg(Color::Rgb(135, 0, 135)), Span::from(";").fg(Color::Rgb(88, 88, 88))])] +dali: accent=Style::new().fg(Color::Rgb(215, 175, 0)).bold() +12 fn greet(name: &str) -> String { +13 - format!("Hello, {}!", name) +13 + format!("Hello, {name}!") +14 } +preview styles: {"Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(188, 188, 188)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(208, 208, 208)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(215, 175, 0)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(255, 135, 255)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(255, 135, 255)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(255, 95, 255)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(255, 95, 255)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().green().bg(Color::Reset).underline_color(Color::Reset)", "Style::new().red().bg(Color::Reset).underline_color(Color::Reset)"} +syntax: [Line::from_iter([Span::from("let").fg(Color::Rgb(215, 175, 0)), Span::from(" answer ").fg(Color::Rgb(208, 208, 208)), Span::from("=").fg(Color::Rgb(188, 188, 188)), Span::from(" ").fg(Color::Rgb(208, 208, 208)), Span::from("42").fg(Color::Rgb(255, 95, 255)), Span::from(";").fg(Color::Rgb(188, 188, 188))])] +davinci: accent=Style::new().fg(Color::Rgb(215, 95, 0)).bold() +12 fn greet(name: &str) -> String { +13 - format!("Hello, {}!", name) +13 + format!("Hello, {name}!") +14 } +preview styles: {"Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(175, 135, 0)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(175, 135, 0)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(175, 95, 0)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(175, 95, 0)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(31, 35, 40)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(68, 68, 68)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Reset).underline_color(Color::Reset)", "Style::new().fg(Color::Rgb(88, 88, 88)).bg(Color::Reset).underline_color(Color::Reset).dim()", "Style::new().green().bg(Color::Reset).underline_color(Color::Reset)", "Style::new().red().bg(Color::Reset).underline_color(Color::Reset)"} +syntax: [Line::from_iter([Span::from("let").fg(Color::Rgb(175, 95, 0)), Span::from(" answer ").fg(Color::Rgb(68, 68, 68)), Span::from("=").fg(Color::Rgb(88, 88, 88)), Span::from(" ").fg(Color::Rgb(68, 68, 68)), Span::from("42").fg(Color::Rgb(175, 135, 0)), Span::from(";").fg(Color::Rgb(88, 88, 88))])] diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 99a73757d3..47279c16be 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -58,6 +58,17 @@ pub(crate) fn table_separator_style() -> Style { /// Returns the shared accent style for active or selected TUI controls. pub(crate) fn accent_style() -> Style { + if matches!( + effective_stdout_color_level(), + StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256 + ) && let Some(mut style) = + crate::render::highlight::foreground_style_for_scopes(&["codex.accent"]) + { + if let Some(Color::Rgb(r, g, b)) = style.fg { + style = style.fg(best_color((r, g, b))); + } + return style.bold(); + } accent_style_for(default_bg()) } @@ -190,6 +201,43 @@ mod tests { } } + #[test] + fn theme_accents_respect_terminal_color_depth() { + const CHILD: &str = "CODEX_ACCENT_COLOR_TEST_CHILD"; + let Ok(level) = std::env::var(CHILD) else { + for level in ["0", "1", "2", "3"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "style::tests::theme_accents_respect_terminal_color_depth", + ]) + .env(CHILD, level) + .env("FORCE_COLOR", level) + .output() + .unwrap(); + assert!( + output.status.success(), + "level {level}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + }; + let theme = + crate::render::highlight::resolve_theme_by_name("ada", /*codex_home*/ None).unwrap(); + crate::render::highlight::set_syntax_theme(theme); + let expected = match level.as_str() { + "3" => Style::default().fg(rgb_color((95, 175, 255))).bold(), + "2" => Style::default() + .fg(crate::terminal_palette::indexed_color(/*index*/ 75)) + .bold(), + "0" | "1" => accent_style_for(default_bg()), + _ => unreachable!(), + }; + assert_eq!(accent_style(), expected); + } + #[test] fn accent_style_uses_darker_cyan_on_light_backgrounds() { let style = accent_style_for(Some((255, 255, 255))); diff --git a/codex-rs/tui/src/terminal_probe/windows.rs b/codex-rs/tui/src/terminal_probe/windows.rs index bbaafcf274..b47e8a3b98 100644 --- a/codex-rs/tui/src/terminal_probe/windows.rs +++ b/codex-rs/tui/src/terminal_probe/windows.rs @@ -14,6 +14,7 @@ use windows_sys::Win32::Foundation::WAIT_TIMEOUT; use windows_sys::Win32::Storage::FileSystem::WriteFile; use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; +use windows_sys::Win32::System::Console::GetConsoleWindow; use windows_sys::Win32::System::Console::GetNumberOfConsoleInputEvents; use windows_sys::Win32::System::Console::GetStdHandle; use windows_sys::Win32::System::Console::INPUT_RECORD; @@ -23,11 +24,12 @@ use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; use windows_sys::Win32::System::Console::WriteConsoleInputW; use windows_sys::Win32::System::Threading::WaitForSingleObject; +use windows_sys::Win32::UI::WindowsAndMessaging::GetClassNameW; const MAX_WINDOWS_PROBE_RECORDS: usize = 64 * 1_024; const WINDOWS_PROBE_READ_RECORDS: usize = 64; -/// Prefer the terminal renderer's OSC colors while retaining the native console palette fallback. +/// Prefer OSC colors; use native colors only when the renderer shares the console palette. /// /// Windows Terminal and xterm.js can render a theme unrelated to ConPTY's console color table. /// Their OSC responses share the input queue with user input, so the probe preserves and restores @@ -48,6 +50,24 @@ pub(crate) fn default_colors(timeout: Duration) -> io::Result>(); + snapshot.push_str(&format!("preview styles: {styles:?}\nsyntax: {lines:?}\n")); + params.items[idx].actions[0](&tx); + assert!( + matches!(rx.try_recv().unwrap(), AppEvent::SyntaxThemeSelected { name: selected } if selected == name) + ); + params.on_cancel.as_ref().unwrap()(&tx); + assert!(matches!( + rx.try_recv().unwrap(), + AppEvent::SyntaxThemePreviewed + )); + assert_eq!(highlight::current_syntax_theme(), original); + }); + } + insta::assert_snapshot!(snapshot); +}