Skip to main content

rab/agent/ui/
theme.rs

1use crate::tui::Theme;
2use crate::tui::components::markdown::{MarkdownTheme, StyleFn, create_highlight_fn};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::atomic::AtomicU16;
8
9// ── Color Types ──────────────────────────────────────────────────
10
11/// A color value in a theme config: hex string "#ff0000", var reference "accent",
12/// 256-color index (0-255), or empty string for default terminal color.
13#[derive(Debug, Clone, Deserialize)]
14#[serde(untagged)]
15pub enum ColorValue {
16    HexOrVar(String),
17    Index(u8),
18}
19
20/// Theme JSON structure matching pi's theme format.
21#[derive(Debug, Clone, Deserialize)]
22pub struct ThemeConfig {
23    pub name: String,
24    #[serde(default)]
25    pub vars: HashMap<String, String>,
26    pub colors: HashMap<String, ColorValue>,
27}
28
29/// Terminal color capability mode.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum ColorMode {
32    TrueColor,
33    Ansi256,
34}
35
36// ── RabTheme ─────────────────────────────────────────────────────
37
38/// The concrete theme used by the rab UI.
39/// Wraps resolved ANSI escape codes for foregrounds, backgrounds, and text styling.
40#[derive(Debug, Clone)]
41pub struct RabTheme {
42    pub name: String,
43    mode: ColorMode,
44    fg_ansi: HashMap<String, String>,
45    bg_ansi: HashMap<String, String>,
46}
47
48impl RabTheme {
49    /// Parse a hex color like "#ff0000" into (r,g,b).
50    fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
51        let hex = hex.trim_start_matches('#');
52        if hex.len() != 6 {
53            return None;
54        }
55        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
56        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
57        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
58        Some((r, g, b))
59    }
60
61    /// Convert an (r,g,b) to the closest 256-color ANSI index.
62    fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
63        const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
64        const GRAY_VALUES: [u8; 24] = [
65            8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188,
66            198, 208, 218, 228, 238,
67        ];
68
69        let find_closest = |value: u8, table: &[u8]| -> usize {
70            let mut min_dist = u16::MAX;
71            let mut min_idx = 0;
72            for (i, &v) in table.iter().enumerate() {
73                let dist = value.abs_diff(v);
74                if (dist as u16) < min_dist {
75                    min_dist = dist as u16;
76                    min_idx = i;
77                }
78            }
79            min_idx
80        };
81
82        let ri = find_closest(r, &CUBE_VALUES);
83        let gi = find_closest(g, &CUBE_VALUES);
84        let bi = find_closest(b, &CUBE_VALUES);
85        let cube_index = 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8;
86
87        // Check grayscale
88        let gray = (r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000;
89        let gi = find_closest(gray as u8, &GRAY_VALUES);
90        let gray_index = 232 + gi as u8;
91
92        let spread = r.max(g).max(b) - r.min(g).min(b);
93        if spread < 10 {
94            return gray_index;
95        }
96        cube_index
97    }
98
99    /// Build the ANSI escape code for a foreground color.
100    fn fg_escape(color: &str, mode: ColorMode) -> String {
101        if color.is_empty() {
102            return "\x1b[39m".to_string();
103        }
104        if let Ok(idx) = color.parse::<u8>() {
105            return format!("\x1b[38;5;{}m", idx);
106        }
107        if let Some((r, g, b)) = Self::hex_to_rgb(color) {
108            return match mode {
109                ColorMode::TrueColor => format!("\x1b[38;2;{};{};{}m", r, g, b),
110                ColorMode::Ansi256 => format!("\x1b[38;5;{}m", Self::rgb_to_256(r, g, b)),
111            };
112        }
113        "\x1b[39m".to_string()
114    }
115
116    /// Build the ANSI escape code for a background color.
117    fn bg_escape(color: &str, mode: ColorMode) -> String {
118        if color.is_empty() {
119            return "\x1b[49m".to_string();
120        }
121        if let Ok(idx) = color.parse::<u8>() {
122            return format!("\x1b[48;5;{}m", idx);
123        }
124        if let Some((r, g, b)) = Self::hex_to_rgb(color) {
125            return match mode {
126                ColorMode::TrueColor => format!("\x1b[48;2;{};{};{}m", r, g, b),
127                ColorMode::Ansi256 => format!("\x1b[48;5;{}m", Self::rgb_to_256(r, g, b)),
128            };
129        }
130        "\x1b[49m".to_string()
131    }
132
133    /// Resolve variable references and color values to hex strings.
134    fn resolve_colors(config: &ThemeConfig) -> HashMap<String, String> {
135        let mut resolved: HashMap<String, String> = HashMap::new();
136
137        for (name, value) in &config.colors {
138            let hex = match value {
139                ColorValue::HexOrVar(s) => {
140                    if s.starts_with('#') {
141                        s.clone()
142                    } else if let Some(v) = config.vars.get(s) {
143                        v.clone()
144                    } else {
145                        s.clone()
146                    }
147                }
148                ColorValue::Index(idx) => idx.to_string(),
149            };
150            resolved.insert(name.clone(), hex);
151        }
152        resolved
153    }
154
155    /// Background color keys (matching pi's bgColorKeys set).
156    const BG_KEYS: &'static [&'static str] = &[
157        "selectedBg",
158        "userMessageBg",
159        "customMessageBg",
160        "toolPendingBg",
161        "toolSuccessBg",
162        "toolErrorBg",
163        "thinking_bg",
164    ];
165
166    /// Build a RabTheme from a ThemeConfig.
167    pub fn from_config(config: &ThemeConfig, mode: ColorMode) -> Self {
168        let colors = Self::resolve_colors(config);
169
170        let mut fg_ansi = HashMap::new();
171        let mut bg_ansi = HashMap::new();
172
173        for (key, value) in &colors {
174            if Self::BG_KEYS.contains(&key.as_str()) {
175                bg_ansi.insert(key.clone(), Self::bg_escape(value, mode));
176            } else {
177                fg_ansi.insert(key.clone(), Self::fg_escape(value, mode));
178            }
179        }
180
181        // Add thinking_bg as a derived background from thinkingText
182        if let Some(text_color) = colors.get("thinkingText")
183            && !bg_ansi.contains_key("thinking_bg")
184        {
185            // Darken thinkingText for background
186            let bg_color = if let Some((r, g, b)) = Self::hex_to_rgb(text_color) {
187                let dr = (r as f64 * 0.7) as u8;
188                let dg = (g as f64 * 0.7) as u8;
189                let db = (b as f64 * 0.7) as u8;
190                format!("#{:02x}{:02x}{:02x}", dr, dg, db)
191            } else {
192                text_color.clone()
193            };
194            bg_ansi.insert("thinking_bg".to_string(), Self::bg_escape(&bg_color, mode));
195        }
196
197        Self {
198            name: config.name.clone(),
199            mode,
200            fg_ansi,
201            bg_ansi,
202        }
203    }
204
205    /// Get the ANSI foreground escape code for a color name.
206    pub fn fg_ansi(&self, color: &str) -> &str {
207        self.fg_ansi
208            .get(color)
209            .map(|s| s.as_str())
210            .unwrap_or("\x1b[39m")
211    }
212
213    /// Get the ANSI background escape code for a color name.
214    pub fn bg_ansi(&self, color: &str) -> &str {
215        self.bg_ansi
216            .get(color)
217            .map(|s| s.as_str())
218            .unwrap_or("\x1b[49m")
219    }
220
221    /// Apply a foreground color to text.
222    pub fn fg(&self, color: &str, text: &str) -> String {
223        format!("{}{}\x1b[39m", self.fg_ansi(color), text)
224    }
225
226    /// Apply a background color to text.
227    pub fn bg(&self, color: &str, text: &str) -> String {
228        format!("{}{}\x1b[49m", self.bg_ansi(color), text)
229    }
230
231    /// Apply bold styling.
232    pub fn bold(&self, text: &str) -> String {
233        format!("\x1b[1m{}\x1b[22m", text)
234    }
235
236    /// Apply italic styling.
237    pub fn italic(&self, text: &str) -> String {
238        format!("\x1b[3m{}\x1b[23m", text)
239    }
240
241    /// Apply underline styling.
242    pub fn underline(&self, text: &str) -> String {
243        format!("\x1b[4m{}\x1b[24m", text)
244    }
245
246    /// Apply strikethrough styling.
247    pub fn strikethrough(&self, text: &str) -> String {
248        format!("\x1b[9m{}\x1b[29m", text)
249    }
250
251    /// Get the color mode.
252    pub fn color_mode(&self) -> ColorMode {
253        self.mode
254    }
255
256    /// Convenience: apply bold + fg
257    pub fn bold_fg(&self, color: &str, text: &str) -> String {
258        format!("\x1b[1m{}{}\x1b[22m\x1b[39m", self.fg_ansi(color), text)
259    }
260
261    // ── Convenience helpers matching the old RabTheme API ──
262
263    /// Apply accent foreground color.
264    pub fn accent(&self, text: &str) -> String {
265        self.fg("accent", text)
266    }
267
268    /// Apply dim foreground color.
269    pub fn dim(&self, text: &str) -> String {
270        self.fg("dim", text)
271    }
272
273    /// Apply muted foreground color.
274    pub fn muted(&self, text: &str) -> String {
275        self.fg("muted", text)
276    }
277
278    /// Apply success foreground color.
279    pub fn success(&self, text: &str) -> String {
280        self.fg("success", text)
281    }
282
283    /// Apply error foreground color.
284    pub fn error(&self, text: &str) -> String {
285        self.fg("error", text)
286    }
287
288    /// Apply text foreground color.
289    pub fn text_color(&self, text: &str) -> String {
290        self.fg("text", text)
291    }
292
293    /// Apply border foreground color.
294    pub fn border(&self, text: &str) -> String {
295        self.fg("border", text)
296    }
297
298    /// Apply user message background.
299    pub fn user_msg_bg(&self, text: &str) -> String {
300        self.bg("userMessageBg", text)
301    }
302
303    /// Apply thinking block background.
304    pub fn thinking_bg(&self, text: &str) -> String {
305        self.bg("thinking_bg", text)
306    }
307
308    /// Bold + accent foreground.
309    pub fn bold_accent(&self, text: &str) -> String {
310        self.bold_fg("accent", text)
311    }
312}
313
314impl Theme for RabTheme {
315    fn fg(&self, color: &str, text: &str) -> String {
316        self.fg(color, text)
317    }
318
319    fn bg(&self, color: &str, text: &str) -> String {
320        self.bg(color, text)
321    }
322
323    fn bold(&self, text: &str) -> String {
324        self.bold(text)
325    }
326
327    fn italic(&self, text: &str) -> String {
328        self.italic(text)
329    }
330}
331
332// ── Global Theme State ───────────────────────────────────────────
333
334use std::sync::{Mutex, OnceLock};
335
336static THEME: OnceLock<Mutex<RabTheme>> = OnceLock::new();
337static THEME_MODE: AtomicU16 = AtomicU16::new(1); // 1=truecolor
338
339fn get_theme_lock() -> &'static Mutex<RabTheme> {
340    THEME.get_or_init(|| Mutex::new(fallback_theme()))
341}
342
343/// Initialize the theme system. Call once at startup.
344pub fn init_theme(theme_name: Option<&str>, force_256: bool) {
345    let mode = if force_256 {
346        ColorMode::Ansi256
347    } else {
348        ColorMode::TrueColor
349    };
350    THEME_MODE.store(
351        if force_256 { 2 } else { 1 },
352        std::sync::atomic::Ordering::Relaxed,
353    );
354
355    let name = theme_name.unwrap_or("dark");
356    match load_theme_config(name) {
357        Ok(config) => {
358            let theme = RabTheme::from_config(&config, mode);
359            if let Ok(mut t) = get_theme_lock().lock() {
360                *t = theme;
361            }
362        }
363        Err(_) => {
364            // Fall back to dark
365            if name != "dark"
366                && let Ok(config) = load_theme_config("dark")
367            {
368                let theme = RabTheme::from_config(&config, mode);
369                if let Ok(mut t) = get_theme_lock().lock() {
370                    *t = theme;
371                }
372            }
373        }
374    }
375}
376
377/// Load a theme by name. Checks built-in themes first, then custom themes directory.
378fn load_theme_config(name: &str) -> Result<ThemeConfig, String> {
379    match name {
380        "dark" => {
381            let json = include_str!("themes/dark.json");
382            serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
383        }
384        "light" => {
385            let json = include_str!("themes/light.json");
386            serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
387        }
388        _ => {
389            let themes_dir = get_themes_dir();
390            let theme_path = themes_dir.join(format!("{}.json", name));
391            if theme_path.exists() {
392                let content = std::fs::read_to_string(&theme_path).map_err(|e| e.to_string())?;
393                serde_json::from_str::<ThemeConfig>(&content).map_err(|e| e.to_string())
394            } else {
395                Err(format!("Theme not found: {}", name))
396            }
397        }
398    }
399}
400
401/// Get the custom themes directory (~/.rab/themes).
402fn get_themes_dir() -> PathBuf {
403    let base = directories::BaseDirs::new()
404        .map(|d| d.home_dir().join(".rab"))
405        .unwrap_or_else(|| PathBuf::from("/tmp/.rab"));
406    let dir = base.join("themes");
407    let _ = std::fs::create_dir_all(&dir);
408    dir
409}
410
411/// Get available theme names.
412pub fn get_available_themes() -> Vec<String> {
413    let mut themes: Vec<String> = vec!["dark".to_string(), "light".to_string()];
414
415    let themes_dir = get_themes_dir();
416    if let Ok(entries) = std::fs::read_dir(&themes_dir) {
417        for entry in entries.flatten() {
418            let path = entry.path();
419            if path.extension().map(|e| e == "json").unwrap_or(false)
420                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
421                && name != "dark"
422                && name != "light"
423            {
424                themes.push(name.to_string());
425            }
426        }
427    }
428
429    themes.sort();
430    themes.dedup();
431    themes
432}
433
434/// Get the current theme.
435pub fn current_theme() -> std::sync::MutexGuard<'static, RabTheme> {
436    get_theme_lock().lock().expect("Theme lock poisoned")
437}
438
439/// Set a new theme by name. Returns success/error.
440pub fn set_theme(name: &str) -> Result<(), String> {
441    let mode = match THEME_MODE.load(std::sync::atomic::Ordering::Relaxed) {
442        2 => ColorMode::Ansi256,
443        _ => ColorMode::TrueColor,
444    };
445    let config = load_theme_config(name)?;
446    let theme = RabTheme::from_config(&config, mode);
447    if let Ok(mut t) = get_theme_lock().lock() {
448        *t = theme;
449    }
450    Ok(())
451}
452
453/// Detect terminal background theme using environment variables.
454/// Returns "dark" or "light".
455pub fn detect_terminal_theme() -> &'static str {
456    if let Ok(colorfgbg) = std::env::var("COLORFGBG")
457        && let Some(bg_str) = colorfgbg.split(';').next_back()
458        && let Ok(bg) = bg_str.trim().parse::<u8>()
459    {
460        let luminance = match bg {
461            0..=7 => 0.2,
462            8..=15 => 0.8,
463            _ => {
464                // 256-color: approximate luminance
465
466                (bg - 16) as f64 / 239.0
467            }
468        };
469        return if luminance > 0.5 { "light" } else { "dark" };
470    }
471    "dark"
472}
473
474/// Fallback theme for when no theme is loaded yet.
475fn fallback_theme() -> RabTheme {
476    let mut config = ThemeConfig {
477        name: "dark".into(),
478        vars: HashMap::new(),
479        colors: HashMap::new(),
480    };
481    let entries: Vec<(&str, &str)> = vec![
482        ("text", "#d4d4d4"),
483        ("dim", "#666666"),
484        ("muted", "#808080"),
485        ("accent", "#8abeb7"),
486        ("success", "#b5bd68"),
487        ("error", "#cc6666"),
488        ("warning", "#ffff00"),
489        ("thinkingText", "#808080"),
490        ("thinking_level_low", "#5f87af"),
491        ("thinking_level_medium", "#81a2be"),
492        ("thinking_level_high", "#b294bb"),
493        ("thinking_level_xhigh", "#d183e8"),
494        ("userMessageBg", "#343541"),
495        ("toolPendingBg", "#282832"),
496        ("toolSuccessBg", "#283228"),
497        ("toolErrorBg", "#3c2828"),
498        ("toolTitle", "#d4d4d4"),
499        ("toolOutput", "#808080"),
500    ];
501    for (k, v) in entries {
502        config
503            .colors
504            .insert(k.to_string(), ColorValue::HexOrVar(v.to_string()));
505    }
506    RabTheme::from_config(&config, ColorMode::TrueColor)
507}
508
509/// Build a `MarkdownTheme` from the current `RabTheme`.
510/// Wires all existing `md*` colors and text decorations.
511pub fn get_markdown_theme() -> MarkdownTheme {
512    let theme = current_theme();
513
514    let heading = mk_style(theme.fg_ansi("mdHeading"));
515    let link = mk_style(theme.fg_ansi("mdLink"));
516    let link_url = mk_style(theme.fg_ansi("mdLinkUrl"));
517    let code = mk_style(theme.fg_ansi("mdCode"));
518    let code_block = mk_style(theme.fg_ansi("mdCodeBlock"));
519    let code_block_border = mk_style(theme.fg_ansi("mdCodeBlockBorder"));
520    let quote = mk_style(theme.fg_ansi("mdQuote"));
521    let quote_border = mk_style(theme.fg_ansi("mdQuoteBorder"));
522    let hr = mk_style(theme.fg_ansi("mdHr"));
523    let list_bullet = mk_style(theme.fg_ansi("mdListBullet"));
524
525    // Release the lock before building closures
526    drop(theme);
527
528    let mut md = MarkdownTheme::new(
529        heading,
530        link,
531        link_url,
532        code,
533        code_block,
534        code_block_border,
535        quote,
536        quote_border,
537        hr,
538        list_bullet,
539        style_bold(),
540        style_italic(),
541        style_strikethrough(),
542        style_underline(),
543    );
544    md.highlight_code = create_highlight_fn();
545    md
546}
547
548/// Build a style function that wraps text with a foreground ANSI prefix and reset suffix.
549fn mk_style(prefix: &str) -> StyleFn {
550    let p = prefix.to_string();
551    Arc::new(move |text: &str| format!("{}{}\x1b[39m", p, text))
552}
553
554/// Build a bold style function.
555fn style_bold() -> StyleFn {
556    Arc::new(|text: &str| format!("\x1b[1m{}\x1b[22m", text))
557}
558
559/// Build an italic style function.
560fn style_italic() -> StyleFn {
561    Arc::new(|text: &str| format!("\x1b[3m{}\x1b[23m", text))
562}
563
564/// Build a strikethrough style function.
565fn style_strikethrough() -> StyleFn {
566    Arc::new(|text: &str| format!("\x1b[9m{}\x1b[29m", text))
567}
568
569/// Build an underline style function.
570fn style_underline() -> StyleFn {
571    Arc::new(|text: &str| format!("\x1b[4m{}\x1b[24m", text))
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_load_dark_theme() {
580        let config = load_theme_config("dark").unwrap();
581        assert_eq!(config.name, "dark");
582        assert!(config.colors.contains_key("accent"));
583        assert!(config.colors.contains_key("text"));
584    }
585
586    #[test]
587    fn test_load_light_theme() {
588        let config = load_theme_config("light").unwrap();
589        assert_eq!(config.name, "light");
590        assert!(config.colors.contains_key("accent"));
591    }
592
593    #[test]
594    fn test_resolve_colors() {
595        let config = load_theme_config("dark").unwrap();
596        let colors = RabTheme::resolve_colors(&config);
597        assert!(colors.contains_key("accent"));
598        assert!(colors.contains_key("text"));
599        assert!(colors.get("accent").unwrap().starts_with('#'));
600    }
601
602    #[test]
603    fn test_theme_from_config() {
604        let config = load_theme_config("dark").unwrap();
605        let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
606        let colored = theme.fg("accent", "hello");
607        assert!(colored.contains("hello"));
608        assert!(colored.contains("\x1b[38;2;"));
609        assert!(colored.ends_with("\x1b[39m"));
610    }
611
612    #[test]
613    fn test_theme_256_fallback() {
614        let config = load_theme_config("dark").unwrap();
615        let theme = RabTheme::from_config(&config, ColorMode::Ansi256);
616        let colored = theme.fg("accent", "hello");
617        assert!(colored.contains("hello"));
618        assert!(colored.contains("\x1b[38;5;"));
619    }
620
621    #[test]
622    fn test_bold_italic() {
623        let config = load_theme_config("dark").unwrap();
624        let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
625        assert_eq!(theme.bold("x"), "\x1b[1mx\x1b[22m");
626        assert_eq!(theme.italic("x"), "\x1b[3mx\x1b[23m");
627    }
628
629    #[test]
630    fn test_hex_to_rgb() {
631        assert_eq!(RabTheme::hex_to_rgb("#ff0000"), Some((255, 0, 0)));
632        assert_eq!(RabTheme::hex_to_rgb("00ff00"), Some((0, 255, 0)));
633        assert_eq!(RabTheme::hex_to_rgb("#zzz"), None);
634    }
635
636    #[test]
637    fn test_fallback_theme() {
638        let theme = fallback_theme();
639        assert_eq!(theme.name, "dark");
640        let text = theme.fg("text", "test");
641        assert!(text.contains("test"));
642    }
643
644    #[test]
645    fn test_set_and_get() {
646        init_theme(Some("dark"), false);
647        let theme = current_theme();
648        assert_eq!(theme.name, "dark");
649    }
650}