use ratatui::style::{Color, Modifier, Style};
pub const BORDER: Color = Color::Indexed(60);
pub const BORDER_HI: Color = Color::Indexed(75);
pub const PANEL_TITLE: Color = Color::Indexed(179);
pub const LABEL: Color = Color::Indexed(75);
pub const VALUE: Color = Color::White;
pub const DIM: Color = Color::Indexed(245);
pub const DIMMER: Color = Color::Indexed(238);
pub const ACCENT: Color = Color::Indexed(75);
pub const COST_LOW: Color = Color::Indexed(114);
pub const COST_MID: Color = Color::Indexed(221);
pub const COST_HIGH: Color = Color::Indexed(203);
pub const CLAUDE: Color = Color::Indexed(173);
pub const OPENAI: Color = Color::Indexed(110);
pub const CURSOR: Color = Color::Indexed(141);
pub const OPENCODE: Color = Color::Indexed(117);
pub const PI: Color = Color::Indexed(150);
pub const GEMINI: Color = Color::Indexed(74);
pub const WINDSURF: Color = Color::Indexed(80);
pub const GLM: Color = Color::Indexed(108);
pub const KIMI: Color = Color::Indexed(216);
pub const DEEPSEEK: Color = Color::Indexed(105);
pub const QWEN: Color = Color::Indexed(180);
pub const GROK: Color = Color::Indexed(247);
pub const DESKTOP_CODE: Color = Color::Indexed(141);
pub const DESKTOP_COWORK: Color = Color::Indexed(183);
pub const SELECTED_BG: Color = Color::Indexed(236);
pub const FAILED_BG: Color = Color::Indexed(52);
pub const HEADER_BG: Color = Color::Indexed(236);
pub const MARKED_BG: Color = Color::Indexed(53);
pub fn label() -> Style {
Style::default().fg(LABEL).add_modifier(Modifier::BOLD)
}
pub fn value() -> Style {
Style::default().fg(VALUE).add_modifier(Modifier::BOLD)
}
pub fn dim() -> Style {
Style::default().fg(DIM)
}
pub fn title() -> Style {
Style::default()
.fg(PANEL_TITLE)
.add_modifier(Modifier::BOLD)
}
pub fn cost_color(value: f64) -> Color {
if value < 1.0 {
COST_LOW
} else if value < 10.0 {
COST_MID
} else {
COST_HIGH
}
}
pub fn model_color(model: &str) -> Color {
let m = model
.rsplit('/')
.next()
.unwrap_or(model)
.to_ascii_lowercase();
if m.starts_with("claude") {
CLAUDE
} else if m.starts_with("gpt") || m.starts_with('o') && m.len() > 1 {
OPENAI
} else if m.starts_with("gemini") {
GEMINI
} else if m.starts_with("glm") {
GLM
} else if m.starts_with("kimi") {
KIMI
} else if m.starts_with("deepseek") {
DEEPSEEK
} else if m.starts_with("qwen") {
QWEN
} else if m.starts_with("grok") {
GROK
} else {
DIM
}
}
pub fn age_color(last_active_secs: Option<i64>, running: bool) -> Color {
if running {
return Color::White;
}
let Some(secs) = last_active_secs else {
return DIMMER;
};
let hours = (secs.max(0) as f64) / 3600.0;
let ratio = ((1.0 + hours).ln() / (1.0 + 720.0f64).ln()).clamp(0.0, 1.0);
Color::Indexed(255 - (ratio * 17.0).round() as u8)
}
pub fn running_dot_color(age_secs: Option<i64>) -> Color {
match age_secs {
None => Color::Indexed(82),
Some(s) if s < 30 => Color::Indexed(82),
Some(s) if s < 300 => COST_LOW,
Some(s) if s < 1_800 => Color::Indexed(71),
Some(s) if s < 7_200 => DIM,
_ => DIMMER,
}
}
pub fn signal_color(signal: crate::hook::Signal) -> Color {
use crate::hook::Signal;
match signal {
Signal::NeedsInput => COST_HIGH,
Signal::Idle => COST_MID,
Signal::Busy | Signal::Started | Signal::Compacting => COST_LOW,
Signal::Ended => DIMMER,
}
}
pub fn cpu_color(cpu: f32) -> Color {
if cpu > 80.0 {
COST_HIGH
} else if cpu > 40.0 {
COST_MID
} else if cpu > 0.0 {
COST_LOW
} else {
DIM
}
}
pub fn context_color(percent: f64) -> Color {
if percent > 85.0 {
COST_HIGH
} else if percent > 65.0 {
COST_MID
} else {
COST_LOW
}
}
pub fn spark_cpu(ratio: f64) -> Color {
match ratio {
r if r <= 0.01 => Color::Indexed(236),
r if r <= 0.30 => Color::Indexed(71),
r if r <= 0.50 => Color::Indexed(114),
r if r <= 0.70 => Color::Indexed(186),
r if r <= 0.85 => Color::Indexed(221),
_ => Color::Indexed(203),
}
}
pub fn spark_spend(ratio: f64) -> Color {
match ratio {
r if r <= 0.01 => Color::Indexed(236),
r if r <= 0.25 => Color::Indexed(71),
r if r <= 0.50 => Color::Indexed(114),
r if r <= 0.75 => Color::Indexed(186),
r if r <= 0.85 => Color::Indexed(221),
_ => Color::Indexed(203),
}
}
pub fn spark_accent(ratio: f64) -> Color {
match ratio {
r if r <= 0.01 => Color::Indexed(238),
r if r <= 0.25 => Color::Indexed(60),
r if r <= 0.50 => Color::Indexed(68),
r if r <= 0.75 => Color::Indexed(75),
_ => Color::Indexed(117),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gradient {
Cpu,
Spend,
Accent,
}
impl Gradient {
pub fn color(&self, ratio: f64) -> Color {
match self {
Gradient::Cpu => spark_cpu(ratio),
Gradient::Spend => spark_spend(ratio),
Gradient::Accent => spark_accent(ratio),
}
}
pub fn baseline(&self) -> Color {
Color::Indexed(236)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cost_thresholds() {
assert_eq!(cost_color(0.5), COST_LOW);
assert_eq!(cost_color(5.0), COST_MID);
assert_eq!(cost_color(50.0), COST_HIGH);
}
#[test]
fn age_fades_monotonically() {
let fresh = age_color(Some(0), false);
let old = age_color(Some(30 * 86_400), false);
let (Color::Indexed(a), Color::Indexed(b)) = (fresh, old) else {
panic!("expected indexed colors");
};
assert!(a > b, "older sessions must render dimmer ({a} vs {b})");
assert_eq!(age_color(Some(99_999), true), Color::White);
}
#[test]
fn context_color_escalates() {
assert_eq!(context_color(10.0), COST_LOW);
assert_eq!(context_color(70.0), COST_MID);
assert_eq!(context_color(95.0), COST_HIGH);
}
}