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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user