Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`
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 —
Expand Down
6 changes: 6 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`
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 —
Expand Down
47 changes: 43 additions & 4 deletions crates/tui/src/commands/groups/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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(),
) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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!(
Expand Down
78 changes: 78 additions & 0 deletions crates/tui/src/palette/user_theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -167,6 +175,51 @@ pub fn resolve_user_theme(value: &str) -> Result<Option<(ThemeId, UiTheme)>, 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<UserThemeOption> {
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::<Vec<_>>();
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<PathBuf, String> {
codewhale_config::codewhale_home()
.map(|home| home.join("themes"))
Expand Down Expand Up @@ -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::<Vec<_>>(),
["custom:alpha", "custom:zulu"]
);
assert!(options.iter().all(|option| option.base == ThemeId::Whale));
}
}
4 changes: 4 additions & 0 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` 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,
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/tui/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading