use crate::{
PROJECT_SCOPE_NAME,
core::{
settings_manager,
theme_view::{self, ThemeView},
},
embedded,
modes::interactive::system_theme::{
Appearance, SystemThemeInput, generate_indexed_theme_colors, generate_system_theme_colors,
},
utils::{
color::{color_to_rgb, indexed_to_rgb, parse_color_str, rgb_to_oklch},
paths::home_dir,
terminal_caps::detect_terminal_capabilities,
terminal_colors::terminal_colors,
},
};
use ratatui::style::{Color, Modifier, Style};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
pub const DEFAULT_THEME_NAME: &str = "system";
pub const SYSTEM_THEME_NAME: &str = "system";
const REQUIRED_THEME_TOKENS: [&str; 51] = [
"accent",
"border",
"borderAccent",
"borderMuted",
"success",
"error",
"warning",
"muted",
"dim",
"text",
"thinkingText",
"selectedBg",
"userMessageBg",
"userMessageText",
"customMessageBg",
"customMessageText",
"customMessageLabel",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
"toolTitle",
"toolOutput",
"mdHeading",
"mdLink",
"mdLinkUrl",
"mdCode",
"mdCodeBlock",
"mdCodeBlockBorder",
"mdQuote",
"mdQuoteBorder",
"mdHr",
"mdListBullet",
"toolDiffAdded",
"toolDiffRemoved",
"toolDiffContext",
"syntaxComment",
"syntaxKeyword",
"syntaxFunction",
"syntaxVariable",
"syntaxString",
"syntaxNumber",
"syntaxType",
"syntaxOperator",
"syntaxPunctuation",
"thinkingOff",
"thinkingMinimal",
"thinkingLow",
"thinkingMedium",
"thinkingHigh",
"thinkingXhigh",
"bashMode",
];
const OPTIONAL_THEME_TOKENS: [&str; 5] = [
"scrollbarTrack",
"scrollbarThumb",
"searchMatchBg",
"searchMatchText",
"thinkingMax",
];
const THEME_TOP_LEVEL_KEYS: [&str; 6] =
["$schema", "name", "appearance", "vars", "colors", "export"];
const THEME_EXPORT_KEYS: [&str; 3] = ["pageBg", "cardBg", "infoBg"];
#[derive(Debug, Clone)]
pub struct Theme {
pub name: String,
pub vars: HashMap<String, String>,
pub syntax_highlight: bool,
pub mermaid: bool,
pub latex: bool,
pub appearance: Option<Appearance>,
pub dim_keys: HashSet<String>,
}
impl Default for Theme {
fn default() -> Self {
Theme::load(DEFAULT_THEME_NAME)
}
}
impl Theme {
pub fn style(&self, key: &str, default: &str) -> Style {
self.with_dim(key, Style::default().fg(color(&self.get(key, default))))
}
pub fn style_bg(&self, key: &str, default: &str) -> Style {
Style::default().bg(color(&self.get(key, default)))
}
pub fn style_fg_bg(
&self,
fg_key: &str,
fg_default: &str,
bg_key: &str,
bg_default: &str,
) -> Style {
self.with_dim(
fg_key,
Style::default()
.fg(color(&self.get(fg_key, fg_default)))
.bg(color(&self.get(bg_key, bg_default))),
)
}
fn with_dim(&self, key: &str, style: Style) -> Style {
if self.dim_keys.contains(key) {
style.add_modifier(Modifier::DIM)
} else {
style
}
}
pub fn resolve_color(&self, spec: &str) -> Color {
if spec == "text" {
return Color::Reset;
}
let value = self.get(spec, "#d4d4d4");
if value.is_empty() {
Color::Reset
} else {
color(&value)
}
}
pub fn user_msg_style(&self) -> Style {
Style::default()
.fg(color(&self.get("accent", "#8abeb7")))
.bg(color(&self.get("userMessageBg", "#343541")))
}
fn detect_terminal_background() -> bool {
let Some(cfg) = std::env::var("COLORFGBG").ok() else {
return false;
};
let bg = cfg.rsplit(';').next().and_then(|s| s.parse::<u8>().ok());
bg.map(|b| b > 8).unwrap_or(false)
}
fn resolve_name(name: &str) -> String {
match name {
"auto" => {
if Self::detect_terminal_background() {
"light".to_string()
} else {
"dark".to_string()
}
}
other => other.to_string(),
}
}
pub fn load(name: &str) -> Theme {
let name = &Self::resolve_name(name);
if name == SYSTEM_THEME_NAME {
return Self::load_system();
}
let candidates = Self::theme_candidates(name);
let mut found = false;
let mut loaded_name = name.clone(); let mut vars: HashMap<String, String> = HashMap::new();
let mut explicit_appearance: Option<Appearance> = None;
for path in candidates {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(v) = serde_json::from_str::<Value>(&text) else {
continue;
};
if validate_theme_json(&path.display().to_string(), &v).is_err() {
continue;
}
ingest_theme_json(&v, &mut loaded_name, &mut vars, &mut explicit_appearance);
found = true;
break;
}
if !found {
let fallback = match name.as_str() {
"light" => embedded!("themes/light.json"),
_ => embedded!("themes/dark.json"),
};
if let Ok(v) = serde_json::from_str::<Value>(fallback) {
ingest_theme_json(&v, &mut loaded_name, &mut vars, &mut explicit_appearance);
}
}
let appearance = explicit_appearance
.or_else(|| detect_theme_appearance(&vars))
.or_else(|| Some(terminal_appearance_now()));
Theme {
name: loaded_name,
vars,
syntax_highlight: true,
mermaid: true,
latex: true,
appearance,
dim_keys: HashSet::new(),
}
}
fn load_system() -> Theme {
let colors = terminal_colors();
let generated = generate_system_theme_colors(&SystemThemeInput {
foreground: colors.foreground,
background: colors.background,
palette: colors.palette.clone(),
saturation: None,
})
.unwrap_or_else(|| {
generate_indexed_theme_colors(1.0, terminal_appearance_now())
});
let vars: HashMap<String, String> = generated
.colors
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect();
let dim_keys: HashSet<String> = generated.dim.iter().map(|k| k.to_string()).collect();
Theme {
name: SYSTEM_THEME_NAME.to_string(),
vars,
syntax_highlight: true,
mermaid: true,
latex: true,
appearance: Some(generated.appearance),
dim_keys,
}
}
pub fn appearance(&self) -> Appearance {
self.appearance.unwrap_or_else(terminal_appearance_now)
}
pub fn view(&self) -> ThemeView {
let appearance = match self.appearance() {
Appearance::Light => "light",
Appearance::Dark => "dark",
};
let colors = terminal_colors();
let guess_fg = if appearance == "light" {
"#000000"
} else {
"#ffffff"
};
let guess_bg = if appearance == "light" {
"#ffffff"
} else {
"#000000"
};
let default_fg = colors
.foreground
.map(|c| c.to_hex())
.unwrap_or_else(|| guess_fg.to_string());
let default_bg = colors
.background
.map(|c| c.to_hex())
.unwrap_or_else(|| guess_bg.to_string());
let mut resolved = BTreeMap::new();
for key in self.vars.keys() {
let value = self.get(key, "");
let concrete = match ansi_index(&value) {
Some(index) => indexed_to_rgb(index).to_hex(),
None => resolve_rgb(&value)
.map(|rgb| rgb.to_hex())
.unwrap_or_else(|| default_fg.clone()),
};
resolved.insert(key.clone(), concrete);
}
ThemeView {
name: self.name.clone(),
appearance: appearance.to_string(),
colors: resolved,
default_fg,
default_bg,
}
}
pub fn publish_view(&self) {
static PUBLISHED: OnceLock<Mutex<Option<String>>> = OnceLock::new();
let fingerprint = self.render_fingerprint();
let mut published = PUBLISHED.get_or_init(|| Mutex::new(None)).lock().unwrap();
if published.as_deref() == Some(fingerprint.as_str()) {
return;
}
*published = Some(fingerprint);
theme_view::set(self.view());
}
fn theme_candidates(name: &str) -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
let name_path = Path::new(name);
if name_path.extension().and_then(|e| e.to_str()) == Some("json") {
candidates.push(name_path.to_path_buf());
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(
cwd.join(PROJECT_SCOPE_NAME)
.join("themes")
.join(format!("{}.json", name)),
);
}
for raw in settings_manager::read_settings().themes {
let p = if raw == "~" {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
} else if let Some(rest) = raw.strip_prefix("~/") {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string())).join(rest)
} else {
PathBuf::from(raw)
};
if p.is_dir() {
candidates.push(p.join(format!("{}.json", name)));
} else if p.extension().and_then(|e| e.to_str()) == Some("json") {
candidates.push(p);
}
}
let themes_dir = settings_manager::agent_dir().join("themes");
candidates.push(themes_dir.join(format!("{}.json", name)));
candidates.push(themes_dir.join("dark.json"));
candidates
}
pub fn render_fingerprint(&self) -> String {
let mut keys: Vec<&String> = self.vars.keys().collect();
keys.sort();
let mut out = String::new();
for k in keys {
out.push_str(k);
out.push('=');
out.push_str(&self.vars[k]);
out.push('|');
}
format!("{}#{}", self.name, out)
}
pub fn get(&self, key: &str, default: &str) -> String {
let mut value = self
.vars
.get(key)
.cloned()
.unwrap_or_else(|| default.to_string());
for _ in 0..4 {
if let Some(resolved) = self.vars.get(&value) {
value = resolved.clone();
} else {
break;
}
}
value
}
}
pub fn available_theme_names(cwd: &str, agent_dir: &Path, include_discovered: bool) -> Vec<String> {
let mut names = HashSet::new();
if include_discovered {
for dir in theme_dirs(cwd, agent_dir) {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path)
&& let Ok(v) = serde_json::from_str::<Value>(&text)
{
if let Some(n) = v.get("name").and_then(|v| v.as_str()) {
names.insert(n.to_string());
} else if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
names.insert(stem.to_string());
}
}
}
}
}
names.insert("dark".to_string());
names.insert("light".to_string());
names.insert(SYSTEM_THEME_NAME.to_string());
let mut names: Vec<String> = names.into_iter().collect();
names.sort();
names
}
fn ingest_theme_json(
v: &Value,
loaded_name: &mut String,
vars: &mut HashMap<String, String>,
explicit_appearance: &mut Option<Appearance>,
) {
if let Some(n) = v.get("name").and_then(|v| v.as_str()) {
*loaded_name = n.to_string();
}
if let Some(appearance) = v.get("appearance").and_then(|v| v.as_str()) {
match appearance {
"dark" => *explicit_appearance = Some(Appearance::Dark),
"light" => *explicit_appearance = Some(Appearance::Light),
_ => {}
}
}
fn ingest_section(section: Option<&Value>, vars: &mut HashMap<String, String>) {
let Some(obj) = section.and_then(|v| v.as_object()) else {
return;
};
for (k, val) in obj {
match val {
Value::String(s) => {
if s == k && vars.contains_key(k) {
continue;
}
vars.insert(k.clone(), s.clone());
}
Value::Number(n) => {
if let Some(index) = n.as_u64().filter(|i| *i <= 255) {
vars.insert(k.clone(), format!("ansi:{index}"));
}
}
_ => {}
}
}
}
ingest_section(v.get("vars"), vars);
ingest_section(v.get("colors"), vars);
}
pub fn validate_theme_json(label: &str, json: &Value) -> std::result::Result<(), String> {
if let Some(name) = json.get("name").and_then(Value::as_str)
&& name.contains('/')
{
return Err(format!(
"Invalid theme name \"{name}\": theme names cannot contain \"/\" because it is reserved for automatic light/dark theme settings."
));
}
let Some(obj) = json.as_object() else {
return Err(format!(
"Invalid theme \"{label}\": expected an object with a \"colors\" map."
));
};
let mut missing_colors: Vec<&str> = Vec::new();
let mut other_errors: Vec<String> = Vec::new();
for key in obj.keys() {
if !THEME_TOP_LEVEL_KEYS.contains(&key.as_str()) {
other_errors.push(format!("/: unexpected property \"{key}\""));
}
}
match obj.get("name") {
Some(Value::String(_)) => {}
Some(_) => other_errors.push("/name: must be a string".to_string()),
None => other_errors.push("/name: is required".to_string()),
}
if let Some(appearance) = obj.get("appearance")
&& !matches!(appearance.as_str(), Some("dark" | "light"))
{
other_errors.push("/appearance: must be \"dark\" or \"light\"".to_string());
}
match obj.get("colors") {
Some(Value::Object(colors)) => {
for token in REQUIRED_THEME_TOKENS {
if !colors.contains_key(token) {
missing_colors.push(token);
}
}
for (key, value) in colors {
if !REQUIRED_THEME_TOKENS.contains(&key.as_str())
&& !OPTIONAL_THEME_TOKENS.contains(&key.as_str())
{
other_errors.push(format!("/colors/{key}: unexpected property"));
} else if !is_theme_color_value(value) {
other_errors.push(format!(
"/colors/{key}: must be a color string or a 256-color index (0-255)"
));
}
}
}
Some(_) => other_errors.push("/colors: must be an object".to_string()),
None => missing_colors.extend(REQUIRED_THEME_TOKENS),
}
if let Some(vars) = obj.get("vars") {
match vars.as_object() {
Some(map) => {
for (key, value) in map {
if !is_theme_color_value(value) {
other_errors.push(format!(
"/vars/{key}: must be a color string or a 256-color index (0-255)"
));
}
}
}
None => other_errors.push("/vars: must be an object".to_string()),
}
}
if let Some(export) = obj.get("export") {
match export.as_object() {
Some(map) => {
for (key, value) in map {
if !THEME_EXPORT_KEYS.contains(&key.as_str()) {
other_errors.push(format!("/export/{key}: unexpected property"));
} else if !is_theme_color_value(value) {
other_errors.push(format!(
"/export/{key}: must be a color string or a 256-color index (0-255)"
));
}
}
}
None => other_errors.push("/export: must be an object".to_string()),
}
}
if missing_colors.is_empty() && other_errors.is_empty() {
return Ok(());
}
let mut message = format!("Invalid theme \"{label}\":\n");
if !missing_colors.is_empty() {
let list = missing_colors
.iter()
.map(|token| format!(" - {token}"))
.collect::<Vec<_>>()
.join("\n");
message.push_str(&format!("\nMissing required color tokens:\n{list}"));
message.push_str("\n\nPlease add these colors to your theme's \"colors\" object.");
message.push_str("\nSee the built-in themes (dark.json, light.json) for reference values.");
}
if !other_errors.is_empty() {
message.push_str(&format!("\n\nOther errors:\n{}", other_errors.join("\n")));
}
Err(message)
}
fn is_theme_color_value(value: &Value) -> bool {
matches!(value, Value::String(_)) || value.as_u64().is_some_and(|n| n <= 255)
}
fn theme_dirs(cwd: &str, agent_dir: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let push = |path: PathBuf, dirs: &mut Vec<PathBuf>| {
if path.is_dir() && !dirs.contains(&path) {
dirs.push(path);
}
};
push(
PathBuf::from(cwd).join(PROJECT_SCOPE_NAME).join("themes"),
&mut dirs,
);
push(agent_dir.join("themes"), &mut dirs);
for raw in settings_manager::read_settings().themes {
push(expand_theme_path(&raw), &mut dirs);
}
dirs
}
fn expand_theme_path(raw: &str) -> PathBuf {
if raw == "~" {
home_dir()
} else if let Some(rest) = raw.strip_prefix("~/") {
home_dir().join(rest)
} else {
PathBuf::from(raw)
}
}
pub fn theme_load_warnings() -> Vec<String> {
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
let agent_dir = settings_manager::agent_dir();
let mut paths: Vec<PathBuf> = Vec::new();
for dir in theme_dirs(&cwd.to_string_lossy(), &agent_dir) {
if let Ok(entries) = std::fs::read_dir(&dir) {
paths.extend(entries.flatten().map(|e| e.path()));
}
}
for raw in settings_manager::read_settings().themes {
let path = expand_theme_path(&raw);
if path.extension().and_then(|e| e.to_str()) == Some("json") {
paths.push(path);
}
}
paths.retain(|p| p.extension().and_then(|e| e.to_str()) == Some("json"));
paths.sort();
paths.dedup();
let mut warnings: Vec<String> = Vec::new();
for path in paths {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let label = path.display().to_string();
match serde_json::from_str::<Value>(&text) {
Ok(v) => warnings.extend(validate_theme_json(&label, &v).err()),
Err(e) => warnings.push(format!("Failed to parse theme {label}: {e}")),
}
}
warnings
}
fn detect_theme_appearance(vars: &HashMap<String, String>) -> Option<Appearance> {
let lightness = |key: &str| -> Option<f64> {
let raw = vars.get(key)?;
let rgb = parse_color_str(raw).map(color_to_rgb)?;
Some(rgb_to_oklch(rgb).l)
};
let background = lightness("userMessageBg")
.or_else(|| lightness("toolPendingBg"))
.or_else(|| lightness("selectedBg"));
let foreground = lightness("text").or_else(|| lightness("muted"));
match (foreground, background) {
(Some(fg), Some(bg)) => Some(if bg < fg {
Appearance::Dark
} else {
Appearance::Light
}),
(None, Some(bg)) => Some(if bg < 0.5 {
Appearance::Dark
} else {
Appearance::Light
}),
(Some(fg), None) => Some(if fg > 0.5 {
Appearance::Dark
} else {
Appearance::Light
}),
(None, None) => None,
}
}
fn terminal_appearance_now() -> Appearance {
let colors = terminal_colors();
match colors.background {
Some(background) => crate::modes::interactive::system_theme::terminal_appearance(
background,
colors.foreground,
),
None => {
if Theme::detect_terminal_background() {
Appearance::Light
} else {
Appearance::Dark
}
}
}
}
pub fn fg(color: &str) -> String {
fg_with(color, true_color())
}
pub fn bg(color: &str) -> String {
bg_with(color, true_color())
}
pub fn color(color: &str) -> Color {
color_with(color, true_color())
}
fn true_color() -> bool {
detect_terminal_capabilities().true_color
}
fn fg_with(color: &str, true_color: bool) -> String {
if let Some(index) = ansi_index(color) {
return format!("\x1b[38;5;{}m", index);
}
let Some(rgb) = resolve_rgb(color) else {
return String::new();
};
if true_color {
format!("\x1b[38;2;{};{};{}m", rgb.r, rgb.g, rgb.b)
} else {
format!("\x1b[38;5;{}m", rgb_to_ansi256(rgb.r, rgb.g, rgb.b))
}
}
fn bg_with(color: &str, true_color: bool) -> String {
if let Some(index) = ansi_index(color) {
return format!("\x1b[48;5;{}m", index);
}
let Some(rgb) = resolve_rgb(color) else {
return String::new();
};
if true_color {
format!("\x1b[48;2;{};{};{}m", rgb.r, rgb.g, rgb.b)
} else {
format!("\x1b[48;5;{}m", rgb_to_ansi256(rgb.r, rgb.g, rgb.b))
}
}
fn color_with(color: &str, true_color: bool) -> Color {
if let Some(index) = ansi_index(color) {
return Color::Indexed(index);
}
match resolve_rgb(color) {
Some(rgb) if true_color => Color::Rgb(rgb.r, rgb.g, rgb.b),
Some(rgb) => Color::Indexed(rgb_to_ansi256(rgb.r, rgb.g, rgb.b)),
None => Color::Reset,
}
}
fn ansi_index(color: &str) -> Option<u8> {
color.strip_prefix("ansi:")?.trim().parse().ok()
}
fn resolve_rgb(color: &str) -> Option<crate::utils::color::Rgb> {
if let Some(index) = ansi_index(color) {
return Some(indexed_to_rgb(index));
}
parse_color_str(color).map(color_to_rgb)
}
fn closest_index(values: &[u8], target: u8) -> usize {
let mut best = 0usize;
let mut best_distance = u16::MAX;
for (i, v) in values.iter().enumerate() {
let distance = target.abs_diff(*v) as u16;
if distance < best_distance {
best = i;
best_distance = distance;
}
}
best
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
let (ri, gi, bi) = (
closest_index(&CUBE_VALUES, r),
closest_index(&CUBE_VALUES, g),
closest_index(&CUBE_VALUES, b),
);
let cube = [CUBE_VALUES[ri], CUBE_VALUES[gi], CUBE_VALUES[bi]];
let cube_index = 16 + 36 * ri as u16 + 6 * gi as u16 + bi as u16;
let gray = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64).round() as u8;
let gray_values: Vec<u8> = (0..24).map(|i| 8 + i * 10).collect();
let gray_offset = closest_index(&gray_values, gray);
let gray_value = gray_values[gray_offset];
let spread = r.max(g).max(b) - r.min(g).min(b);
let weighted = |c: [u8; 3]| -> f64 {
let d = |a: u8, b: u8| (a as f64 - b as f64).powi(2);
d(c[0], r) * 0.299 + d(c[1], g) * 0.587 + d(c[2], b) * 0.114
};
if spread < 10 && weighted([gray_value; 3]) < weighted(cube) {
return 232 + gray_offset as u8;
}
cube_index as u8
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_theme_json(name: &str) -> Value {
let mut colors = serde_json::Map::new();
for token in REQUIRED_THEME_TOKENS {
colors.insert(token.to_string(), serde_json::json!("#000000"));
}
serde_json::json!({ "name": name, "colors": colors })
}
fn valid_theme_json_with(name: &str, edit: impl FnOnce(&mut Value)) -> String {
let mut json = valid_theme_json(name);
edit(&mut json);
json.to_string()
}
#[test]
fn packaged_themes_pass_validation() {
for (name, text) in [
("dark", embedded!("themes/dark.json")),
("light", embedded!("themes/light.json")),
] {
let json: Value = serde_json::from_str(text).unwrap();
assert_eq!(
validate_theme_json(name, &json),
Ok(()),
"内置主题 {name} 应通过校验"
);
}
}
#[test]
fn theme_validation_reports_missing_tokens_and_bad_values() {
let json = serde_json::json!({"name": "x", "colors": {"accent": "#ffffff"}});
let err = validate_theme_json("x", &json).unwrap_err();
assert!(err.starts_with("Invalid theme \"x\":"), "{err}");
assert!(err.contains("Missing required color tokens:"), "{err}");
assert!(err.contains(" - border"), "{err}");
assert!(err.contains("Please add these colors"), "{err}");
assert!(
!err.contains(" - accent\n"),
"已给出的 token 不应列入缺失:{err}"
);
let json = valid_theme_json("a/b");
let err = validate_theme_json("/tmp/a.json", &json).unwrap_err();
assert!(err.contains("theme names cannot contain \"/\""), "{err}");
let mut json = valid_theme_json("x");
json["extra"] = serde_json::json!(1);
json["colors"]["nope"] = serde_json::json!("#000000");
json["colors"]["accent"] = serde_json::json!(256);
let err = validate_theme_json("x", &json).unwrap_err();
assert!(err.contains("Other errors:"), "{err}");
assert!(err.contains("/: unexpected property \"extra\""), "{err}");
assert!(err.contains("/colors/nope: unexpected property"), "{err}");
assert!(
err.contains("/colors/accent: must be a color string"),
"{err}"
);
let mut json = valid_theme_json("x");
json["colors"]["thinkingMax"] = serde_json::json!(199);
json["export"] = serde_json::json!({"pageBg": "#101010"});
assert_eq!(validate_theme_json("x", &json), Ok(()));
assert!(validate_theme_json("x", &serde_json::json!("nope")).is_err());
let err = validate_theme_json("x", &serde_json::json!({"name": "x"})).unwrap_err();
assert!(err.contains("Missing required color tokens:"), "{err}");
}
#[test]
fn invalid_theme_file_is_skipped_and_reported() {
let _ad = crate::test_support::AgentDirGuard::temp();
let dir = crate::core::settings_manager::agent_dir().join("themes");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("half.json"),
r##"{"name":"half","colors":{"accent":"#ffffff"}}"##,
)
.unwrap();
std::fs::write(dir.join("broken.json"), "{oops").unwrap();
std::fs::write(dir.join("good.json"), valid_theme_json("good").to_string()).unwrap();
assert_eq!(Theme::load("half").name, "dark");
assert_eq!(Theme::load("good").name, "good");
let warnings = theme_load_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("half.json") && w.contains("Missing required color tokens")),
"{warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("broken.json") && w.starts_with("Failed to parse theme")),
"{warnings:?}"
);
assert!(
!warnings.iter().any(|w| w.contains("good.json")),
"合法主题不应报警:{warnings:?}"
);
}
#[test]
fn view_resolves_keys_to_concrete_colors() {
let _ad = crate::test_support::AgentDirGuard::temp();
let dir = crate::core::settings_manager::agent_dir().join("themes");
std::fs::create_dir_all(&dir).unwrap();
let mut json = valid_theme_json("view-test");
json["appearance"] = serde_json::json!("light");
json["vars"] = serde_json::json!({ "neutral": "oklch(0.5 0 0)" });
json["colors"]["accent"] = serde_json::json!("neutral");
json["colors"]["text"] = serde_json::json!("");
json["colors"]["toolTitle"] = serde_json::json!("#123456");
std::fs::write(dir.join("view-test.json"), json.to_string()).unwrap();
let view = Theme::load("view-test").view();
assert_eq!(view.appearance, "light");
assert!(view.is_light());
assert_eq!(view.name, "view-test");
assert_eq!(view.color("accent"), Some("#636363"), "{:?}", view.colors);
assert_eq!(view.color("toolTitle"), Some("#123456"));
assert_eq!(view.color("text"), Some(view.default_fg.as_str()));
assert!(view.default_fg.starts_with('#'), "{:?}", view.default_fg);
assert_eq!(view.color("nope"), None);
}
#[test]
fn publish_view_exposes_current_theme() {
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::theme_view::clear();
let theme = Theme::load("dark");
theme.publish_view();
let published = crate::core::theme_view::get().expect("已发布");
assert_eq!(published.name, theme.name);
assert_eq!(published.colors.len(), theme.vars.len());
theme.publish_view();
assert_eq!(crate::core::theme_view::get(), Some(published));
crate::core::theme_view::clear();
}
#[test]
fn loads_packaged_dark_theme() {
let t = Theme::load("dark");
assert_eq!(t.name, "dark");
if t.vars.is_empty() {
return;
}
assert!(t.get("accent", "#000000") != "#000000");
}
#[test]
fn settings_themes_dir_appears_in_list_and_loads() {
let _ad = crate::test_support::AgentDirGuard::temp();
let dir = crate::core::settings_manager::agent_dir();
let themes_dir = dir.join("themes");
let _ = std::fs::create_dir_all(&themes_dir);
std::fs::write(
themes_dir.join("my-theme.json"),
valid_theme_json_with("my-theme", |json| {
json["colors"]["accent"] = serde_json::json!("#ff0000");
}),
)
.unwrap();
let names = available_theme_names("/nonexistent", &dir, true);
assert!(names.iter().any(|n| n == "my-theme"), "list: {:?}", names);
let t = Theme::load("my-theme");
assert_eq!(t.name, "my-theme");
let _ = std::fs::remove_file(themes_dir.join("my-theme.json"));
}
#[test]
fn theme_auto_resolves_from_terminal_background() {
unsafe { std::env::set_var("COLORFGBG", "15;15") };
assert!(Theme::detect_terminal_background(), "bg>8 判 light");
let t = Theme::load("auto");
assert!(t.name.contains("light"), "auto 解析为 {}", t.name);
unsafe { std::env::set_var("COLORFGBG", "0;0") };
assert!(!Theme::detect_terminal_background());
let t2 = Theme::load("auto");
assert!(t2.name.contains("dark"), "auto 解析为 {}", t2.name);
unsafe { std::env::remove_var("COLORFGBG") };
}
fn assert_resolved_color(t: &Theme, key: &str) -> String {
let v = t.get(key, "");
assert!(
!v.is_empty() && v != key,
"{key} 应解析为具体颜色,得到 {v:?}"
);
assert_ne!(color(&v), Color::Reset, "{key} 不应解析成终端默认色:{v:?}");
v
}
#[test]
fn color_output_honors_terminal_capability() {
assert_eq!(fg_with("#ff0000", true), "\x1b[38;2;255;0;0m");
assert_eq!(bg_with("#ff0000", true), "\x1b[48;2;255;0;0m");
assert_eq!(color_with("#ff0000", true), Color::Rgb(255, 0, 0));
assert_eq!(fg_with("#ff0000", false), "\x1b[38;5;196m");
assert_eq!(bg_with("#ff0000", false), "\x1b[48;5;196m");
assert_eq!(color_with("#ff0000", false), Color::Indexed(196));
assert_eq!(fg_with("nope", false), "");
assert_eq!(color_with("nope", false), Color::Reset);
}
#[test]
fn rgb_to_ansi256_matches_pi() {
assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
assert_eq!(rgb_to_ansi256(0, 255, 0), 46);
assert_eq!(rgb_to_ansi256(0, 0, 255), 21);
assert_eq!(rgb_to_ansi256(0x80, 0x80, 0x80), 244);
assert_eq!(rgb_to_ansi256(0xd4, 0xd4, 0xd4), 188);
}
#[test]
fn theme_json_supports_oklch_okhsl_and_short_hex() {
let _ad = crate::test_support::AgentDirGuard::temp();
let dir = crate::core::settings_manager::agent_dir();
let themes_dir = dir.join("themes");
let _ = std::fs::create_dir_all(&themes_dir);
std::fs::write(
themes_dir.join("color-syntax.json"),
valid_theme_json_with("color-syntax", |json| {
json["appearance"] = serde_json::json!("light");
json["vars"] = serde_json::json!({ "accent": "oklch(60% 0.15 250)" });
json["colors"]["accent"] = serde_json::json!("oklch(60% 0.15 250)");
json["colors"]["text"] = serde_json::json!("#abc");
json["colors"]["success"] = serde_json::json!("okhsl(200 60% 40%)");
json["colors"]["error"] = serde_json::json!("ansi:196");
json["colors"]["warning"] = serde_json::json!("");
}),
)
.unwrap();
let t = Theme::load("color-syntax");
assert_eq!(t.name, "color-syntax");
assert_eq!(t.appearance(), Appearance::Light, "显式 appearance 生效");
assert_eq!(t.get("accent", ""), "oklch(60% 0.15 250)");
assert_eq!(
color_with(&t.get("text", ""), true),
Color::Rgb(0xaa, 0xbb, 0xcc)
);
assert_eq!(
color_with("oklch(60% 0.15 250)", true),
Color::Rgb(0x27, 0x84, 0xd5)
);
assert_eq!(
color_with("okhsl(200 60% 40%)", true),
Color::Rgb(0x2c, 0x69, 0x6c)
);
assert_eq!(color("ansi:196"), Color::Indexed(196));
assert_eq!(fg_with("ansi:196", true), "\x1b[38;5;196m");
assert_eq!(bg_with("ansi:196", false), "\x1b[48;5;196m");
assert_eq!(t.resolve_color("warning"), Color::Reset);
assert_eq!(color(""), Color::Reset);
assert_eq!(color("nope"), Color::Reset);
let _ = std::fs::remove_file(themes_dir.join("color-syntax.json"));
}
#[test]
fn theme_appearance_detected_from_colors() {
let _ad = crate::test_support::AgentDirGuard::temp();
let dir = crate::core::settings_manager::agent_dir();
let themes_dir = dir.join("themes");
let _ = std::fs::create_dir_all(&themes_dir);
std::fs::write(
themes_dir.join("detect-dark.json"),
valid_theme_json_with("detect-dark", |json| {
json["colors"]["text"] = serde_json::json!("#e0e0e0");
json["colors"]["userMessageBg"] = serde_json::json!("#101010");
}),
)
.unwrap();
std::fs::write(
themes_dir.join("detect-light.json"),
valid_theme_json_with("detect-light", |json| {
json["colors"]["text"] = serde_json::json!("#202020");
json["colors"]["userMessageBg"] = serde_json::json!("#fefefe");
}),
)
.unwrap();
assert_eq!(Theme::load("detect-dark").appearance(), Appearance::Dark);
assert_eq!(Theme::load("detect-light").appearance(), Appearance::Light);
let _ = std::fs::remove_file(themes_dir.join("detect-dark.json"));
let _ = std::fs::remove_file(themes_dir.join("detect-light.json"));
}
#[test]
fn system_theme_uses_indexed_tier_without_terminal_colors() {
let _ad = crate::test_support::AgentDirGuard::temp();
let t = Theme::load(SYSTEM_THEME_NAME);
assert_eq!(t.name, SYSTEM_THEME_NAME);
assert!(!t.vars.is_empty());
assert_eq!(t.get("accent", ""), "ansi:5", "accent 用色族槽位");
assert_eq!(t.get("success", ""), "ansi:2", "success 属 green 族 → 槽 2");
assert_eq!(t.get("text", "#fff"), "", "正文用终端默认色");
assert_eq!(t.get("userMessageBg", "#fff"), "", "面板不设背景");
assert!(t.dim_keys.contains("muted"), "{:?}", t.dim_keys);
assert!(t.dim_keys.contains("dim"), "{:?}", t.dim_keys);
assert!(!t.dim_keys.contains("text"), "正文不算 dim");
}
#[test]
fn indexed_tier_marks_neutral_tokens_faint() {
let _ad = crate::test_support::AgentDirGuard::temp();
let theme = Theme::load(SYSTEM_THEME_NAME);
assert!(
theme
.style("muted", "#808080")
.add_modifier
.contains(Modifier::DIM),
"muted 应叠加 DIM"
);
assert!(
!theme
.style("accent", "#8abeb7")
.add_modifier
.contains(Modifier::DIM),
"accent 不应叠加 DIM"
);
}
#[test]
fn system_theme_name_is_listed() {
let _ad = crate::test_support::AgentDirGuard::temp();
let names = available_theme_names(
"/nonexistent",
&crate::core::settings_manager::agent_dir(),
true,
);
assert!(names.iter().any(|n| n == SYSTEM_THEME_NAME), "{names:?}");
}
#[test]
fn self_referencing_colors_resolve_to_vars_color() {
for name in ["dark", "light"] {
let t = Theme::load(name);
let text = assert_resolved_color(&t, "text");
assert_resolved_color(&t, "userMessageText");
assert_eq!(t.get("userMessageText", ""), text);
assert_resolved_color(&t, "accent");
assert_resolved_color(&t, "selectedBg");
assert_resolved_color(&t, "toolPendingBg");
assert_resolved_color(&t, "toolSuccessBg");
assert_resolved_color(&t, "toolErrorBg");
}
}
#[test]
fn builtin_themes_use_canonical_token_names() {
const CONSUMED: &[&str] = &[
"accent",
"border",
"success",
"error",
"warning",
"muted",
"dim",
"text",
"userMessageBg",
"userMessageText",
"customMessageBg",
"customMessageText",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
"toolTitle",
"toolOutput",
"mdCode",
"mdCodeBlock",
"mdListBullet",
"scrollbarThumb",
];
for name in ["dark", "light"] {
let t = Theme::load(name);
for key in CONSUMED {
assert_resolved_color(&t, key);
}
}
const RETIRED: &[&str] = &[
"userMsgBg",
"customMsgBg",
"mdMath",
"mdMathBlock",
"systemInfo",
"systemSuccess",
"systemWarning",
"systemError",
"dimGray",
"darkGray",
];
for name in ["dark", "light"] {
let t = Theme::load(name);
for key in RETIRED {
assert!(!t.vars.contains_key(*key), "{name}: 不应再定义旧键 {key}");
}
}
}
}