nexus-chat 0.1.0

A local-first terminal chat app for deep research and multi-agent work
//! Semantic color roles, sourced from the user's active omarchy theme
//! (`~/.config/omarchy/current/theme/alacritty.toml`) when present, falling
//! back to the app's original hardcoded ANSI palette otherwise. `events.rs`
//! polls `current_link_target()` and calls `load()` again when the omarchy
//! theme symlink changes, so switching themes in omarchy updates the running
//! app without a restart.

use std::path::PathBuf;

use ratatui::style::Color;
use serde::Deserialize;

#[derive(Clone, Copy)]
pub struct Theme {
    pub bg: Color,
    pub fg: Color,
    pub fg_dim: Color,
    pub accent: Color,
    pub accent2: Color,
    pub border: Color,
    pub border_dim: Color,
    pub success: Color,
    pub warning: Color,
    pub error: Color,
    pub user_msg: Color,
    pub assistant_msg: Color,
    pub tool_msg: Color,
    pub research_msg: Color,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            bg: Color::Black,
            fg: Color::White,
            fg_dim: Color::DarkGray,
            accent: Color::Cyan,
            accent2: Color::Magenta,
            border: Color::Cyan,
            border_dim: Color::DarkGray,
            success: Color::Green,
            warning: Color::Yellow,
            error: Color::Red,
            user_msg: Color::Cyan,
            assistant_msg: Color::White,
            tool_msg: Color::Yellow,
            research_msg: Color::Magenta,
        }
    }
}

#[derive(Deserialize)]
struct Alacritty {
    colors: ColorsSection,
}

#[derive(Deserialize)]
struct ColorsSection {
    primary: Primary,
    normal: Palette,
    bright: Palette,
}

#[derive(Deserialize)]
struct Primary {
    background: String,
    foreground: String,
}

#[derive(Deserialize)]
struct Palette {
    black: String,
    red: String,
    green: String,
    yellow: String,
    magenta: String,
    cyan: String,
}

fn hex_to_color(s: &str) -> Option<Color> {
    let s = s.trim_start_matches('#');
    if s.len() != 6 {
        return None;
    }
    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
    Some(Color::Rgb(r, g, b))
}

impl Theme {
    fn from_alacritty(a: &Alacritty) -> Self {
        let fallback = Self::default();
        let n = &a.colors.normal;
        let b = &a.colors.bright;
        Self {
            bg: hex_to_color(&a.colors.primary.background).unwrap_or(fallback.bg),
            fg: hex_to_color(&a.colors.primary.foreground).unwrap_or(fallback.fg),
            fg_dim: hex_to_color(&b.black).unwrap_or(fallback.fg_dim),
            accent: hex_to_color(&n.cyan).unwrap_or(fallback.accent),
            accent2: hex_to_color(&n.magenta).unwrap_or(fallback.accent2),
            border: hex_to_color(&n.cyan).unwrap_or(fallback.border),
            border_dim: hex_to_color(&b.black).unwrap_or(fallback.border_dim),
            success: hex_to_color(&n.green).unwrap_or(fallback.success),
            warning: hex_to_color(&n.yellow).unwrap_or(fallback.warning),
            error: hex_to_color(&n.red).unwrap_or(fallback.error),
            user_msg: hex_to_color(&n.cyan).unwrap_or(fallback.user_msg),
            assistant_msg: hex_to_color(&a.colors.primary.foreground)
                .unwrap_or(fallback.assistant_msg),
            tool_msg: hex_to_color(&n.yellow).unwrap_or(fallback.tool_msg),
            research_msg: hex_to_color(&n.magenta).unwrap_or(fallback.research_msg),
        }
    }
}

fn omarchy_current_dir() -> Option<PathBuf> {
    Some(std::env::home_dir()?.join(".config/omarchy/current"))
}

/// Linear blend between two colors at `t` in 0.0..=1.0 (used for tinted
/// message cards — terminals have no alpha, so the tint is pre-mixed into
/// the theme background). Falls back to `a` for non-RGB colors.
pub fn blend(a: Color, b: Color, t: f32) -> Color {
    let mix = |x: u8, y: u8| {
        let (xf, yf) = (f32::from(x), f32::from(y));
        // t is caller-clamped to 0.0..=1.0; the result is always in 0..=255.
        #[allow(
            clippy::cast_possible_truncation,
            clippy::cast_possible_wrap,
            clippy::cast_sign_loss
        )]
        {
            (xf + (yf - xf) * t).round() as u8
        }
    };
    match (a, b) {
        (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
            Color::Rgb(mix(r1, r2), mix(g1, g2), mix(b1, b2))
        }
        _ => a,
    }
}

/// The omarchy theme symlink's current target (e.g. `.../themes/retropc`), or
/// `None` off-omarchy. Compared across polls to detect a theme switch.
pub fn current_link_target() -> Option<PathBuf> {
    std::fs::read_link(omarchy_current_dir()?).ok()
}

/// Load the active omarchy theme's colors, or the built-in default palette if
/// omarchy isn't installed or the theme file doesn't parse.
pub fn load() -> Theme {
    omarchy_current_dir()
        .map(|d| d.join("theme/alacritty.toml"))
        .and_then(|p| std::fs::read_to_string(p).ok())
        .and_then(|s| toml::from_str::<Alacritty>(&s).ok())
        .map(|a| Theme::from_alacritty(&a))
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_omarchy_alacritty_toml() {
        let toml = r##"
[colors.primary]
background = "#0B0C16"
foreground = "#ddf7ff"

[colors.normal]
black = "#0B0C16"
red = "#50f872"
green = "#4fe88f"
yellow = "#50f7d4"
blue = "#829dd4"
magenta = "#86a7df"
cyan = "#7cf8f7"
white = "#85E1FB"

[colors.bright]
black = "#6a6e95"
red = "#85ff9d"
green = "#9cf7c2"
yellow = "#a4ffec"
blue = "#c4d2ed"
magenta = "#cddbf4"
cyan = "#d1fffe"
white = "#ddf7ff"
"##;
        let a: Alacritty = toml::from_str(toml).unwrap();
        let theme = Theme::from_alacritty(&a);
        assert_eq!(theme.accent, Color::Rgb(0x7c, 0xf8, 0xf7));
        assert_eq!(theme.fg, Color::Rgb(0xdd, 0xf7, 0xff));
    }

    #[test]
    fn missing_file_falls_back_to_default() {
        // No omarchy config in the test sandbox (or whatever's there parses) —
        // either way `load()` must not panic and must return *some* theme.
        let _ = load();
    }

    #[test]
    fn malformed_toml_falls_back_to_default() {
        let bad = "not = [valid";
        let parsed: Option<Alacritty> = toml::from_str(bad).ok();
        assert!(parsed.is_none());
    }
}