diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab4760129..b1ddb01470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `/theme` picker now discovers valid user-authored `custom:` + overlays, previews their colors, highlights the active overlay, and preserves + it when the picker is opened and committed without navigation (#5901). + ## [0.9.12] - 2026-09-04 Codewhale v0.9.12 puts computer use in the binary, opens two new routes โ€” diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 3be9d7ed7c..98596915aa 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `/theme` picker now discovers valid user-authored `custom:` + overlays, previews their colors, highlights the active overlay, and preserves + it when the picker is opened and committed without navigation (#5901). + ## [0.9.12] - 2026-09-04 Codewhale v0.9.12 puts computer use in the binary, opens two new routes โ€” diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 1c163a84bb..93bcb09998 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -323,9 +323,16 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { )), "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), - "theme" | "ui_theme" => { - Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string()) - } + "theme" | "ui_theme" => Some( + if app + .theme_name + .starts_with(crate::palette::USER_THEME_PREFIX) + { + app.theme_name.clone() + } else { + crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string() + }, + ), "background_color" | "background" | "bg" => { crate::palette::hex_rgb_string(app.ui_theme.surface_bg) .or_else(|| Some("(default)".to_string())) @@ -2566,7 +2573,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> }; let background_setting = background_color_override.and_then(crate::palette::hex_rgb_string); - let (_, theme_id, ui_theme) = match crate::palette::resolve_theme_setting( + let (theme_name, theme_id, ui_theme) = match crate::palette::resolve_theme_setting( &settings.theme, background_setting.as_deref(), ) { @@ -2577,6 +2584,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> }; app.background_color_override = background_color_override; app.theme_id = theme_id; + app.theme_name = theme_name; app.ui_theme = ui_theme; app.needs_redraw = true; } @@ -4992,6 +5000,37 @@ context_window = 262144 ); } + #[test] + fn custom_theme_selection_keeps_the_raw_selector_in_live_app_state() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-custom-theme-selection-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let _guard = EnvGuard::new(&temp_root); + let themes = temp_root.join(".codewhale").join("themes"); + fs::create_dir_all(&themes).expect("themes dir"); + fs::write( + themes.join("midnight.json"), + r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##, + ) + .expect("theme overlay"); + + let mut app = create_test_app(); + let result = set_config_value(&mut app, "theme", "custom:midnight", false); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.theme_name, "custom:midnight"); + assert_eq!(app.theme_id, crate::palette::ThemeId::Whale); + assert_eq!( + app.ui_theme.accent_primary, + ratatui::style::Color::Rgb(0x12, 0x34, 0x56) + ); + } + #[test] fn invalid_theme_name_changes_nothing() { let temp_root = env::temp_dir().join(format!( diff --git a/crates/tui/src/palette/user_theme.rs b/crates/tui/src/palette/user_theme.rs index 0745aae6bd..f2b718c008 100644 --- a/crates/tui/src/palette/user_theme.rs +++ b/crates/tui/src/palette/user_theme.rs @@ -13,6 +13,14 @@ pub const USER_THEME_PREFIX: &str = "custom:"; pub const USER_THEME_SCHEMA: &str = include_str!("../assets/user-theme.schema.json"); const MAX_USER_THEME_BYTES: u64 = 64 * 1024; +/// A validated user-authored overlay available to the theme picker. +#[derive(Debug, Clone)] +pub struct UserThemeOption { + pub selector: String, + pub base: ThemeId, + pub theme: UiTheme, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct UserThemeFile { @@ -167,6 +175,51 @@ pub fn resolve_user_theme(value: &str) -> Result, Str Ok(Some((base, theme))) } +/// List valid user-authored theme overlays in stable selector order. +/// +/// Invalid, unreadable, oversized, and symlinked entries are omitted so an +/// optional malformed overlay cannot prevent the built-in picker from opening. +/// Detailed validation remains centralized in [`resolve_user_theme`]. +#[must_use] +pub fn list_user_theme_options() -> Vec { + let Ok(themes_dir) = user_themes_dir() else { + return Vec::new(); + }; + let Ok(metadata) = fs::symlink_metadata(&themes_dir) else { + return Vec::new(); + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Vec::new(); + } + let Ok(entries) = fs::read_dir(&themes_dir) else { + return Vec::new(); + }; + + let mut options = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + (path.extension().and_then(|extension| extension.to_str()) == Some("json")) + .then(|| path.file_stem()?.to_str().map(str::to_string)) + .flatten() + }) + .filter_map(|slug| { + let selector = normalize_user_theme_selector(&format!("{USER_THEME_PREFIX}{slug}")) + .ok() + .flatten()?; + let (base, theme) = resolve_user_theme(&selector).ok().flatten()?; + Some(UserThemeOption { + selector, + base, + theme, + }) + }) + .collect::>(); + options.sort_by(|left, right| left.selector.cmp(&right.selector)); + options.dedup_by(|left, right| left.selector == right.selector); + options +} + pub fn user_themes_dir() -> Result { codewhale_config::codewhale_home() .map(|home| home.join("themes")) @@ -324,4 +377,29 @@ mod tests { symlink(&outside, themes.join("linked.json")).unwrap(); assert!(resolve_user_theme("custom:linked").is_err()); } + + #[test] + fn list_user_theme_options_keeps_only_valid_sorted_overlays() { + let _lock = crate::test_support::lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path()); + let themes = temp.path().join("themes"); + fs::create_dir(&themes).unwrap(); + let valid = r##"{"schema_version":1,"base":"dark","colors":{}}"##; + fs::write(themes.join("zulu.json"), valid).unwrap(); + fs::write(themes.join("alpha.json"), valid).unwrap(); + fs::write(themes.join("broken.json"), "not json").unwrap(); + fs::write(themes.join("notes.txt"), valid).unwrap(); + + let options = list_user_theme_options(); + + assert_eq!( + options + .iter() + .map(|option| option.selector.as_str()) + .collect::>(), + ["custom:alpha", "custom:zulu"] + ); + assert!(options.iter().all(|option| option.base == ThemeId::Whale)); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 08acc9b2f1..9ee332ca63 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1878,6 +1878,10 @@ pub struct App { /// (Catppuccin, Tokyo Night, Dracula, Gruvbox) propagate to every /// render site, not just the handful that read `app.ui_theme`. pub theme_id: palette::ThemeId, + /// Normalized persisted selector, including `custom:` overlays. + /// `theme_id` remains the resolved base theme for behavior such as the + /// underwater surface and color-compatibility backend. + pub theme_name: String, // Onboarding pub onboarding: OnboardingState, pub onboarding_needs_api_key: bool, diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 41699f5ede..dc605168f5 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -327,7 +327,7 @@ impl App { settings.theme ) }); - let (_, theme_id, ui_theme) = resolved_theme.unwrap_or_else(|_| { + let (theme_name, theme_id, ui_theme) = resolved_theme.unwrap_or_else(|_| { let id = palette::ThemeId::System; let mut theme = id.ui_theme(); if let Some(background) = background_color_override { @@ -927,6 +927,7 @@ impl App { ui_theme, background_color_override, theme_id, + theme_name, onboarding, onboarding_needs_api_key: needs_api_key, onboarding_provider: provider, diff --git a/crates/tui/src/tui/theme_picker.rs b/crates/tui/src/tui/theme_picker.rs index c7413cc6c9..404405ff4e 100644 --- a/crates/tui/src/tui/theme_picker.rs +++ b/crates/tui/src/tui/theme_picker.rs @@ -12,9 +12,9 @@ //! `ThemeSelectionUpdated{persist:false}` to restore the exact theme that //! was active when the picker opened. //! -//! The option list is 1:1 with [`SELECTABLE_THEMES`] โ€” one row per theme, no -//! modifier rows. `underwater` is an ordinary row: the painted ocean field is -//! the theme, not a treatment beside it. +//! The option list contains compiled themes followed by valid user overlays. +//! `underwater` is an ordinary row: the painted ocean field is the theme, not +//! a treatment beside it. use std::borrow::Cow; use std::cell::RefCell; @@ -58,6 +58,8 @@ pub struct ThemePickerView { last_mouse_selected: Option, /// UI locale captured from the app at construction (#4057 wave 2). locale: Locale, + /// Valid user overlays loaded once when the picker opens. + custom_themes: Vec, } impl ThemePickerView { @@ -73,7 +75,7 @@ impl ThemePickerView { background_override: Option, ) -> Self { let normalized = original_name.trim().to_ascii_lowercase(); - let options = theme_options(&normalized); + let (options, custom_themes) = theme_options(&normalized); let controller = SettingsPickerController::new(options, normalized.clone()); let opening_cursor = controller.selected_source_index(); Self { @@ -85,6 +87,7 @@ impl ThemePickerView { row_hitboxes: RefCell::new(Vec::new()), last_mouse_selected: None, locale, + custom_themes, } } @@ -104,10 +107,27 @@ impl ThemePickerView { )) } - fn current(&self) -> ThemeId { + fn selected_theme_name(&self) -> &str { self.controller .selected_id() - .and_then(ThemeId::from_name) + .unwrap_or(ThemeId::System.name()) + } + + fn custom_theme_for(&self, selector: &str) -> Option { + self.custom_themes + .iter() + .find(|option| option.selector == selector) + .map(|option| option.theme) + } + + #[cfg(test)] + fn current(&self) -> ThemeId { + let selected = self.selected_theme_name(); + self.custom_themes + .iter() + .find(|option| option.selector == selected) + .map(|option| option.base) + .or_else(|| ThemeId::from_name(selected)) .unwrap_or(ThemeId::System) } @@ -118,11 +138,17 @@ impl ThemePickerView { /// Resolve a theme to a `UiTheme`, returning the cached `System` /// resolution to avoid repeated env-var reads inside `render`. - fn ui_theme_for(&self, id: ThemeId) -> UiTheme { - let theme = if matches!(id, ThemeId::System) { - self.system_ui_theme + fn ui_theme_for_selection(&self, selection: &str) -> UiTheme { + let theme = if let Some(custom) = self.custom_theme_for(selection) { + custom + } else if let Some(id) = ThemeId::from_name(selection) { + if matches!(id, ThemeId::System) { + self.system_ui_theme + } else { + id.ui_theme() + } } else { - id.ui_theme() + self.system_ui_theme }; self.background_override .map_or(theme, |background| theme.with_background_color(background)) @@ -130,16 +156,15 @@ impl ThemePickerView { fn preview_event(&self) -> ViewAction { ViewAction::Emit(ViewEvent::ThemeSelectionUpdated { - theme: self.current().name().to_string(), + theme: self.selected_theme_name().to_string(), persist: false, }) } fn commit_event(&self) -> ViewAction { - // A commit that never moved the cursor must not rewrite settings: - // the persisted theme may be a custom: selector this list - // cannot express as a row, and re-committing the cursor row would - // silently replace it. + // A commit that never moved the cursor must preserve the exact + // opening selector. This also protects a custom: selector if + // its file disappears or becomes invalid while the picker is open. if self.controller.selected_source_index() == self.opening_cursor { return ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated { theme: self.original_theme_name.clone(), @@ -147,7 +172,7 @@ impl ThemePickerView { }); } ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated { - theme: self.current().name().to_string(), + theme: self.selected_theme_name().to_string(), persist: true, }) } @@ -179,9 +204,16 @@ impl ThemePickerView { } } -fn theme_options(current_name: &str) -> Vec { +fn theme_options(current_name: &str) -> (Vec, Vec) { + theme_options_with_custom(current_name, crate::palette::list_user_theme_options()) +} + +fn theme_options_with_custom( + current_name: &str, + custom_themes: Vec, +) -> (Vec, Vec) { let current = current_name.trim().to_ascii_lowercase(); - SELECTABLE_THEMES + let mut options = SELECTABLE_THEMES .iter() .copied() .map(|id| { @@ -202,7 +234,35 @@ fn theme_options(current_name: &str) -> Vec { .prefer_list_when_narrow(true) .build() }) - .collect() + .collect::>(); + for custom in &custom_themes { + let label = custom + .selector + .strip_prefix(crate::palette::USER_THEME_PREFIX) + .map_or_else(|| custom.selector.clone(), |slug| format!("Custom: {slug}")); + options.push( + SettingOption::builder(custom.selector.clone(), label) + .summary(format!( + "User overlay ยท based on {}", + custom.base.display_name() + )) + .detail(format!( + "User-authored overlay based on {}", + custom.base.display_name() + )) + .help("Pick a user-authored theme overlay") + .values(SettingValues::new( + Cow::Owned(current.clone()), + Cow::Borrowed("underwater"), + Cow::Owned(custom.selector.clone()), + )) + .availability(SettingAvailability::Available) + .tab("themes") + .prefer_list_when_narrow(true) + .build(), + ); + } + (options, custom_themes) } impl ModalView for ThemePickerView { @@ -281,8 +341,7 @@ impl ModalView for ThemePickerView { // the cursor moves, matching what the background will look like // after Enter. We keep the live `surface_bg` (not the shared ink) and // the bare `Clear` so the preview backdrop reads as intended. - let current = self.current(); - let live = self.ui_theme_for(current); + let live = self.ui_theme_for_selection(self.selected_theme_name()); let inner = render_underwater_surface(area, buf, tr(self.locale, MessageId::ThemeSurfaceTitle)); @@ -338,7 +397,6 @@ impl ModalView for ThemePickerView { .options() .get(source_idx) .expect("visible source index must reference an option"); - let selection = ThemeId::from_name(option.id.as_ref()).unwrap_or(ThemeId::System); let is_selected = visible_idx == selected_visible; let row_style = if is_selected { menu_style::theme_selected_row_style(&live) @@ -364,7 +422,7 @@ impl ModalView for ThemePickerView { // accent + panel + border colors so the picker doubles as a // legend. The underwater row shows its water column; use the // cached resolver so `System` doesn't repeat `UiTheme::detect()`. - let row_theme = self.ui_theme_for(selection); + let row_theme = self.ui_theme_for_selection(option.id.as_ref()); let swatch_colors = match crate::tui::ocean::OceanRamp::for_theme(&row_theme) { Some(ramp) => [ ramp.surface, @@ -577,6 +635,46 @@ mod tests { ); } + #[test] + fn custom_theme_rows_preview_and_commit_their_selector() { + let mut custom_theme = ThemeId::Whale.ui_theme(); + custom_theme.accent_primary = Color::Rgb(0x12, 0x34, 0x56); + let custom = crate::palette::UserThemeOption { + selector: "custom:midnight".to_string(), + base: ThemeId::Whale, + theme: custom_theme, + }; + let (options, custom_themes) = theme_options_with_custom("custom:midnight", vec![custom]); + let controller = SettingsPickerController::new(options, "custom:midnight"); + let view = ThemePickerView { + opening_cursor: controller.selected_source_index(), + controller, + original_theme_name: "custom:midnight".to_string(), + system_ui_theme: UiTheme::detect(), + background_override: None, + row_hitboxes: RefCell::new(Vec::new()), + last_mouse_selected: None, + locale: Locale::En, + custom_themes, + }; + + assert_eq!(view.controller.selected_id(), Some("custom:midnight")); + assert_eq!(view.current(), ThemeId::Whale); + assert_eq!( + view.ui_theme_for_selection("custom:midnight") + .accent_primary, + Color::Rgb(0x12, 0x34, 0x56) + ); + assert_eq!( + selected_values(&view.preview_event()), + Some(("custom:midnight", false)) + ); + assert_eq!( + selected_values(&view.commit_event()), + Some(("custom:midnight", true)) + ); + } + #[test] fn enter_after_navigating_away_still_commits_the_chosen_option() { let mut v = ThemePickerView::new("dracula".to_string()); diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 691e00450e..5ae6e96799 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -1978,7 +1978,7 @@ pub(crate) async fn apply_command_result( // Esc can revert through the same ConfigUpdated channel. // Avoids re-reading settings.toml from disk on every // `/theme` invocation. - let original = app.theme_id.name().to_string(); + let original = app.theme_name.clone(); app.view_stack .push_boxed(crate::tui::theme_picker::ThemePickerView::boxed( original, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8186a1b5e2..f215e352f8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1576,6 +1576,8 @@ Common settings keys: validation. `/theme schema` prints the embedded JSON Schema and `/theme path` shows the exact directory. An overlay names one compiled `base` theme and changes only listed semantic colors; it cannot include or read another file. + Open `/theme` to browse valid overlays, preview them live, and keep the + active `custom:` selector when the picker is opened without moving. - `auto_compact` (on/off, model-aware default on for known context windows unless explicitly configured) - `auto_compact_threshold_percent` (10-100, default `80`): pre-send