diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index eb6fb2f8fb..fcafc7ddcf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2623,7 +2623,7 @@ impl App { ) { crate::render::highlight::set_syntax_theme(theme); } - self.config.tui_theme = Some(name); + self.sync_tui_theme_selection(name); } Err(err) => { tracing::error!(error = %err, "failed to persist theme selection"); @@ -2810,6 +2810,11 @@ impl App { self.chat_widget.set_personality(personality); } + fn sync_tui_theme_selection(&mut self, name: String) { + self.config.tui_theme = Some(name.clone()); + self.chat_widget.set_tui_theme(Some(name)); + } + fn personality_label(personality: Personality) -> &'static str { match personality { Personality::None => "None", @@ -3682,6 +3687,19 @@ mod tests { Ok(()) } + #[tokio::test] + async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_theme_selection("dracula".to_string()); + + assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); + assert_eq!( + app.chat_widget.config_ref().tui_theme.as_deref(), + Some("dracula") + ); + } + #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6dbed2d12b..4db2e099be 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6350,6 +6350,11 @@ impl ChatWidget { self.config.personality = Some(personality); } + /// Set the syntax theme override in the widget's config copy. + pub(crate) fn set_tui_theme(&mut self, theme: Option) { + self.config.tui_theme = theme; + } + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.current_collaboration_mode = diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index d42972f2c5..88770620cc 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -53,10 +53,19 @@ pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>) if parse_theme_name(name).is_some() { return None; } - // Check for a custom .tmTheme file on disk. - let has_custom_file = codex_home.is_some_and(|home| custom_theme_path(name, home).is_file()); - if has_custom_file { - 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; + } + return Some(format!( + "Syntax theme \"{name}\" was found at ~/.codex/themes/{name}.tmTheme \ + but could not be parsed. Falling back to auto-detection." + )); + } } Some(format!( "Unknown syntax theme \"{name}\", falling back to auto-detection. \ @@ -167,10 +176,10 @@ pub(crate) fn current_theme_name() -> String { if parse_theme_name(name).is_some() { return name.clone(); } - if let Some(Some(home)) = CODEX_HOME.get() { - if custom_theme_path(name, home).is_file() { - return name.clone(); - } + if let Some(Some(home)) = CODEX_HOME.get() + && load_custom_theme(name, home).is_some() + { + return name.clone(); } } // Adaptive default: light or dark based on terminal background. @@ -445,6 +454,24 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + fn write_minimal_tmtheme(path: &Path) { + // Minimal valid .tmTheme plist (enough for syntect to parse). + std::fs::write( + path, + r#" + + +nameTest +settings +settings +foreground#FFFFFF +background#000000 + +"#, + ) + .unwrap(); + } + /// Reconstruct plain text from highlighted Lines. fn reconstructed(lines: &[Line<'static>]) -> String { lines @@ -793,21 +820,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let themes_dir = dir.path().join("themes"); std::fs::create_dir(&themes_dir).unwrap(); - // Minimal valid .tmTheme plist (enough for syntect to parse). - std::fs::write( - themes_dir.join("test-custom.tmTheme"), - r#" - - -nameTest -settings -settings -foreground#FFFFFF -background#000000 - -"#, - ) - .unwrap(); + write_minimal_tmtheme(&themes_dir.join("test-custom.tmTheme")); let theme = load_custom_theme("test-custom", dir.path()); assert!(theme.is_some(), "should load .tmTheme from themes dir"); } @@ -843,14 +856,33 @@ mod tests { } #[test] - fn validate_theme_name_none_when_custom_file_exists() { + fn validate_theme_name_none_when_custom_file_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let themes_dir = dir.path().join("themes"); + std::fs::create_dir(&themes_dir).unwrap(); + write_minimal_tmtheme(&themes_dir.join("my-fancy.tmTheme")); + assert!( + validate_theme_name(Some("my-fancy"), Some(dir.path())).is_none(), + "should not warn when custom .tmTheme file parses successfully" + ); + } + + #[test] + fn validate_theme_name_warns_when_custom_file_is_invalid() { let dir = tempfile::tempdir().unwrap(); let themes_dir = dir.path().join("themes"); std::fs::create_dir(&themes_dir).unwrap(); std::fs::write(themes_dir.join("my-fancy.tmTheme"), "placeholder").unwrap(); + let warning = validate_theme_name(Some("my-fancy"), Some(dir.path())); assert!( - validate_theme_name(Some("my-fancy"), Some(dir.path())).is_none(), - "should not warn when custom .tmTheme file exists on disk" + warning.is_some(), + "should warn when custom .tmTheme exists but cannot be parsed" + ); + assert!( + warning + .as_deref() + .is_some_and(|msg| msg.contains("could not be parsed")), + "warning should explain that the theme file is invalid" ); }