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 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 TEXT_BODY: Color = Color::White;
pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
pub const SELECTION_TEXT: Color = Color::White;
pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235);
pub const TEXT_PRIMARY: Color = TEXT_BODY;
pub const TEXT_MUTED: Color = TEXT_SECONDARY;
pub const TEXT_DIM: Color = TEXT_HINT;
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); #[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 ACCENT_REASONING_LIVE: Color = Color::Rgb(146, 198, 248); 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(205, 216, 228);
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 struct UiTheme {
pub name: &'static str,
pub composer_bg: Color,
pub selection_bg: Color,
pub header_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 const UI_THEME: UiTheme = UiTheme {
name: "whale",
composer_bg: DEEPSEEK_SLATE,
selection_bg: SELECTION_BG,
header_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,
};
#[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;
}
}
let term = std::env::var("TERM").unwrap_or_default();
let term = term.to_ascii_lowercase();
if term.contains("256") {
Self::Ansi256
} else if term.is_empty() || term == "dumb" {
Self::Ansi16
} else {
Self::TrueColor
}
}
}
#[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,
}
}
#[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(
blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
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 > g + 24 {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if 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,
SURFACE_REASONING, adapt_bg, adapt_color, blend, nearest_ansi16, pulse_brightness,
reasoning_surface_tint, rgb_to_ansi256,
};
use ratatui::style::Color;
#[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::LightCyan
);
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 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::Cyan);
assert_eq!(nearest_ansi16(106, 174, 242), 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());
}
}