feat(tui): live theme override and dynamic CODEX_HOME paths

set_theme_override now updates the runtime theme immediately instead
of only setting the OnceLock, enabling live switching from /theme.
Replace hardcoded ~/.codex/themes references with resolved
$CODEX_HOME/themes paths in warnings, schema docs, and picker subtitle.
This commit is contained in:
Felipe Coury
2026-02-10 20:59:27 -03:00
parent 987db6aaf6
commit f72e7bf529
6 changed files with 51 additions and 19 deletions

View File

@@ -1403,7 +1403,7 @@
},
"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.",
"description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
"type": "string"
}
},

View File

@@ -685,7 +685,7 @@ pub struct Tui {
/// Syntax highlighting theme name (kebab-case).
///
/// When set, overrides automatic light/dark theme detection.
/// Run with `--list-themes` or see docs for available names.
/// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.
#[serde(default)]
pub theme: Option<String>,
}

View File

@@ -362,7 +362,9 @@ pub(crate) enum AppEvent {
StatusLineSetupCancelled,
/// Apply a user-confirmed syntax theme selection.
SyntaxThemeSelected { name: String },
SyntaxThemeSelected {
name: String,
},
}
/// The exit strategy requested by the UI layer.

View File

@@ -104,8 +104,8 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod theme_picker;
mod text_formatting;
mod theme_picker;
mod tooltips;
mod tui;
mod ui_consts;
@@ -1227,9 +1227,7 @@ trust_level = "untrusted"
// Theme override must use the final config (not initial_config).
// This mirrors the real call site in run_ratatui_app.
if let Some(w) =
validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path()))
{
if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) {
config.startup_warnings.push(w);
}

View File

@@ -39,9 +39,22 @@ pub(crate) fn set_theme_override(
name: Option<String>,
codex_home: Option<PathBuf>,
) -> Option<String> {
let warning = validate_theme_name(name.as_deref(), codex_home.as_deref());
let _ = THEME_OVERRIDE.set(name);
let _ = CODEX_HOME.set(codex_home);
let mut warning = validate_theme_name(name.as_deref(), codex_home.as_deref());
let override_set_ok = THEME_OVERRIDE.set(name.clone()).is_ok();
let codex_home_set_ok = CODEX_HOME.set(codex_home.clone()).is_ok();
if THEME.get().is_some() {
set_syntax_theme(resolve_theme_with_override(
name.as_deref(),
codex_home.as_deref(),
));
}
if !override_set_ok || !codex_home_set_ok {
let duplicate_msg = "Ignoring duplicate or late syntax theme override persistence; runtime theme was updated from the latest override, but persisted override config can only be initialized once.";
tracing::warn!("{duplicate_msg}");
if warning.is_none() {
warning = Some(duplicate_msg.to_string());
}
}
warning
}
@@ -49,6 +62,9 @@ pub(crate) fn set_theme_override(
/// `.tmTheme` file. Returns a user-facing warning when it does not.
pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>) -> Option<String> {
let name = name?;
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() {
return None;
@@ -62,7 +78,7 @@ pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>)
return None;
}
return Some(format!(
"Syntax theme \"{name}\" was found at ~/.codex/themes/{name}.tmTheme \
"Syntax theme \"{name}\" was found at {custom_theme_path_display} \
but could not be parsed. Falling back to auto-detection."
));
}
@@ -70,7 +86,7 @@ pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>)
Some(format!(
"Unknown syntax theme \"{name}\", falling back to auto-detection. \
Use a bundled name or place a .tmTheme file at \
~/.codex/themes/{name}.tmTheme"
{custom_theme_path_display}"
))
}
@@ -125,17 +141,17 @@ fn load_custom_theme(name: &str, codex_home: &Path) -> Option<Theme> {
/// Build the theme from current override/auto-detection settings.
/// Extracted from the old `theme()` init closure so it can be reused.
fn build_default_theme() -> Theme {
fn resolve_theme_with_override(name: Option<&str>, codex_home: Option<&Path>) -> Theme {
let ts = two_face::theme::extra();
// Honor user-configured theme if valid.
if let Some(Some(name)) = THEME_OVERRIDE.get() {
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/themes/{name}.tmTheme from disk.
if let Some(Some(home)) = CODEX_HOME.get() {
// 2. Try loading {CODEX_HOME}/themes/{name}.tmTheme from disk.
if let Some(home) = codex_home {
if let Some(theme) = load_custom_theme(name, home) {
return theme;
}
@@ -151,6 +167,16 @@ fn build_default_theme() -> Theme {
ts.get(name).clone()
}
/// Build the theme from current override/auto-detection settings.
/// Extracted from the old `theme()` init closure so it can be reused.
fn build_default_theme() -> Theme {
let name = THEME_OVERRIDE.get().and_then(|name| name.as_deref());
let codex_home = CODEX_HOME
.get()
.and_then(|codex_home| codex_home.as_deref());
resolve_theme_with_override(name, codex_home)
}
fn theme_lock() -> &'static RwLock<Theme> {
THEME.get_or_init(|| RwLock::new(build_default_theme()))
}

View File

@@ -204,12 +204,18 @@ pub(crate) fn build_theme_picker_params(
highlight::set_syntax_theme(original_theme.clone());
})
as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>);
let themes_dir_display = codex_home_owned
.as_ref()
.map(|home| home.join("themes"))
.unwrap_or_else(|| Path::new("$CODEX_HOME").join("themes"))
.display()
.to_string();
SelectionViewParams {
title: Some("Select Syntax Theme".to_string()),
subtitle: Some(
"Custom .tmTheme files can be added to the ~/.codex/themes directory.".to_string(),
),
subtitle: Some(format!(
"Custom .tmTheme files can be added to the {themes_dir_display} directory."
)),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,