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