tui: support safe user-authored theme overlays

Load bounded semantic-color overlays from the Codewhale-owned themes directory, preserve custom selectors through typed settings, and surface invalid themes without silently changing persisted state.\n\nIncludes an embedded JSON Schema, symlink-safe leaf opens, exact hex validation, and focused config round-trip coverage.
This commit is contained in:
Hunter B
2026-07-23 05:15:50 -07:00
parent 9239970145
commit 312a6e872d
10 changed files with 504 additions and 56 deletions
@@ -1881,8 +1881,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.needs_redraw = true;
}
"theme" | "ui_theme" | "background_color" | "background" | "bg" => {
app.theme_id = crate::palette::ThemeId::from_name(&settings.theme)
.unwrap_or(crate::palette::ThemeId::System);
// Theme previews reload persisted settings for each cursor move.
// Keep a session-only background overlay live unless this command
// is itself updating (or clearing) the background.
@@ -1894,13 +1892,20 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
.as_deref()
.and_then(crate::palette::parse_hex_rgb_color)
};
app.background_color_override = background_color_override;
let background_setting =
background_color_override.and_then(crate::palette::hex_rgb_string);
app.ui_theme = crate::palette::ui_theme_from_settings(
let (_, theme_id, ui_theme) = match crate::palette::resolve_theme_setting(
&settings.theme,
background_setting.as_deref(),
);
) {
Ok(resolved) => resolved,
Err(error) => {
return CommandResult::error(format!("Failed to apply theme: {error}"));
}
};
app.background_color_override = background_color_override;
app.theme_id = theme_id;
app.ui_theme = ui_theme;
app.needs_redraw = true;
}
"cost_currency" | "currency" => {
@@ -2134,6 +2139,14 @@ fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) {
pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => CommandResult::action(AppAction::OpenThemePicker),
Some("schema") => CommandResult::message(crate::palette::user_theme_schema_json()),
Some("path") => match crate::palette::user_themes_dir() {
Ok(path) => CommandResult::message(format!(
"User themes: {}\nSelect with: /theme custom:<name>",
path.display()
)),
Err(error) => CommandResult::error(error),
},
Some(name) => set_config_value(app, "theme", name, true),
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ static MODE_INFO: CommandInfo = CommandInfo {
static THEME_INFO: CommandInfo = CommandInfo {
name: "theme",
aliases: &[],
usage: "/theme [name]",
usage: "/theme [name|custom:<name>|schema|path]",
description_id: MessageId::CmdThemeDescription,
};
static VERBOSE_INFO: CommandInfo = CommandInfo {
+77 -2
View File
@@ -73,6 +73,12 @@ pub struct SettingsSection {
pub inline_diffs: InlineDiffValue,
pub locale: UiLocale,
pub theme: UiThemeValue,
#[schemars(
title = "Custom theme name",
description = "Theme slug from the fixed Codewhale themes directory; used only when theme is custom."
)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_theme_name: Option<String>,
#[schemars(
title = "Background color",
description = "Optional Blue Stage background override as #RRGGBB. Leave empty to keep the named theme."
@@ -216,6 +222,7 @@ pub enum UiThemeValue {
GruvboxDark,
Matrix,
Uwu,
Custom,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -390,6 +397,13 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
inline_diffs: settings.inline_diffs.as_str().into(),
locale: UiLocale::from_setting(&settings.locale)?,
theme: UiThemeValue::from_setting(&settings.theme)?,
custom_theme_name: crate::palette::normalize_user_theme_selector(&settings.theme)
.map_err(anyhow::Error::msg)?
.map(|selector| {
selector
.trim_start_matches(crate::palette::USER_THEME_PREFIX)
.to_string()
}),
background_color: settings.background_color.clone(),
bracketed_paste: settings.bracketed_paste,
composer_density: settings.composer_density.as_str().into(),
@@ -550,6 +564,7 @@ pub fn apply_document(
persist: bool,
) -> Result<ConfigUiApplyOutcome> {
validate_document(&doc, app, config)?;
let theme_setting = theme_setting_for_document(&doc)?;
let mut notes = Vec::new();
let previous_compaction = app.compaction_config();
let previous_reasoning_effort = app.reasoning_effort;
@@ -585,7 +600,7 @@ pub fn apply_document(
),
("inline_diffs", doc.settings.inline_diffs.as_setting()),
("locale", doc.settings.locale.as_setting()),
("theme", doc.settings.theme.as_setting()),
("theme", theme_setting.as_str()),
(
"background_color",
doc.settings
@@ -769,9 +784,28 @@ fn validate_document(doc: &ConfigUiDocument, app: &App, config: &Config) -> Resu
if doc.config.mcp_config_path.trim().is_empty() {
bail!("mcp_config_path cannot be empty");
}
let _ = theme_setting_for_document(doc)?;
Ok(())
}
fn theme_setting_for_document(doc: &ConfigUiDocument) -> Result<String> {
let setting = if doc.settings.theme == UiThemeValue::Custom {
let name = doc
.settings
.custom_theme_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.ok_or_else(|| anyhow::anyhow!("custom theme requires custom_theme_name"))?;
format!("{}{}", crate::palette::USER_THEME_PREFIX, name)
} else {
doc.settings.theme.as_setting().to_string()
};
crate::palette::resolve_theme_setting(&setting, None)
.map(|(normalized, _, _)| normalized)
.map_err(anyhow::Error::msg)
}
fn validate_and_normalize_model(
provider: crate::config::ApiProvider,
model: &str,
@@ -912,10 +946,17 @@ impl UiThemeValue {
Self::GruvboxDark => "gruvbox-dark",
Self::Matrix => "matrix",
Self::Uwu => "uwu",
Self::Custom => "custom",
}
}
fn from_setting(value: &str) -> Result<Self> {
if crate::palette::normalize_user_theme_selector(value)
.map_err(anyhow::Error::msg)?
.is_some()
{
return Ok(Self::Custom);
}
match crate::palette::normalize_theme_name(value) {
Some("system") => Ok(Self::System),
Some("dark") => Ok(Self::Dark),
@@ -1434,6 +1475,39 @@ background_color = "#1A1B26"
}
}
#[test]
fn custom_theme_round_trips_through_typed_config_document() {
let _lock = lock_test_env();
let temp_root = tempfile::tempdir().expect("isolated Codewhale home");
let codewhale_home = temp_root.path().join(".codewhale");
let themes_dir = codewhale_home.join("themes");
fs::create_dir_all(&themes_dir).expect("themes dir");
fs::write(
themes_dir.join("ocean.json"),
r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##,
)
.expect("custom theme");
fs::write(
codewhale_home.join("settings.toml"),
r#"theme = "custom:ocean"
"#,
)
.expect("settings");
let _home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
let mut app = app();
let mut config = Config::default();
let doc = build_document(&app, &config).expect("document");
assert_eq!(doc.settings.theme, UiThemeValue::Custom);
assert_eq!(doc.settings.custom_theme_name.as_deref(), Some("ocean"));
apply_document(doc, &mut app, &mut config, false).expect("apply custom theme");
assert_eq!(
app.ui_theme.accent_primary,
ratatui::style::Color::Rgb(0x12, 0x34, 0x56)
);
}
#[test]
fn schema_contains_typed_enums() {
let schema = build_schema();
@@ -1479,7 +1553,8 @@ background_color = "#1A1B26"
"dracula",
"gruvbox-dark",
"matrix",
"uwu"
"uwu",
"custom"
])
);
}
+2
View File
@@ -13,6 +13,7 @@ mod adapt;
mod detect;
mod themes;
mod tokens;
mod user_theme;
#[cfg(test)]
mod tests;
@@ -24,3 +25,4 @@ pub use detect::*;
#[allow(unused_imports)]
pub use themes::*;
pub use tokens::*;
pub use user_theme::*;
+4 -10
View File
@@ -893,11 +893,6 @@ impl UiTheme {
Self::for_mode(PaletteMode::detect())
}
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
ThemeId::from_name(value).map(ThemeId::ui_theme)
}
#[must_use]
pub fn with_background_color(mut self, color: Color) -> Self {
self.surface_bg = color;
@@ -939,12 +934,11 @@ pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str {
}
#[must_use]
#[cfg_attr(not(test), allow(dead_code))]
pub fn ui_theme_from_settings(theme: &str, background_color: Option<&str>) -> UiTheme {
let mut ui_theme = UiTheme::from_setting(theme).unwrap_or_else(UiTheme::detect);
if let Some(background) = background_color.and_then(parse_hex_rgb_color) {
ui_theme = ui_theme.with_background_color(background);
}
ui_theme
super::resolve_theme_setting(theme, background_color)
.map(|(_, _, theme)| theme)
.unwrap_or_else(|_| UiTheme::detect())
}
#[must_use]
+327
View File
@@ -0,0 +1,327 @@
//! User-authored theme overlays loaded from the Codewhale-owned themes directory.
use std::fs::{self, File, OpenOptions};
use std::io::Read;
use std::path::{Path, PathBuf};
use ratatui::style::Color;
use serde::Deserialize;
use super::{ThemeId, UiTheme, parse_hex_rgb_color};
pub const USER_THEME_PREFIX: &str = "custom:";
pub const USER_THEME_SCHEMA: &str = include_str!("../../../../docs/schemas/user-theme.schema.json");
const MAX_USER_THEME_BYTES: u64 = 64 * 1024;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct UserThemeFile {
schema_version: u8,
base: String,
colors: UserThemeColors,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct UserThemeColors {
surface_bg: Option<String>,
panel_bg: Option<String>,
elevated_bg: Option<String>,
composer_bg: Option<String>,
selection_bg: Option<String>,
header_bg: Option<String>,
footer_bg: Option<String>,
text_dim: Option<String>,
text_hint: Option<String>,
text_muted: Option<String>,
text_body: Option<String>,
text_soft: Option<String>,
border: Option<String>,
accent_primary: Option<String>,
accent_secondary: Option<String>,
accent_action: Option<String>,
error_fg: Option<String>,
error_hover: Option<String>,
error_surface: Option<String>,
error_border: Option<String>,
error_text: Option<String>,
warning: Option<String>,
success: Option<String>,
info: Option<String>,
mode_agent: Option<String>,
mode_yolo: Option<String>,
mode_plan: Option<String>,
mode_operate: Option<String>,
permission_ask: Option<String>,
permission_auto_review: Option<String>,
permission_full_access: Option<String>,
status_ready: Option<String>,
status_working: Option<String>,
status_warning: Option<String>,
diff_added_fg: Option<String>,
diff_deleted_fg: Option<String>,
diff_added_bg: Option<String>,
diff_deleted_bg: Option<String>,
tool_running: Option<String>,
tool_success: Option<String>,
tool_failed: Option<String>,
}
#[must_use]
pub fn user_theme_schema_json() -> &'static str {
USER_THEME_SCHEMA
}
pub fn normalize_user_theme_selector(value: &str) -> Result<Option<String>, String> {
let trimmed = value.trim();
let Some(slug) = trimmed.strip_prefix(USER_THEME_PREFIX) else {
return Ok(None);
};
let slug = slug.trim().to_ascii_lowercase();
if slug.is_empty()
|| slug.len() > 64
|| !slug
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
{
return Err(
"custom theme names must be 1-64 ASCII letters, digits, '-' or '_'".to_string(),
);
}
Ok(Some(format!("{USER_THEME_PREFIX}{slug}")))
}
pub fn normalize_theme_setting(value: &str) -> Result<String, String> {
if let Some(id) = ThemeId::from_name(value) {
return Ok(id.name().to_string());
}
normalize_user_theme_selector(value)?.ok_or_else(|| {
format!("invalid theme '{value}'; use a compiled theme name or custom:<name>")
})
}
pub fn resolve_theme_setting(
value: &str,
background_color: Option<&str>,
) -> Result<(String, ThemeId, UiTheme), String> {
let normalized = normalize_theme_setting(value)?;
let (id, mut theme) = if let Some(resolved) = resolve_user_theme(&normalized)? {
resolved
} else {
let id = ThemeId::from_name(&normalized)
.ok_or_else(|| format!("invalid compiled theme '{normalized}'"))?;
(id, id.ui_theme())
};
if let Some(value) = background_color {
theme = theme.with_background_color(color("background_color", value)?);
}
Ok((normalized, id, theme))
}
pub fn resolve_user_theme(value: &str) -> Result<Option<(ThemeId, UiTheme)>, String> {
let Some(selector) = normalize_user_theme_selector(value)? else {
return Ok(None);
};
let slug = selector.trim_start_matches(USER_THEME_PREFIX);
let themes_dir = user_themes_dir()?;
reject_symlink_directory(&themes_dir)?;
let path = themes_dir.join(format!("{slug}.json"));
let mut file = open_theme_file(&path)?;
let metadata = file
.metadata()
.map_err(|error| format!("failed to inspect user theme {}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!(
"user theme {} must be a regular file",
path.display()
));
}
if metadata.len() > MAX_USER_THEME_BYTES {
return Err(format!(
"user theme {} is too large ({} bytes; max {MAX_USER_THEME_BYTES})",
path.display(),
metadata.len()
));
}
let mut raw = String::with_capacity(metadata.len() as usize);
file.read_to_string(&mut raw)
.map_err(|error| format!("failed to read user theme {}: {error}", path.display()))?;
let parsed: UserThemeFile = serde_json::from_str(&raw)
.map_err(|error| format!("invalid user theme {}: {error}", path.display()))?;
if parsed.schema_version != 1 {
return Err(format!(
"unsupported user theme schema_version {} in {}; expected 1",
parsed.schema_version,
path.display()
));
}
let base = ThemeId::from_name(&parsed.base).ok_or_else(|| {
format!(
"invalid base theme '{}' in {}; use a compiled theme name",
parsed.base,
path.display()
)
})?;
let mut theme = base.ui_theme();
apply_colors(&mut theme, &parsed.colors)?;
Ok(Some((base, theme)))
}
pub fn user_themes_dir() -> Result<PathBuf, String> {
codewhale_config::codewhale_home()
.map(|home| home.join("themes"))
.map_err(|error| format!("failed to resolve Codewhale themes directory: {error}"))
}
fn reject_symlink_directory(path: &Path) -> Result<(), String> {
let metadata = fs::symlink_metadata(path).map_err(|error| {
format!(
"failed to inspect themes directory {}: {error}",
path.display()
)
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!(
"themes directory {} must be a real directory, not a symlink",
path.display()
));
}
Ok(())
}
fn open_theme_file(path: &Path) -> Result<File, String> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
options.open(path).map_err(|error| {
format!(
"failed to open user theme {} safely: {error}",
path.display()
)
})
}
fn color(name: &str, value: &str) -> Result<Color, String> {
parse_hex_rgb_color(value)
.ok_or_else(|| format!("user theme color '{name}' must be #RRGGBB, got '{value}'"))
}
fn apply_colors(theme: &mut UiTheme, colors: &UserThemeColors) -> Result<(), String> {
macro_rules! apply {
($($field:ident),+ $(,)?) => {$({
if let Some(value) = colors.$field.as_deref() {
theme.$field = color(stringify!($field), value)?;
}
})+};
}
apply!(
surface_bg,
panel_bg,
elevated_bg,
composer_bg,
selection_bg,
header_bg,
footer_bg,
text_dim,
text_hint,
text_muted,
text_body,
text_soft,
border,
accent_primary,
accent_secondary,
accent_action,
error_fg,
error_hover,
error_surface,
error_border,
error_text,
warning,
success,
info,
mode_agent,
mode_yolo,
mode_plan,
mode_operate,
permission_ask,
permission_auto_review,
permission_full_access,
status_ready,
status_working,
status_warning,
diff_added_fg,
diff_deleted_fg,
diff_added_bg,
diff_deleted_bg,
tool_running,
tool_success,
tool_failed,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::EnvVarGuard;
#[test]
fn selector_rejects_paths_and_accepts_bounded_slugs() {
assert_eq!(
normalize_user_theme_selector("custom:My_Theme").unwrap(),
Some("custom:my_theme".to_string())
);
assert!(normalize_user_theme_selector("custom:../secret").is_err());
assert!(normalize_user_theme_selector("custom:").is_err());
assert_eq!(normalize_user_theme_selector("dark").unwrap(), None);
}
#[test]
fn user_theme_loads_fixed_file_and_rejects_unknown_fields() {
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();
fs::write(
themes.join("ocean.json"),
r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##,
)
.unwrap();
let (base, theme) = resolve_user_theme("custom:ocean").unwrap().unwrap();
assert_eq!(base, ThemeId::Whale);
assert_eq!(theme.accent_primary, Color::Rgb(0x12, 0x34, 0x56));
fs::write(
themes.join("bad.json"),
r##"{"schema_version":1,"base":"dark","colors":{"mystery":"#123456"}}"##,
)
.unwrap();
assert!(resolve_user_theme("custom:bad").is_err());
}
#[cfg(unix)]
#[test]
fn user_theme_refuses_symlink_files() {
use std::os::unix::fs::symlink;
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 outside = temp.path().join("outside.json");
fs::write(&outside, "{}").unwrap();
symlink(&outside, themes.join("linked.json")).unwrap();
assert!(resolve_user_theme("custom:linked").is_err());
}
}
+27 -31
View File
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use crate::config::{ApiProvider, expand_path, normalize_model_name};
use crate::localization::normalize_configured_locale;
use crate::palette::{normalize_hex_rgb_color, normalize_theme_name};
use crate::palette::{normalize_hex_rgb_color, normalize_theme_setting};
use crate::tui::app::ReasoningEffort;
const SETTINGS_FILE_NAME: &str = "settings.toml";
@@ -207,14 +207,7 @@ impl TuiPrefs {
/// Returns `Err` if an unrecognised `theme` value is found so callers can
/// surface a helpful message rather than silently ignoring a typo.
pub fn validate(&mut self) -> Result<()> {
let theme = self.theme.trim().to_ascii_lowercase();
let Some(theme) = normalize_theme_name(&theme) else {
anyhow::bail!(
"Invalid tui.toml theme '{}': expected system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark, or solarized-light.",
self.theme
);
};
self.theme = theme.to_string();
self.theme = normalize_theme_setting(&self.theme).map_err(anyhow::Error::msg)?;
Ok(())
}
}
@@ -675,7 +668,7 @@ impl Settings {
.unwrap_or("en")
.to_string();
s.background_color = normalize_optional_background_color(s.background_color.as_deref());
s.theme = normalize_settings_theme(&s.theme).to_string();
s.theme = normalize_settings_theme(&s.theme);
s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
s.reasoning_effort = s
.reasoning_effort
@@ -944,20 +937,10 @@ impl Settings {
self.locale = locale.to_string();
}
"theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark, solarized-light."
);
};
self.theme = id.name().to_string();
self.theme = normalize_theme_setting(value).map_err(anyhow::Error::msg)?;
}
"ui_theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark, solarized-light."
);
};
self.theme = id.name().to_string();
self.theme = normalize_theme_setting(value).map_err(anyhow::Error::msg)?;
}
"background_color" | "background" | "bg" => {
self.background_color = normalize_background_color_setting(value)?;
@@ -1342,7 +1325,7 @@ impl Settings {
),
(
"theme",
"UI theme: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark, solarized-light",
"UI theme: a compiled name or custom:<name> from the Codewhale themes directory",
),
(
"background_color",
@@ -1772,8 +1755,8 @@ fn normalize_synchronized_output(value: &str) -> &str {
}
}
fn normalize_settings_theme(value: &str) -> &'static str {
normalize_theme_name(value).unwrap_or("system")
fn normalize_settings_theme(value: &str) -> String {
normalize_theme_setting(value).unwrap_or_else(|_| "system".to_string())
}
/// Returns `true` when the active terminal is Ptyxis (the new default
@@ -2210,6 +2193,11 @@ mod tests {
.expect("set solarized alias");
assert_eq!(settings.theme, "solarized-light");
settings
.set("theme", "custom:Ocean_1")
.expect("custom selector validation must not depend on the file system");
assert_eq!(settings.theme, "custom:ocean_1");
let err = settings
.set("theme", "nord")
.expect_err("unknown theme should fail");
@@ -3645,12 +3633,20 @@ mod tests {
..TuiPrefs::default()
};
let err = prefs.validate().expect_err("nord is not a valid theme");
assert!(err.to_string().contains("Invalid tui.toml theme"));
assert!(
err.to_string()
.contains("expected system, dark, light, grayscale")
);
assert!(err.to_string().contains("solarized-light"));
assert!(err.to_string().contains("invalid theme 'nord'"));
assert!(err.to_string().contains("custom:<name>"));
}
#[test]
fn tui_prefs_validate_custom_selector_without_loading_file() {
let mut prefs = TuiPrefs {
theme: "custom:Ocean_1".to_string(),
..TuiPrefs::default()
};
prefs
.validate()
.expect("selector validation must not depend on the file system");
assert_eq!(prefs.theme, "custom:ocean_1");
}
#[test]
+20 -7
View File
@@ -3175,16 +3175,27 @@ impl App {
// Resolve the named theme from settings; unknown values were already
// normalised to "system" in Settings::load. The background_color
// setting still overlays on top.
let theme_id =
palette::ThemeId::from_name(&settings.theme).unwrap_or(palette::ThemeId::System);
let mut ui_theme = theme_id.ui_theme();
let background_color_override = settings
.background_color
.as_deref()
.and_then(palette::parse_hex_rgb_color);
if let Some(background) = background_color_override {
ui_theme = ui_theme.with_background_color(background);
}
let background_setting = background_color_override.and_then(palette::hex_rgb_string);
let resolved_theme =
palette::resolve_theme_setting(&settings.theme, background_setting.as_deref());
let theme_warning = resolved_theme.as_ref().err().map(|error| {
format!(
"⚠ configured theme '{}' could not be loaded — using System ({error})",
settings.theme
)
});
let (_, 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 {
theme = theme.with_background_color(background);
}
(id.name().to_string(), id, theme)
});
let provider_models = settings.provider_models.clone().unwrap_or_default();
let model = provider_models
.get(&provider_identity)
@@ -3496,7 +3507,9 @@ impl App {
turn_error_posted: false,
// Surface parse warnings so the user knows their config file is
// broken instead of silently losing all settings.
status_message: settings_parse_warning.or(tui_prefs_warning),
status_message: settings_parse_warning
.or(tui_prefs_warning)
.or(theme_warning),
status_toasts: VecDeque::new(),
sticky_status: None,
last_status_message_seen: None,
+7
View File
@@ -1150,6 +1150,13 @@ Common settings keys:
rose owns danger, violet owns Operate, and green remains completed/verified.
Text labels, markers, and motion policy carry the same states when color is
unavailable; color is never the only cue.
User-authored overlays live only at `~/.codewhale/themes/<name>.json` (or
`$CODEWHALE_HOME/themes/<name>.json`) and are selected with
`/theme custom:<name>`. The filename is a bounded slug, symlinks and files
over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail
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.
- `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
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://codewhale.ai/schemas/user-theme.schema.json",
"title": "Codewhale user theme overlay",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "base", "colors"],
"properties": {
"schema_version": { "const": 1 },
"base": {
"enum": ["system", "terminal", "dark", "light", "grayscale", "catppuccin-mocha", "tokyo-night", "dracula", "gruvbox-dark", "claude", "matrix", "solarized-light", "uwu"]
},
"colors": {
"type": "object",
"propertyNames": {
"enum": ["surface_bg", "panel_bg", "elevated_bg", "composer_bg", "selection_bg", "header_bg", "footer_bg", "text_dim", "text_hint", "text_muted", "text_body", "text_soft", "border", "accent_primary", "accent_secondary", "accent_action", "error_fg", "error_hover", "error_surface", "error_border", "error_text", "warning", "success", "info", "mode_agent", "mode_yolo", "mode_plan", "mode_operate", "permission_ask", "permission_auto_review", "permission_full_access", "status_ready", "status_working", "status_warning", "diff_added_fg", "diff_deleted_fg", "diff_added_bg", "diff_deleted_bg", "tool_running", "tool_success", "tool_failed"]
},
"additionalProperties": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }
}
}
}