Skip to main content

ai_agent/utils/
system_theme.rs

1/**
2 * Terminal dark/light mode detection for the 'auto' theme setting.
3 *
4 * Detection is based on the terminal's actual background color (queried via
5 * OSC 11 by systemThemeWatcher.ts) rather than the OS appearance setting --
6 * a dark terminal on a light-mode OS should still resolve to 'dark'.
7 *
8 * The detected theme is cached module-level so callers can resolve 'auto'
9 * without awaiting the async OSC round-trip. The cache is seeded from
10 * $COLORFGBG (synchronous, set by some terminals at launch) and then
11 * updated by the watcher once the OSC 11 response arrives.
12 */
13use crate::utils::config::ThemeSetting;
14use once_cell::sync::Lazy;
15use std::env;
16use std::sync::Mutex;
17
18/// System theme detected from terminal
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SystemTheme {
21    Dark,
22    Light,
23}
24
25static CACHED_SYSTEM_THEME: Lazy<Mutex<Option<SystemTheme>>> = Lazy::new(|| Mutex::new(None));
26
27/// Get the current terminal theme. Cached after first detection; the watcher
28/// updates the cache on live changes.
29pub fn get_system_theme_name() -> SystemTheme {
30    let mut cached = CACHED_SYSTEM_THEME.lock().unwrap();
31    if cached.is_none() {
32        *cached = detect_from_color_fg_bg().or(Some(SystemTheme::Dark));
33    }
34    cached.unwrap_or(SystemTheme::Dark)
35}
36
37/// Update the cached terminal theme. Called by the watcher when the OSC 11
38/// query returns so non-React call sites stay in sync.
39pub fn set_cached_system_theme(theme: SystemTheme) {
40    let mut cached = CACHED_SYSTEM_THEME.lock().unwrap();
41    *cached = Some(theme);
42}
43
44/// Resolve a ThemeSetting (which may be 'auto') to a concrete theme name.
45pub fn resolve_theme_setting(setting: &ThemeSetting) -> &'static str {
46    match setting {
47        ThemeSetting::System => {
48            if get_system_theme_name() == SystemTheme::Light {
49                "light"
50            } else {
51                "dark"
52            }
53        }
54        ThemeSetting::Dark => "dark",
55        ThemeSetting::Light => "light",
56    }
57}
58
59/// Parse an OSC color response data string into a theme.
60///
61/// Accepts XParseColor formats returned by OSC 10/11 queries:
62/// - `rgb:R/G/B` where each component is 1-4 hex digits (each scaled to
63///   [0, 16^n - 1] for n digits). This is what xterm, iTerm2, Terminal.app,
64///   Ghostty, kitty, Alacritty, etc. return.
65/// - `#RRGGBB` / `#RRRRGGGGBBBB` (rare, but cheap to accept).
66///
67/// Returns None for unrecognized formats so callers can fall back.
68pub fn theme_from_osc_color(data: &str) -> Option<SystemTheme> {
69    let rgb = parse_osc_rgb(data)?;
70    // ITU-R BT.709 relative luminance. Midpoint split: > 0.5 is light.
71    let luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
72    if luminance > 0.5 {
73        Some(SystemTheme::Light)
74    } else {
75        Some(SystemTheme::Dark)
76    }
77}
78
79#[derive(Debug, Clone, Copy)]
80struct Rgb {
81    r: f64,
82    g: f64,
83    b: f64,
84}
85
86fn parse_osc_rgb(data: &str) -> Option<Rgb> {
87    // rgb:RRRR/GGGG/BBBB -- each component is 1-4 hex digits.
88    // Some terminals append an alpha component (rgba:.../.../.../...); ignore it.
89    let data_lower = data.to_lowercase();
90    if let Some(caps) = regex::Regex::new(r"^rgba?:([0-9a-f]{1,4})/([0-9a-f]{1,4})/([0-9a-f]{1,4})")
91        .ok()?
92        .captures(&data_lower)
93    {
94        return Some(Rgb {
95            r: hex_component(&caps[1]),
96            g: hex_component(&caps[2]),
97            b: hex_component(&caps[3]),
98        });
99    }
100    // #RRGGBB or #RRRRGGGGBBBB -- split into three equal hex runs.
101    if let Some(caps) = regex::Regex::new(r"^#([0-9a-f]+)$")
102        .ok()?
103        .captures(&data_lower)
104    {
105        let hex = &caps[1];
106        if hex.len() % 3 == 0 {
107            let n = hex.len() / 3;
108            return Some(Rgb {
109                r: hex_component(&hex[..n]),
110                g: hex_component(&hex[n..2 * n]),
111                b: hex_component(&hex[2 * n..]),
112            });
113        }
114    }
115    None
116}
117
118/// Normalize a 1-4 digit hex component to [0, 1].
119fn hex_component(hex: &str) -> f64 {
120    let max = 16_f64.powi(hex.len() as i32) - 1.0;
121    let value = u64::from_str_radix(hex, 16).unwrap_or(0) as f64;
122    value / max
123}
124
125/// Read $COLORFGBG for a synchronous initial guess before the OSC 11
126/// round-trip completes. Format is `fg;bg` (or `fg;other;bg`) where values
127/// are ANSI color indices. rxvt convention: bg 0-6 or 8 are dark; bg 7
128/// and 9-15 are light. Only set by some terminals (rxvt-family, Konsole,
129/// iTerm2 with the option enabled), so this is a best-effort hint.
130fn detect_from_color_fg_bg() -> Option<SystemTheme> {
131    let colorfgbg = env::var("COLORFGBG").ok()?;
132    let parts: Vec<&str> = colorfgbg.split(';').collect();
133    let bg = parts.last()?;
134    if bg.is_empty() {
135        return None;
136    }
137    let bg_num: i32 = bg.parse().ok()?;
138    if bg_num < 0 || bg_num > 15 {
139        return None;
140    }
141    // 0-6 and 8 are dark ANSI colors; 7 (white) and 9-15 (bright) are light.
142    if bg_num <= 6 || bg_num == 8 {
143        Some(SystemTheme::Dark)
144    } else {
145        Some(SystemTheme::Light)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_theme_from_osc_color_rgb() {
155        // Dark background (low luminance)
156        assert_eq!(theme_from_osc_color("rgb:0/0/0"), Some(SystemTheme::Dark));
157        // Light background (high luminance)
158        assert_eq!(
159            theme_from_osc_color("rgb:ff/ff/ff"),
160            Some(SystemTheme::Light)
161        );
162        // Mid-point (0x80 = 128/255 ≈ 0.502 > 0.5, so light)
163        assert_eq!(
164            theme_from_osc_color("rgb:80/80/80"),
165            Some(SystemTheme::Light)
166        );
167    }
168
169    #[test]
170    fn test_theme_from_osc_color_hash() {
171        assert_eq!(theme_from_osc_color("#000000"), Some(SystemTheme::Dark));
172        assert_eq!(theme_from_osc_color("#ffffff"), Some(SystemTheme::Light));
173    }
174
175    #[test]
176    fn test_resolve_theme_setting() {
177        assert_eq!(resolve_theme_setting(&ThemeSetting::Dark), "dark");
178        assert_eq!(resolve_theme_setting(&ThemeSetting::Light), "light");
179        // System resolves to current system theme
180        let _ = resolve_theme_setting(&ThemeSetting::System);
181    }
182}