cctop 0.2.0

An htop-like terminal monitor for AI coding agent sessions (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, Windsurf)
//! Colour palette. btop-inspired: dark ground, few saturated accents, and
//! meaning carried by hue (green = idle/cheap, amber = warming, red = hot).

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);
// Open-weight vendors seen through custom providers. Chosen to stay clear of
// each other and of the harness hues above.
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);
/// Wash behind a tool call that reported an error. Dark enough to stay behind
/// the row's own foreground colours rather than competing with them.
pub const FAILED_BG: Color = Color::Indexed(52);
pub const HEADER_BG: Color = Color::Indexed(236);
/// Tint for rows the user has marked for a batch action, so marks read at a
/// glance even when nothing is selected. Kept dim so it stays behind the data.
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)
}

/// Green under a dollar, amber under ten, red beyond.
pub fn cost_color(value: f64) -> Color {
    if value < 1.0 {
        COST_LOW
    } else if value < 10.0 {
        COST_MID
    } else {
        COST_HIGH
    }
}

/// Hue by vendor so mixed lists stay scannable.
///
/// Matched on the leaf of the name: a model reached through a custom provider
/// carries its whole route (`canopywave/zai/glm-5.1`), and the vendor that
/// prefix names is the gateway, not whose model it is.
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
    }
}

/// Running sessions fade from bright to dim as they go quiet, so the eye lands
/// on genuinely active work. Log-scaled: minutes matter, weeks don't.
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)
}

/// How a running session's dot reads: bright green when freshly active, fading
/// toward grey as it goes quiet. The shape already carries running-vs-stopped,
/// so hue may dim without the dot disappearing into the background.
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,
    }
}

/// The colour of a state an agent reported about itself through its hooks.
///
/// Ranked by what it costs to miss it, not by how active the agent is: a held
/// question blocks work until it is answered, a finished turn waits patiently,
/// and an agent that is busy needs nothing from anyone. Shared so the dot beside
/// an agent and the word for its state never disagree.
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
    }
}

/// Context pressure, measured against the auto-compact threshold.
pub fn context_color(percent: f64) -> Color {
    if percent > 85.0 {
        COST_HIGH
    } else if percent > 65.0 {
        COST_MID
    } else {
        COST_LOW
    }
}

/// Gradient for CPU-like series: green through amber to red.
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),
    }
}

/// Gradient for spend series, same shape as CPU but tuned a shade cooler.
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),
    }
}

/// Blue-only gradient for series where "high" isn't bad (memory, tokens).
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),
    }
}

/// Which gradient a series should use.
#[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),
        }
    }

    /// Colour for an empty slot, so the baseline stays visible.
    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);
    }
}