From 82fc47e317ab3045ce7abd43bc44d060669d47e1 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 8 Feb 2026 22:56:17 -0300 Subject: [PATCH] feat(tui): add `theme` config setting, suppress underline from themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `[tui] theme = "..."` config option that lets users override the auto-detected syntax highlighting theme with any of the 32 bundled two-face themes by kebab-case name. Invalid names log a warning and fall back to adaptive detection. Also suppress FontStyle::UNDERLINE in convert_style — themes like Dracula mark type scopes with underline which produces distracting underlines on type/module names in terminal output. --- codex-rs/core/config.schema.json | 5 + codex-rs/core/src/config/mod.rs | 33 +++++ codex-rs/core/src/config/types.rs | 7 + codex-rs/tui/src/lib.rs | 3 + codex-rs/tui/src/render/highlight.rs | 207 ++++++++++++++++++++++++++- 5 files changed, 251 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index addbcbe313..1115d00da7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1400,6 +1400,11 @@ "type": "string" }, "type": "array" + }, + "theme": { + "default": null, + "description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Run with `--list-themes` or see docs for available names.", + "type": "string" } }, "type": "object" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ddc5aa9cdd..6474ca4bc0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -278,6 +278,9 @@ pub struct Config { /// `current-dir`. pub tui_status_line: Option>, + /// Syntax highlighting theme override (kebab-case name). + pub tui_theme: Option, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -2120,6 +2123,7 @@ impl Config { .map(|t| t.alternate_screen) .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), + tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2518,6 +2522,30 @@ allowed_domains = ["openai.com"] Ok(()) } + #[test] + fn tui_theme_deserializes_from_toml() { + let cfg = r#" +[tui] +theme = "dracula" +"#; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), + Some("dracula"), + ); + } + + #[test] + fn tui_theme_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); + } + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2537,6 +2565,7 @@ allowed_domains = ["openai.com"] show_tooltips: true, alternate_screen: AltScreenMode::Auto, status_line: None, + theme: None, } ); } @@ -4646,6 +4675,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }, o3_profile_config @@ -4768,6 +4798,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }; @@ -4888,6 +4919,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }; @@ -4994,6 +5026,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 2e3573f770..e5b5dfdd33 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -681,6 +681,13 @@ pub struct Tui { /// `current-dir`. #[serde(default)] pub status_line: Option>, + + /// Syntax highlighting theme name (kebab-case). + /// + /// When set, overrides automatic light/dark theme detection. + /// Run with `--list-themes` or see docs for available names. + #[serde(default)] + pub theme: Option, } const fn default_true() -> bool { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c44b5eb751..9b9caaff06 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -441,6 +441,9 @@ async fn run_ratatui_app( ) -> color_eyre::Result { color_eyre::install()?; + // Configure syntax highlighting theme before any rendering can occur. + crate::render::highlight::set_theme_override(initial_config.tui_theme.clone()); + tooltips::announcement::prewarm(); // Forward panic reports through tracing so they appear in the UI status diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index a102dcc291..039f56a5ef 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -17,15 +17,70 @@ use two_face::theme::EmbeddedThemeName; static SYNTAX_SET: OnceLock = OnceLock::new(); static THEME: OnceLock = OnceLock::new(); +static THEME_OVERRIDE: OnceLock> = OnceLock::new(); fn syntax_set() -> &'static SyntaxSet { SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) } +/// Set the user-configured theme override before any highlighting occurs. +/// Must be called at most once, before the first call to `theme()`. +pub(crate) fn set_theme_override(name: Option) { + let _ = THEME_OVERRIDE.set(name); +} + +/// Map a kebab-case theme name to the corresponding `EmbeddedThemeName`. +fn parse_theme_name(name: &str) -> Option { + match name { + "ansi" => Some(EmbeddedThemeName::Ansi), + "base16" => Some(EmbeddedThemeName::Base16), + "base16-eighties-dark" => Some(EmbeddedThemeName::Base16EightiesDark), + "base16-mocha-dark" => Some(EmbeddedThemeName::Base16MochaDark), + "base16-ocean-dark" => Some(EmbeddedThemeName::Base16OceanDark), + "base16-ocean-light" => Some(EmbeddedThemeName::Base16OceanLight), + "base16-256" => Some(EmbeddedThemeName::Base16_256), + "catppuccin-frappe" => Some(EmbeddedThemeName::CatppuccinFrappe), + "catppuccin-latte" => Some(EmbeddedThemeName::CatppuccinLatte), + "catppuccin-macchiato" => Some(EmbeddedThemeName::CatppuccinMacchiato), + "catppuccin-mocha" => Some(EmbeddedThemeName::CatppuccinMocha), + "coldark-cold" => Some(EmbeddedThemeName::ColdarkCold), + "coldark-dark" => Some(EmbeddedThemeName::ColdarkDark), + "dark-neon" => Some(EmbeddedThemeName::DarkNeon), + "dracula" => Some(EmbeddedThemeName::Dracula), + "github" => Some(EmbeddedThemeName::Github), + "gruvbox-dark" => Some(EmbeddedThemeName::GruvboxDark), + "gruvbox-light" => Some(EmbeddedThemeName::GruvboxLight), + "inspired-github" => Some(EmbeddedThemeName::InspiredGithub), + "1337" => Some(EmbeddedThemeName::Leet), + "monokai-extended" => Some(EmbeddedThemeName::MonokaiExtended), + "monokai-extended-bright" => Some(EmbeddedThemeName::MonokaiExtendedBright), + "monokai-extended-light" => Some(EmbeddedThemeName::MonokaiExtendedLight), + "monokai-extended-origin" => Some(EmbeddedThemeName::MonokaiExtendedOrigin), + "nord" => Some(EmbeddedThemeName::Nord), + "one-half-dark" => Some(EmbeddedThemeName::OneHalfDark), + "one-half-light" => Some(EmbeddedThemeName::OneHalfLight), + "solarized-dark" => Some(EmbeddedThemeName::SolarizedDark), + "solarized-light" => Some(EmbeddedThemeName::SolarizedLight), + "sublime-snazzy" => Some(EmbeddedThemeName::SublimeSnazzy), + "two-dark" => Some(EmbeddedThemeName::TwoDark), + "zenburn" => Some(EmbeddedThemeName::Zenburn), + _ => None, + } +} + fn theme() -> &'static Theme { THEME.get_or_init(|| { let ts = two_face::theme::extra(); - // Pick light or dark theme based on terminal background color. + + // Honor user-configured theme if valid. + if let Some(Some(name)) = THEME_OVERRIDE.get() { + if let Some(theme_name) = parse_theme_name(name) { + return ts.get(theme_name).clone(); + } + tracing::warn!("unknown syntax theme \"{name}\", falling back to auto-detection"); + } + + // Adaptive default: light or dark based on terminal background. let name = match crate::terminal_palette::default_bg() { Some(bg) if crate::color::is_light(bg) => EmbeddedThemeName::CatppuccinLatte, _ => EmbeddedThemeName::CatppuccinMocha, @@ -55,9 +110,9 @@ fn convert_style(syn_style: SyntectStyle) -> Style { rt_style.add_modifier |= Modifier::BOLD; } // Intentionally skip italic — many terminals render it poorly or not at all. - if syn_style.font_style.contains(FontStyle::UNDERLINE) { - rt_style.add_modifier |= Modifier::UNDERLINED; - } + // Intentionally skip underline — themes like Dracula use underline on type + // scopes (entity.name.type, support.class) which produces distracting + // underlines on type/module names in terminal output. rt_style } @@ -278,6 +333,36 @@ mod tests { assert!(!rt.add_modifier.contains(Modifier::UNDERLINED)); } + #[test] + #[allow(clippy::disallowed_methods)] + fn convert_style_suppresses_underline() { + // Dracula (and other themes) set FontStyle::UNDERLINE on type scopes, + // producing distracting underlines on type names in terminal output. + // convert_style must suppress underline, just like it suppresses italic. + let syn = SyntectStyle { + foreground: syntect::highlighting::Color { + r: 100, + g: 200, + b: 150, + a: 255, + }, + background: syntect::highlighting::Color { + r: 0, + g: 0, + b: 0, + a: 0, + }, + font_style: FontStyle::UNDERLINE, + }; + let rt = convert_style(syn); + assert!( + !rt.add_modifier.contains(Modifier::UNDERLINED), + "convert_style should suppress UNDERLINE from themes — \ + themes like Dracula use underline on type scopes which \ + looks wrong in terminal output" + ); + } + #[test] fn highlight_multiline_python() { let code = "def hello():\n print(\"hi\")\n return 42"; @@ -388,4 +473,118 @@ mod tests { ); } } + + #[test] + fn parse_theme_name_covers_all_variants() { + let known = [ + ("ansi", EmbeddedThemeName::Ansi), + ("base16", EmbeddedThemeName::Base16), + ("base16-eighties-dark", EmbeddedThemeName::Base16EightiesDark), + ("base16-mocha-dark", EmbeddedThemeName::Base16MochaDark), + ("base16-ocean-dark", EmbeddedThemeName::Base16OceanDark), + ("base16-ocean-light", EmbeddedThemeName::Base16OceanLight), + ("base16-256", EmbeddedThemeName::Base16_256), + ("catppuccin-frappe", EmbeddedThemeName::CatppuccinFrappe), + ("catppuccin-latte", EmbeddedThemeName::CatppuccinLatte), + ("catppuccin-macchiato", EmbeddedThemeName::CatppuccinMacchiato), + ("catppuccin-mocha", EmbeddedThemeName::CatppuccinMocha), + ("coldark-cold", EmbeddedThemeName::ColdarkCold), + ("coldark-dark", EmbeddedThemeName::ColdarkDark), + ("dark-neon", EmbeddedThemeName::DarkNeon), + ("dracula", EmbeddedThemeName::Dracula), + ("github", EmbeddedThemeName::Github), + ("gruvbox-dark", EmbeddedThemeName::GruvboxDark), + ("gruvbox-light", EmbeddedThemeName::GruvboxLight), + ("inspired-github", EmbeddedThemeName::InspiredGithub), + ("1337", EmbeddedThemeName::Leet), + ("monokai-extended", EmbeddedThemeName::MonokaiExtended), + ("monokai-extended-bright", EmbeddedThemeName::MonokaiExtendedBright), + ("monokai-extended-light", EmbeddedThemeName::MonokaiExtendedLight), + ("monokai-extended-origin", EmbeddedThemeName::MonokaiExtendedOrigin), + ("nord", EmbeddedThemeName::Nord), + ("one-half-dark", EmbeddedThemeName::OneHalfDark), + ("one-half-light", EmbeddedThemeName::OneHalfLight), + ("solarized-dark", EmbeddedThemeName::SolarizedDark), + ("solarized-light", EmbeddedThemeName::SolarizedLight), + ("sublime-snazzy", EmbeddedThemeName::SublimeSnazzy), + ("two-dark", EmbeddedThemeName::TwoDark), + ("zenburn", EmbeddedThemeName::Zenburn), + ]; + for (kebab, expected) in &known { + assert_eq!( + parse_theme_name(kebab), + Some(*expected), + "parse_theme_name({kebab:?}) did not return expected variant" + ); + } + } + + #[test] + fn parse_theme_name_returns_none_for_unknown() { + assert_eq!(parse_theme_name("nonexistent-theme"), None); + assert_eq!(parse_theme_name(""), None); + } + + #[test] + fn parse_theme_name_is_exhaustive() { + use two_face::theme::EmbeddedLazyThemeSet; + + // Every variant in the embedded set must be reachable via parse_theme_name. + let all_variants = EmbeddedLazyThemeSet::theme_names(); + + // Guard: if two-face adds themes, this test forces us to update the mapping. + assert_eq!( + all_variants.len(), + 32, + "two-face theme count changed — update parse_theme_name" + ); + + // Build the set of variants reachable through our kebab-case mapping. + let kebab_names = [ + "ansi", + "base16", + "base16-eighties-dark", + "base16-mocha-dark", + "base16-ocean-dark", + "base16-ocean-light", + "base16-256", + "catppuccin-frappe", + "catppuccin-latte", + "catppuccin-macchiato", + "catppuccin-mocha", + "coldark-cold", + "coldark-dark", + "dark-neon", + "dracula", + "github", + "gruvbox-dark", + "gruvbox-light", + "inspired-github", + "1337", + "monokai-extended", + "monokai-extended-bright", + "monokai-extended-light", + "monokai-extended-origin", + "nord", + "one-half-dark", + "one-half-light", + "solarized-dark", + "solarized-light", + "sublime-snazzy", + "two-dark", + "zenburn", + ]; + let mapped: Vec = kebab_names + .iter() + .map(|k| parse_theme_name(k).unwrap_or_else(|| panic!("unmapped kebab name: {k}"))) + .collect(); + + // Every variant from two-face must appear in our mapped set. + for variant in all_variants { + assert!( + mapped.contains(variant), + "EmbeddedThemeName::{variant:?} has no kebab-case mapping in parse_theme_name" + ); + } + } }