use ratatui::style::Color;
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);
#[allow(dead_code)]
pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212);
#[allow(dead_code)]
pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138);
pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (246, 248, 251); pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 242, 248); pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (219, 229, 240); pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (255, 246, 214); pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (223, 247, 231); pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (254, 229, 229); pub const LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (15, 23, 42); pub const LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (51, 65, 85); pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (100, 116, 139); pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (30, 41, 59); pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (139, 161, 184); pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (207, 224, 247); pub const GRAYSCALE_SURFACE_RGB: (u8, u8, u8) = (10, 10, 10); pub const GRAYSCALE_PANEL_RGB: (u8, u8, u8) = (18, 18, 18); pub const GRAYSCALE_ELEVATED_RGB: (u8, u8, u8) = (31, 31, 31); pub const GRAYSCALE_REASONING_RGB: (u8, u8, u8) = (38, 38, 38); pub const GRAYSCALE_SUCCESS_RGB: (u8, u8, u8) = (34, 34, 34); pub const GRAYSCALE_ERROR_RGB: (u8, u8, u8) = (42, 42, 42); pub const GRAYSCALE_TEXT_BODY_RGB: (u8, u8, u8) = (236, 236, 236); pub const GRAYSCALE_TEXT_MUTED_RGB: (u8, u8, u8) = (180, 180, 180); pub const GRAYSCALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 138, 138); pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62);
pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127);
pub const DEEPSEEK_BLUE: Color = Color::Rgb(
DEEPSEEK_BLUE_RGB.0,
DEEPSEEK_BLUE_RGB.1,
DEEPSEEK_BLUE_RGB.2,
);
pub const DEEPSEEK_SKY: Color =
Color::Rgb(DEEPSEEK_SKY_RGB.0, DEEPSEEK_SKY_RGB.1, DEEPSEEK_SKY_RGB.2);
#[allow(dead_code)]
pub const DEEPSEEK_AQUA: Color = Color::Rgb(
DEEPSEEK_AQUA_RGB.0,
DEEPSEEK_AQUA_RGB.1,
DEEPSEEK_AQUA_RGB.2,
);
#[allow(dead_code)]
pub const DEEPSEEK_NAVY: Color = Color::Rgb(
DEEPSEEK_NAVY_RGB.0,
DEEPSEEK_NAVY_RGB.1,
DEEPSEEK_NAVY_RGB.2,
);
pub const DEEPSEEK_INK: Color =
Color::Rgb(DEEPSEEK_INK_RGB.0, DEEPSEEK_INK_RGB.1, DEEPSEEK_INK_RGB.2);
pub const DEEPSEEK_SLATE: Color = Color::Rgb(
DEEPSEEK_SLATE_RGB.0,
DEEPSEEK_SLATE_RGB.1,
DEEPSEEK_SLATE_RGB.2,
);
pub const DEEPSEEK_RED: Color =
Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
pub const LIGHT_SURFACE: Color = Color::Rgb(
LIGHT_SURFACE_RGB.0,
LIGHT_SURFACE_RGB.1,
LIGHT_SURFACE_RGB.2,
);
pub const LIGHT_PANEL: Color = Color::Rgb(LIGHT_PANEL_RGB.0, LIGHT_PANEL_RGB.1, LIGHT_PANEL_RGB.2);
pub const LIGHT_ELEVATED: Color = Color::Rgb(
LIGHT_ELEVATED_RGB.0,
LIGHT_ELEVATED_RGB.1,
LIGHT_ELEVATED_RGB.2,
);
pub const LIGHT_REASONING: Color = Color::Rgb(
LIGHT_REASONING_RGB.0,
LIGHT_REASONING_RGB.1,
LIGHT_REASONING_RGB.2,
);
pub const LIGHT_SUCCESS: Color = Color::Rgb(
LIGHT_SUCCESS_RGB.0,
LIGHT_SUCCESS_RGB.1,
LIGHT_SUCCESS_RGB.2,
);
pub const LIGHT_ERROR: Color = Color::Rgb(LIGHT_ERROR_RGB.0, LIGHT_ERROR_RGB.1, LIGHT_ERROR_RGB.2);
pub const LIGHT_TEXT_BODY: Color = Color::Rgb(
LIGHT_TEXT_BODY_RGB.0,
LIGHT_TEXT_BODY_RGB.1,
LIGHT_TEXT_BODY_RGB.2,
);
pub const LIGHT_TEXT_MUTED: Color = Color::Rgb(
LIGHT_TEXT_MUTED_RGB.0,
LIGHT_TEXT_MUTED_RGB.1,
LIGHT_TEXT_MUTED_RGB.2,
);
pub const LIGHT_TEXT_HINT: Color = Color::Rgb(
LIGHT_TEXT_HINT_RGB.0,
LIGHT_TEXT_HINT_RGB.1,
LIGHT_TEXT_HINT_RGB.2,
);
pub const LIGHT_TEXT_SOFT: Color = Color::Rgb(
LIGHT_TEXT_SOFT_RGB.0,
LIGHT_TEXT_SOFT_RGB.1,
LIGHT_TEXT_SOFT_RGB.2,
);
pub const LIGHT_BORDER: Color =
Color::Rgb(LIGHT_BORDER_RGB.0, LIGHT_BORDER_RGB.1, LIGHT_BORDER_RGB.2);
pub const LIGHT_SELECTION_BG: Color = Color::Rgb(
LIGHT_SELECTION_RGB.0,
LIGHT_SELECTION_RGB.1,
LIGHT_SELECTION_RGB.2,
);
pub const GRAYSCALE_SURFACE: Color = Color::Rgb(
GRAYSCALE_SURFACE_RGB.0,
GRAYSCALE_SURFACE_RGB.1,
GRAYSCALE_SURFACE_RGB.2,
);
pub const GRAYSCALE_PANEL: Color = Color::Rgb(
GRAYSCALE_PANEL_RGB.0,
GRAYSCALE_PANEL_RGB.1,
GRAYSCALE_PANEL_RGB.2,
);
pub const GRAYSCALE_ELEVATED: Color = Color::Rgb(
GRAYSCALE_ELEVATED_RGB.0,
GRAYSCALE_ELEVATED_RGB.1,
GRAYSCALE_ELEVATED_RGB.2,
);
pub const GRAYSCALE_REASONING: Color = Color::Rgb(
GRAYSCALE_REASONING_RGB.0,
GRAYSCALE_REASONING_RGB.1,
GRAYSCALE_REASONING_RGB.2,
);
pub const GRAYSCALE_SUCCESS: Color = Color::Rgb(
GRAYSCALE_SUCCESS_RGB.0,
GRAYSCALE_SUCCESS_RGB.1,
GRAYSCALE_SUCCESS_RGB.2,
);
pub const GRAYSCALE_ERROR: Color = Color::Rgb(
GRAYSCALE_ERROR_RGB.0,
GRAYSCALE_ERROR_RGB.1,
GRAYSCALE_ERROR_RGB.2,
);
pub const GRAYSCALE_TEXT_BODY: Color = Color::Rgb(
GRAYSCALE_TEXT_BODY_RGB.0,
GRAYSCALE_TEXT_BODY_RGB.1,
GRAYSCALE_TEXT_BODY_RGB.2,
);
pub const GRAYSCALE_TEXT_MUTED: Color = Color::Rgb(
GRAYSCALE_TEXT_MUTED_RGB.0,
GRAYSCALE_TEXT_MUTED_RGB.1,
GRAYSCALE_TEXT_MUTED_RGB.2,
);
pub const GRAYSCALE_TEXT_HINT: Color = Color::Rgb(
GRAYSCALE_TEXT_HINT_RGB.0,
GRAYSCALE_TEXT_HINT_RGB.1,
GRAYSCALE_TEXT_HINT_RGB.2,
);
pub const GRAYSCALE_TEXT_SOFT: Color = Color::Rgb(
GRAYSCALE_TEXT_SOFT_RGB.0,
GRAYSCALE_TEXT_SOFT_RGB.1,
GRAYSCALE_TEXT_SOFT_RGB.2,
);
pub const GRAYSCALE_BORDER: Color = Color::Rgb(
GRAYSCALE_BORDER_RGB.0,
GRAYSCALE_BORDER_RGB.1,
GRAYSCALE_BORDER_RGB.2,
);
pub const GRAYSCALE_SELECTION_BG: Color = Color::Rgb(
GRAYSCALE_SELECTION_RGB.0,
GRAYSCALE_SELECTION_RGB.1,
GRAYSCALE_SELECTION_RGB.2,
);
pub const TEXT_BODY: Color = Color::Rgb(226, 232, 240); pub const TEXT_SECONDARY: Color = Color::Rgb(177, 190, 207); pub const TEXT_HINT: Color = Color::Rgb(135, 151, 171); pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
pub const SELECTION_TEXT: Color = Color::White;
pub const TEXT_SOFT: Color = Color::Rgb(217, 226, 238); pub const TEXT_REASONING: Color = Color::Rgb(211, 170, 112);
pub const TEXT_PRIMARY: Color = TEXT_BODY;
pub const TEXT_MUTED: Color = TEXT_SECONDARY;
pub const TEXT_DIM: Color = TEXT_HINT;
pub const USER_BODY: Color = Color::Rgb(74, 222, 128); pub const LIGHT_USER_BODY: Color = Color::Rgb(21, 128, 61);
pub const BORDER_COLOR: Color =
Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
#[allow(dead_code)]
pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; #[allow(dead_code)]
pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; #[allow(dead_code)]
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); #[allow(dead_code)]
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); #[allow(dead_code)]
pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); #[allow(dead_code)]
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); pub const SURFACE_REASONING_TINT: Color = Color::Rgb(16, 24, 37); #[allow(dead_code)]
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); #[allow(dead_code)]
pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); #[allow(dead_code)]
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); #[allow(dead_code)]
pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); #[allow(dead_code)]
pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(224, 153, 72); pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(191, 205, 220);
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); pub const STATUS_ERROR: Color = DEEPSEEK_RED;
#[allow(dead_code)]
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60);
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
#[allow(dead_code)]
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteMode {
Dark,
Light,
Grayscale,
}
impl PaletteMode {
#[must_use]
pub fn from_colorfgbg(value: &str) -> Option<Self> {
let bg = value
.split(';')
.rev()
.find_map(|part| part.parse::<u16>().ok())?;
Some(if bg >= 8 { Self::Light } else { Self::Dark })
}
#[must_use]
pub fn detect() -> Self {
std::env::var("COLORFGBG")
.ok()
.and_then(|value| Self::from_colorfgbg(&value))
.unwrap_or(Self::Dark)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UiTheme {
pub name: &'static str,
pub mode: PaletteMode,
pub surface_bg: Color,
pub panel_bg: Color,
pub elevated_bg: Color,
pub composer_bg: Color,
pub selection_bg: Color,
pub header_bg: Color,
pub footer_bg: Color,
pub mode_agent: Color,
pub mode_yolo: Color,
pub mode_plan: Color,
pub status_ready: Color,
pub status_working: Color,
pub status_warning: Color,
pub text_dim: Color,
pub text_hint: Color,
pub text_muted: Color,
pub text_body: Color,
pub text_soft: Color,
pub border: Color,
}
pub const UI_THEME: UiTheme = UiTheme {
name: "whale",
mode: PaletteMode::Dark,
surface_bg: DEEPSEEK_INK,
panel_bg: DEEPSEEK_SLATE,
elevated_bg: SURFACE_ELEVATED,
composer_bg: DEEPSEEK_SLATE,
selection_bg: SELECTION_BG,
header_bg: DEEPSEEK_INK,
footer_bg: DEEPSEEK_INK,
mode_agent: MODE_AGENT,
mode_yolo: MODE_YOLO,
mode_plan: MODE_PLAN,
status_ready: TEXT_MUTED,
status_working: DEEPSEEK_SKY,
status_warning: STATUS_WARNING,
text_dim: TEXT_DIM,
text_hint: TEXT_HINT,
text_muted: TEXT_MUTED,
text_body: TEXT_BODY,
text_soft: TEXT_SOFT,
border: BORDER_COLOR,
};
pub const LIGHT_UI_THEME: UiTheme = UiTheme {
name: "whale-light",
mode: PaletteMode::Light,
surface_bg: LIGHT_SURFACE,
panel_bg: LIGHT_PANEL,
elevated_bg: LIGHT_ELEVATED,
composer_bg: LIGHT_PANEL,
selection_bg: LIGHT_SELECTION_BG,
header_bg: LIGHT_SURFACE,
footer_bg: LIGHT_SURFACE,
mode_agent: DEEPSEEK_BLUE,
mode_yolo: DEEPSEEK_RED,
mode_plan: Color::Rgb(180, 83, 9),
status_ready: LIGHT_TEXT_MUTED,
status_working: DEEPSEEK_BLUE,
status_warning: Color::Rgb(180, 83, 9),
text_dim: LIGHT_TEXT_HINT,
text_hint: LIGHT_TEXT_HINT,
text_muted: LIGHT_TEXT_MUTED,
text_body: LIGHT_TEXT_BODY,
text_soft: LIGHT_TEXT_SOFT,
border: LIGHT_BORDER,
};
pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
name: "grayscale",
mode: PaletteMode::Grayscale,
surface_bg: GRAYSCALE_SURFACE,
panel_bg: GRAYSCALE_PANEL,
elevated_bg: GRAYSCALE_ELEVATED,
composer_bg: GRAYSCALE_PANEL,
selection_bg: GRAYSCALE_SELECTION_BG,
header_bg: GRAYSCALE_SURFACE,
footer_bg: GRAYSCALE_SURFACE,
mode_agent: GRAYSCALE_TEXT_SOFT,
mode_yolo: GRAYSCALE_TEXT_BODY,
mode_plan: GRAYSCALE_TEXT_MUTED,
status_ready: GRAYSCALE_TEXT_MUTED,
status_working: GRAYSCALE_TEXT_SOFT,
status_warning: GRAYSCALE_TEXT_BODY,
text_dim: GRAYSCALE_TEXT_HINT,
text_hint: GRAYSCALE_TEXT_HINT,
text_muted: GRAYSCALE_TEXT_MUTED,
text_body: GRAYSCALE_TEXT_BODY,
text_soft: GRAYSCALE_TEXT_SOFT,
border: GRAYSCALE_BORDER,
};
impl UiTheme {
#[must_use]
pub fn for_mode(mode: PaletteMode) -> Self {
match mode {
PaletteMode::Dark => UI_THEME,
PaletteMode::Light => LIGHT_UI_THEME,
PaletteMode::Grayscale => GRAYSCALE_UI_THEME,
}
}
#[must_use]
pub fn detect() -> Self {
Self::for_mode(PaletteMode::detect())
}
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match normalize_theme_name(value)? {
"system" => Some(Self::detect()),
"dark" => Some(Self::for_mode(PaletteMode::Dark)),
"light" => Some(Self::for_mode(PaletteMode::Light)),
"grayscale" => Some(Self::for_mode(PaletteMode::Grayscale)),
_ => None,
}
}
#[must_use]
pub fn with_background_color(mut self, color: Color) -> Self {
self.surface_bg = color;
self.header_bg = color;
self.footer_bg = color;
self
}
}
#[must_use]
pub fn normalize_theme_name(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "auto" | "system" | "default" => Some("system"),
"dark" | "whale" | "whale-dark" => Some("dark"),
"light" | "whale-light" => Some("light"),
"grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white"
| "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"),
_ => None,
}
}
#[must_use]
pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str {
match mode {
PaletteMode::Dark => "dark",
PaletteMode::Light => "light",
PaletteMode::Grayscale => "grayscale",
}
}
#[must_use]
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
}
#[must_use]
pub fn parse_hex_rgb_color(value: &str) -> Option<Color> {
let hex = value.trim().strip_prefix('#').unwrap_or(value.trim());
if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
#[must_use]
pub fn normalize_hex_rgb_color(value: &str) -> Option<String> {
hex_rgb_string(parse_hex_rgb_color(value)?)
}
#[must_use]
pub fn hex_rgb_string(color: Color) -> Option<String> {
let Color::Rgb(r, g, b) = color else {
return None;
};
Some(format!("#{r:02x}{g:02x}{b:02x}"))
}
#[must_use]
pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> Color {
match mode {
PaletteMode::Dark => color,
PaletteMode::Light => adapt_fg_for_light_palette(color),
PaletteMode::Grayscale => adapt_fg_for_grayscale_palette(color),
}
}
#[must_use]
pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color {
match mode {
PaletteMode::Dark => color,
PaletteMode::Light => adapt_bg_for_light_palette(color),
PaletteMode::Grayscale => adapt_bg_for_grayscale_palette(color),
}
}
fn adapt_fg_for_light_palette(color: Color) -> Color {
if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
LIGHT_TEXT_BODY
} else if color == TEXT_SECONDARY || color == TEXT_MUTED {
LIGHT_TEXT_MUTED
} else if color == TEXT_HINT || color == TEXT_DIM {
LIGHT_TEXT_HINT
} else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
LIGHT_TEXT_SOFT
} else if color == BORDER_COLOR {
LIGHT_BORDER
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
DEEPSEEK_BLUE
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
Color::Rgb(146, 64, 14)
} else if color == ACCENT_TOOL_ISSUE {
Color::Rgb(159, 18, 57)
} else if color == DIFF_ADDED {
Color::Rgb(22, 101, 52)
} else if color == USER_BODY {
LIGHT_USER_BODY
} else {
color
}
}
fn adapt_bg_for_light_palette(color: Color) -> Color {
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
LIGHT_SURFACE
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
{
LIGHT_PANEL
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
LIGHT_ELEVATED
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
{
LIGHT_REASONING
} else if color == SURFACE_SUCCESS {
LIGHT_SUCCESS
} else if color == SURFACE_ERROR {
LIGHT_ERROR
} else if color == DIFF_ADDED_BG {
LIGHT_SUCCESS
} else if color == DIFF_DELETED_BG {
LIGHT_ERROR
} else if color == SELECTION_BG {
LIGHT_SELECTION_BG
} else {
color
}
}
fn adapt_fg_for_grayscale_palette(color: Color) -> Color {
if color == Color::Reset {
return color;
}
if color == TEXT_BODY
|| color == SELECTION_TEXT
|| color == LIGHT_TEXT_BODY
|| color == Color::White
|| color == DEEPSEEK_RED
|| color == STATUS_ERROR
|| color == MODE_YOLO
{
GRAYSCALE_TEXT_BODY
} else if color == TEXT_SOFT
|| color == TEXT_TOOL_OUTPUT
|| color == LIGHT_TEXT_SOFT
|| color == TEXT_ACCENT
|| color == DEEPSEEK_SKY
|| color == DEEPSEEK_BLUE
|| color == ACCENT_TOOL_LIVE
|| color == STATUS_SUCCESS
|| color == STATUS_INFO
|| color == MODE_AGENT
{
GRAYSCALE_TEXT_SOFT
} else if color == TEXT_SECONDARY
|| color == TEXT_MUTED
|| color == LIGHT_TEXT_MUTED
|| color == TEXT_REASONING
|| color == ACCENT_REASONING_LIVE
|| color == STATUS_WARNING
|| color == MODE_PLAN
|| color == USER_BODY
|| color == LIGHT_USER_BODY
|| color == DIFF_ADDED
{
GRAYSCALE_TEXT_MUTED
} else if color == TEXT_HINT
|| color == TEXT_DIM
|| color == LIGHT_TEXT_HINT
|| color == BORDER_COLOR
|| color == LIGHT_BORDER
|| color == ACCENT_TOOL_ISSUE
{
GRAYSCALE_TEXT_HINT
} else {
match color {
Color::Black => GRAYSCALE_TEXT_BODY,
Color::Gray | Color::DarkGray => GRAYSCALE_TEXT_HINT,
Color::Red
| Color::LightRed
| Color::Green
| Color::LightGreen
| Color::Yellow
| Color::LightYellow
| Color::Blue
| Color::LightBlue
| Color::Magenta
| Color::LightMagenta
| Color::Cyan
| Color::LightCyan => GRAYSCALE_TEXT_SOFT,
Color::Rgb(r, g, b) => grayscale_fg_from_luma(luma(r, g, b)),
Color::Indexed(_) => color,
_ => color,
}
}
}
fn adapt_bg_for_grayscale_palette(color: Color) -> Color {
if color == Color::Reset {
return color;
}
if color == DEEPSEEK_INK || color == BACKGROUND_DARK || color == LIGHT_SURFACE {
GRAYSCALE_SURFACE
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
|| color == LIGHT_PANEL
{
GRAYSCALE_PANEL
} else if color == SURFACE_ELEVATED
|| color == SURFACE_TOOL_ACTIVE
|| color == LIGHT_ELEVATED
|| color == SELECTION_BG
|| color == LIGHT_SELECTION_BG
{
GRAYSCALE_ELEVATED
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
|| color == LIGHT_REASONING
{
GRAYSCALE_REASONING
} else if color == SURFACE_SUCCESS || color == DIFF_ADDED_BG || color == LIGHT_SUCCESS {
GRAYSCALE_SUCCESS
} else if color == SURFACE_ERROR || color == DIFF_DELETED_BG || color == LIGHT_ERROR {
GRAYSCALE_ERROR
} else {
match color {
Color::Black => GRAYSCALE_SURFACE,
Color::White | Color::Gray => GRAYSCALE_ELEVATED,
Color::DarkGray => GRAYSCALE_PANEL,
Color::Red
| Color::LightRed
| Color::Green
| Color::LightGreen
| Color::Yellow
| Color::LightYellow
| Color::Blue
| Color::LightBlue
| Color::Magenta
| Color::LightMagenta
| Color::Cyan
| Color::LightCyan => GRAYSCALE_ELEVATED,
Color::Rgb(r, g, b) => grayscale_bg_from_luma(luma(r, g, b)),
Color::Indexed(_) => color,
_ => color,
}
}
}
fn grayscale_fg_from_luma(luma: u8) -> Color {
match luma {
0..=95 => GRAYSCALE_TEXT_HINT,
96..=155 => GRAYSCALE_TEXT_MUTED,
156..=215 => GRAYSCALE_TEXT_SOFT,
_ => GRAYSCALE_TEXT_BODY,
}
}
fn grayscale_bg_from_luma(luma: u8) -> Color {
match luma {
0..=28 => GRAYSCALE_SURFACE,
29..=95 => GRAYSCALE_PANEL,
96..=185 => GRAYSCALE_ELEVATED,
_ => GRAYSCALE_REASONING,
}
}
fn luma(r: u8, g: u8, b: u8) -> u8 {
(((u16::from(r) * 299) + (u16::from(g) * 587) + (u16::from(b) * 114)) / 1000) as u8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorDepth {
Ansi16,
Ansi256,
TrueColor,
}
impl ColorDepth {
#[must_use]
pub fn detect() -> Self {
if let Ok(ct) = std::env::var("COLORTERM") {
let ct = ct.to_ascii_lowercase();
if ct.contains("truecolor") || ct.contains("24bit") {
return Self::TrueColor;
}
}
if std::env::var_os("WT_SESSION").is_some() {
return Self::TrueColor;
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let term_program = term_program.to_ascii_lowercase();
if term_program.contains("iterm")
|| term_program.contains("wezterm")
|| term_program.contains("vscode")
|| term_program.contains("warp")
{
return Self::TrueColor;
}
}
let term = std::env::var("TERM").unwrap_or_default();
let term = term.to_ascii_lowercase();
if term.contains("truecolor") || term.contains("24bit") {
Self::TrueColor
} else if term.contains("256") {
Self::Ansi256
} else if term.is_empty() || term == "dumb" {
Self::Ansi16
} else {
Self::Ansi256
}
}
}
#[allow(dead_code)]
#[must_use]
pub fn adapt_color(color: Color, depth: ColorDepth) -> Color {
match (color, depth) {
(_, ColorDepth::TrueColor) => color,
(Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
(Color::Rgb(r, g, b), ColorDepth::Ansi16) => nearest_ansi16(r, g, b),
_ => color,
}
}
#[allow(dead_code)]
#[must_use]
pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color {
match (color, depth) {
(_, ColorDepth::TrueColor) => color,
(Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
(_, ColorDepth::Ansi256) => color,
(_, ColorDepth::Ansi16) => Color::Reset,
}
}
#[allow(dead_code)]
#[must_use]
pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
match (fg, bg) {
(Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => {
let mix = |a: u8, b: u8| -> u8 {
let a = f32::from(a);
let b = f32::from(b);
(b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8
};
Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb))
}
_ => fg,
}
}
#[must_use]
pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
match depth {
ColorDepth::Ansi16 => None,
_ => Some(adapt_bg(SURFACE_REASONING_TINT, depth)),
}
}
#[must_use]
pub fn pulse_brightness(color: Color, now_ms: u64) -> Color {
let phase = (now_ms % 2000) as f32 / 2000.0;
let t = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5; let alpha = 0.30 + t * 0.70; match color {
Color::Rgb(r, g, b) => {
let s = |c: u8| -> u8 { ((f32::from(c)) * alpha).round().clamp(0.0, 255.0) as u8 };
Color::Rgb(s(r), s(g), s(b))
}
other => other,
}
}
#[allow(dead_code)]
fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color {
let lum = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
if lum < 24 {
return Color::Black;
}
if r > 220 && g > 220 && b > 220 {
return Color::White;
}
let bright = lum > 144;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
if max.saturating_sub(min) < 16 {
return if bright { Color::Gray } else { Color::DarkGray };
}
if r >= g && r >= b {
if g > b + 24 {
if bright {
Color::LightYellow
} else {
Color::Yellow
}
} else if b > r.saturating_sub(24) {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if bright {
Color::LightRed
} else {
Color::Red
}
} else if g >= r && g >= b {
if b > r + 24 {
if bright {
Color::LightCyan
} else {
Color::Cyan
}
} else if bright {
Color::LightGreen
} else {
Color::Green
}
} else if r.saturating_add(48) >= b && r > g + 24 {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if g.saturating_add(48) >= b && g > r + 24 {
if bright {
Color::LightCyan
} else {
Color::Cyan
}
} else if bright {
Color::LightBlue
} else {
Color::Blue
}
}
#[allow(dead_code)]
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
fn nearest_cube_level(channel: u8) -> usize {
CUBE_LEVELS
.iter()
.enumerate()
.min_by_key(|(_, level)| channel.abs_diff(**level))
.map(|(idx, _)| idx)
.unwrap_or(0)
}
fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
let dr = i32::from(a.0) - i32::from(b.0);
let dg = i32::from(a.1) - i32::from(b.1);
let db = i32::from(a.2) - i32::from(b.2);
(dr * dr + dg * dg + db * db) as u32
}
let ri = nearest_cube_level(r);
let gi = nearest_cube_level(g);
let bi = nearest_cube_level(b);
let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]);
let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8;
let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8;
let gray_i = if avg <= 8 {
0
} else if avg >= 238 {
23
} else {
((u16::from(avg) - 8 + 5) / 10).min(23) as u8
};
let gray = 8 + 10 * gray_i;
let gray_index = 232 + gray_i;
if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) {
gray_index
} else {
cube_index
}
}
#[cfg(test)]
mod tests {
use super::{
ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY,
DEEPSEEK_SLATE, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING,
GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT,
GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING,
LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode,
SURFACE_REASONING, SURFACE_REASONING_TINT, TEXT_BODY, TEXT_HINT, TEXT_REASONING,
TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg, adapt_bg_for_palette_mode, adapt_color,
adapt_fg_for_palette_mode, blend, nearest_ansi16, normalize_hex_rgb_color,
normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint,
rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings,
};
use ratatui::style::Color;
#[test]
fn palette_mode_parses_colorfgbg_background_slot() {
assert_eq!(
PaletteMode::from_colorfgbg("0;15"),
Some(PaletteMode::Light)
);
assert_eq!(PaletteMode::from_colorfgbg("15;0"), Some(PaletteMode::Dark));
assert_eq!(
PaletteMode::from_colorfgbg("7;default;15"),
Some(PaletteMode::Light)
);
assert_eq!(PaletteMode::from_colorfgbg("not-a-color"), None);
}
#[test]
fn ui_theme_selects_light_variant() {
let theme = super::UiTheme::for_mode(PaletteMode::Light);
assert_eq!(theme, LIGHT_UI_THEME);
assert_eq!(theme.surface_bg, LIGHT_SURFACE);
assert_eq!(theme.text_body, LIGHT_TEXT_BODY);
}
#[test]
fn ui_theme_selects_grayscale_variant() {
let theme = super::UiTheme::for_mode(PaletteMode::Grayscale);
assert_eq!(theme, GRAYSCALE_UI_THEME);
assert_eq!(theme.surface_bg, GRAYSCALE_SURFACE);
assert_eq!(theme.panel_bg, GRAYSCALE_PANEL);
assert_eq!(theme.text_body, GRAYSCALE_TEXT_BODY);
}
#[test]
fn theme_names_normalize_common_grayscale_aliases() {
assert_eq!(normalize_theme_name("system"), Some("system"));
assert_eq!(normalize_theme_name("default"), Some("system"));
assert_eq!(normalize_theme_name("whale"), Some("dark"));
assert_eq!(normalize_theme_name("black-white"), Some("grayscale"));
assert_eq!(normalize_theme_name("mono"), Some("grayscale"));
assert_eq!(normalize_theme_name("solarized"), None);
assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale");
}
#[test]
fn light_palette_has_quiet_layer_separation() {
assert_eq!(LIGHT_SURFACE, Color::Rgb(246, 248, 251));
assert_eq!(LIGHT_PANEL, Color::Rgb(236, 242, 248));
assert_eq!(LIGHT_ELEVATED, Color::Rgb(219, 229, 240));
assert_eq!(LIGHT_BORDER, Color::Rgb(139, 161, 184));
assert_ne!(LIGHT_SURFACE, LIGHT_PANEL);
assert_ne!(LIGHT_PANEL, LIGHT_ELEVATED);
}
#[test]
fn dark_palette_uses_soft_body_text_and_warm_reasoning() {
assert_eq!(TEXT_BODY, Color::Rgb(226, 232, 240));
assert_eq!(TEXT_REASONING, Color::Rgb(211, 170, 112));
assert_eq!(ACCENT_REASONING_LIVE, Color::Rgb(224, 153, 72));
assert_ne!(TEXT_REASONING, TEXT_TOOL_OUTPUT);
assert_ne!(TEXT_BODY, Color::White);
}
#[test]
fn ui_theme_applies_custom_background_to_base_surfaces() {
let custom = Color::Rgb(26, 27, 38);
let theme = super::UiTheme::for_mode(PaletteMode::Dark).with_background_color(custom);
assert_eq!(theme.surface_bg, custom);
assert_eq!(theme.header_bg, custom);
assert_eq!(theme.footer_bg, custom);
assert_eq!(
theme.composer_bg, UI_THEME.composer_bg,
"custom background must not erase panel contrast"
);
}
#[test]
fn hex_rgb_color_parser_accepts_hashless_and_normalizes() {
assert_eq!(parse_hex_rgb_color("#1a1B26"), Some(Color::Rgb(26, 27, 38)));
assert_eq!(parse_hex_rgb_color("1a1b26"), Some(Color::Rgb(26, 27, 38)));
assert_eq!(
normalize_hex_rgb_color("#1A1B26").as_deref(),
Some("#1a1b26")
);
assert_eq!(parse_hex_rgb_color("#123"), None);
assert_eq!(parse_hex_rgb_color("#zzzzzz"), None);
}
#[test]
fn light_palette_maps_dark_surfaces_and_text() {
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Light),
LIGHT_SURFACE
);
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Light),
LIGHT_PANEL
);
assert_eq!(
adapt_fg_for_palette_mode(Color::White, LIGHT_SURFACE, PaletteMode::Light),
LIGHT_TEXT_BODY
);
assert_eq!(
adapt_fg_for_palette_mode(TEXT_HINT, LIGHT_SURFACE, PaletteMode::Light),
LIGHT_TEXT_HINT
);
}
#[test]
fn grayscale_palette_maps_brand_hues_to_neutral_roles() {
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Grayscale),
GRAYSCALE_SURFACE
);
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Grayscale),
GRAYSCALE_PANEL
);
assert_eq!(
adapt_bg_for_palette_mode(SURFACE_REASONING, PaletteMode::Grayscale),
GRAYSCALE_REASONING
);
assert_eq!(
adapt_fg_for_palette_mode(DEEPSEEK_SKY, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
GRAYSCALE_TEXT_SOFT
);
assert_eq!(
adapt_fg_for_palette_mode(DEEPSEEK_RED, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
GRAYSCALE_TEXT_BODY
);
assert_eq!(
adapt_fg_for_palette_mode(TEXT_HINT, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
GRAYSCALE_TEXT_HINT
);
}
#[test]
fn ui_theme_from_settings_applies_theme_and_background() {
let theme = ui_theme_from_settings("grayscale", Some("#111111"));
assert_eq!(theme.mode, PaletteMode::Grayscale);
assert_eq!(theme.surface_bg, Color::Rgb(17, 17, 17));
assert_eq!(theme.header_bg, Color::Rgb(17, 17, 17));
assert_eq!(theme.footer_bg, Color::Rgb(17, 17, 17));
assert_eq!(theme.panel_bg, GRAYSCALE_PANEL);
assert_eq!(theme.elevated_bg, GRAYSCALE_ELEVATED);
assert_eq!(theme.border, GRAYSCALE_BORDER);
}
#[test]
fn adapt_color_passes_through_truecolor() {
let c = Color::Rgb(53, 120, 229);
assert_eq!(adapt_color(c, ColorDepth::TrueColor), c);
}
#[test]
fn adapt_color_maps_rgb_to_indexed_on_ansi256() {
let c = Color::Rgb(53, 120, 229);
assert!(matches!(
adapt_color(c, ColorDepth::Ansi256),
Color::Indexed(_)
));
}
#[test]
fn adapt_bg_maps_rgb_to_indexed_on_ansi256() {
assert!(matches!(
adapt_bg(SURFACE_REASONING, ColorDepth::Ansi256),
Color::Indexed(_)
));
}
#[test]
fn adapt_color_drops_to_named_on_ansi16() {
assert_eq!(
adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16),
Color::LightBlue
);
assert_eq!(adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), Color::Red);
}
#[test]
fn adapt_bg_disables_tints_on_ansi16() {
assert_eq!(
adapt_bg(SURFACE_REASONING, ColorDepth::Ansi16),
Color::Reset
);
assert_eq!(
adapt_bg(SURFACE_REASONING, ColorDepth::TrueColor),
SURFACE_REASONING
);
}
#[test]
fn reasoning_tint_is_none_on_ansi16() {
assert!(reasoning_surface_tint(ColorDepth::Ansi16).is_none());
assert!(reasoning_surface_tint(ColorDepth::TrueColor).is_some());
assert!(matches!(
reasoning_surface_tint(ColorDepth::Ansi256),
Some(Color::Indexed(_))
));
}
#[test]
fn light_palette_maps_reasoning_tint_to_light_surface() {
assert_eq!(
blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
SURFACE_REASONING_TINT
);
assert_eq!(
adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light),
LIGHT_REASONING
);
assert_eq!(
adapt_bg_for_palette_mode(
reasoning_surface_tint(ColorDepth::TrueColor).expect("truecolor tint"),
PaletteMode::Light,
),
LIGHT_REASONING
);
}
#[test]
fn blend_at_zero_returns_bg_at_one_returns_fg() {
let fg = Color::Rgb(200, 100, 50);
let bg = Color::Rgb(0, 0, 0);
assert_eq!(blend(fg, bg, 0.0), bg);
assert_eq!(blend(fg, bg, 1.0), fg);
}
#[test]
fn blend_at_half_is_midpoint() {
let mid = blend(Color::Rgb(200, 100, 0), Color::Rgb(0, 0, 0), 0.5);
assert_eq!(mid, Color::Rgb(100, 50, 0));
}
#[test]
fn pulse_brightness_swings_within_envelope() {
let src = ACCENT_REASONING_LIVE;
let mut min_r = u8::MAX;
let mut max_r = 0u8;
for ms in (0u64..2000).step_by(50) {
if let Color::Rgb(r, _, _) = pulse_brightness(src, ms) {
min_r = min_r.min(r);
max_r = max_r.max(r);
}
}
let Color::Rgb(src_r, _, _) = src else {
panic!("expected RGB");
};
let lower = (f32::from(src_r) * 0.30).round() as u8;
assert!(min_r <= lower + 2, "trough too high: {min_r}");
assert!(max_r + 2 >= src_r, "crest too low: {max_r}");
}
#[test]
fn pulse_passes_named_colors_unchanged() {
assert_eq!(pulse_brightness(Color::Reset, 0), Color::Reset);
assert_eq!(pulse_brightness(Color::Cyan, 1234), Color::Cyan);
}
#[test]
fn nearest_ansi16_routes_known_brand_colors() {
assert_eq!(nearest_ansi16(53, 120, 229), Color::Blue);
assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue);
assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue);
assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan);
assert_eq!(nearest_ansi16(226, 80, 96), Color::Red);
assert_eq!(nearest_ansi16(11, 21, 38), Color::Black);
}
#[test]
fn rgb_to_ansi256_uses_stable_extended_palette() {
assert!(rgb_to_ansi256(53, 120, 229) >= 16);
assert!(rgb_to_ansi256(11, 21, 38) >= 16);
}
#[test]
fn color_depth_detect_is_safe_without_env() {
let _ = ColorDepth::detect();
let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect());
}
}