Skip to main content

netget/cli/
theme.rs

1use crossterm::style::Color;
2#[cfg(not(target_arch = "wasm32"))]
3use std::time::Duration;
4use tracing::debug;
5
6/// Theme variants based on terminal background
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Theme {
9    Light,
10    Dark,
11    Neutral,
12}
13
14/// Color palette for TUI with semantic color names
15#[derive(Debug, Clone)]
16pub struct ColorPalette {
17    // Log level colors
18    pub error: Color,
19    pub warning: Color,
20    pub info: Color,
21    pub debug: Color,
22    pub trace: Color,
23    /// Streamed model reasoning/chain-of-thought (`[REASONING]` lines).
24    pub reasoning: Color,
25    pub user: Color,
26
27    // Connection/server/client indicators
28    pub server: Color,
29    pub client: Color,
30    pub connection: Color,
31
32    // UI elements
33    pub separator: Color,
34    pub dimmed: Color,
35    pub normal: Color,
36
37    // Status indicators
38    pub success: Color,
39    pub failure: Color,
40    pub ask: Color,
41}
42
43impl ColorPalette {
44    /// Create a color palette for dark terminals (current NetGet default)
45    pub fn dark() -> Self {
46        Self {
47            error: Color::Red,
48            warning: Color::Yellow,
49            // NOT ansi Blue: on a dark background that is the classic
50            // unreadable pair, and `info` is the dashboard's accent — field
51            // values, focused borders, buttons. 75 is a bright steel blue
52            // that stays legible on black (256-color, which every TERM the
53            // dashboard runs under supports).
54            info: Color::AnsiValue(75),
55            debug: Color::Cyan,
56            trace: Color::DarkGrey,
57            reasoning: Color::Magenta,
58            user: Color::Green,
59            server: Color::Cyan,
60            client: Color::Magenta,
61            connection: Color::Cyan,
62            separator: Color::DarkGreen,
63            dimmed: Color::DarkGrey,
64            normal: Color::White,
65            success: Color::Green,
66            failure: Color::Red,
67            ask: Color::Yellow,
68        }
69    }
70
71    /// Create a color palette for light terminals
72    pub fn light() -> Self {
73        Self {
74            error: Color::DarkRed,
75            warning: Color::DarkYellow,
76            info: Color::DarkBlue,
77            debug: Color::DarkCyan,
78            trace: Color::DarkGrey,
79            reasoning: Color::DarkMagenta,
80            user: Color::DarkGreen,
81            server: Color::DarkCyan,
82            client: Color::DarkMagenta,
83            connection: Color::DarkCyan,
84            separator: Color::DarkGreen,
85            dimmed: Color::Grey,
86            normal: Color::Black,
87            success: Color::DarkGreen,
88            failure: Color::DarkRed,
89            ask: Color::DarkYellow,
90        }
91    }
92
93    /// Create a neutral color palette that works on both light and dark backgrounds
94    /// Uses medium contrast colors that are readable in most situations
95    pub fn neutral() -> Self {
96        Self {
97            error: Color::Red,
98            warning: Color::DarkYellow,
99            // A mid-brightness 256-color blue that reads on both black and
100            // white; ansi Blue is near-invisible on dark terminals, and
101            // neutral is exactly the palette a dark terminal lands on when
102            // detection fails.
103            info: Color::AnsiValue(32),
104            debug: Color::DarkCyan,
105            trace: Color::Grey,
106            reasoning: Color::Magenta,
107            user: Color::DarkGreen,
108            server: Color::DarkCyan,
109            client: Color::DarkMagenta,
110            connection: Color::DarkCyan,
111            separator: Color::DarkGreen,
112            dimmed: Color::Grey,
113            normal: Color::Reset, // Use terminal default
114            success: Color::DarkGreen,
115            failure: Color::Red,
116            ask: Color::DarkYellow,
117        }
118    }
119
120    /// Get the appropriate color palette based on theme
121    pub fn from_theme(theme: Theme) -> Self {
122        match theme {
123            Theme::Dark => Self::dark(),
124            Theme::Light => Self::light(),
125            Theme::Neutral => Self::neutral(),
126        }
127    }
128}
129
130/// Detect terminal background and determine appropriate theme
131/// Returns None if detection fails or times out
132///
133/// Note: termbg can leave the terminal in a bad state on some terminals
134/// (especially macOS Terminal.app). We wrap detection in catch_unwind and
135/// flush any stale input afterwards.
136#[cfg(not(target_arch = "wasm32"))]
137pub fn detect_theme() -> Option<Theme> {
138    use std::panic::catch_unwind;
139
140    // Check for known-problematic terminal environments
141    // macOS Terminal.app and some other terminals don't handle OSC queries well
142    if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
143        // Apple Terminal is known to have issues with termbg
144        if term_program == "Apple_Terminal" {
145            debug!("Skipping theme detection on Apple Terminal (known issues)");
146            return theme_from_colorfgbg();
147        }
148    }
149
150    // Use a short timeout to avoid blocking startup
151    let timeout = Duration::from_millis(100);
152
153    // Wrap in catch_unwind to handle any panics from termbg
154    let result = catch_unwind(|| termbg::theme(timeout));
155
156    // Flush any leftover input that termbg might have left in the buffer
157    // This is critical - termbg sends OSC sequences and if the terminal
158    // doesn't respond or responds incorrectly, garbage can be left in stdin
159    flush_stdin_nonblocking();
160
161    match result {
162        Ok(Ok(termbg::Theme::Light)) => Some(Theme::Light),
163        Ok(Ok(termbg::Theme::Dark)) => Some(Theme::Dark),
164        Ok(Err(e)) => {
165            debug!("Theme detection failed: {:?}", e);
166            theme_from_colorfgbg()
167        }
168        Err(_) => {
169            debug!("Theme detection panicked");
170            theme_from_colorfgbg()
171        }
172    }
173}
174
175/// Second-chance detection from `$COLORFGBG` ("fg;bg", e.g. "15;0").
176///
177/// Terminals that don't answer the OSC query (or where we dare not send it,
178/// like Apple Terminal) often still export this. Falling back here is what
179/// keeps a dark terminal from landing on the neutral palette, whose blue is
180/// barely readable on black — the exact complaint this fixes.
181#[cfg(not(target_arch = "wasm32"))]
182fn theme_from_colorfgbg() -> Option<Theme> {
183    let value = std::env::var("COLORFGBG").ok()?;
184    let bg = value.rsplit(';').next()?.trim().parse::<u8>().ok()?;
185    // ANSI 0-6 and 8 are dark backgrounds; 7 and 15 are light.
186    let theme = if bg == 7 || bg == 15 {
187        Theme::Light
188    } else {
189        Theme::Dark
190    };
191    debug!("Theme from COLORFGBG={value}: {theme:?}");
192    Some(theme)
193}
194
195/// Flush any pending input from stdin without blocking
196/// This cleans up any stale escape sequences that termbg may have left
197#[cfg(not(target_arch = "wasm32"))]
198fn flush_stdin_nonblocking() {
199    use std::io::Read;
200
201    // On Unix, we can set stdin to non-blocking temporarily
202    #[cfg(unix)]
203    {
204        use std::os::unix::io::AsRawFd;
205
206        let stdin = std::io::stdin();
207        let fd = stdin.as_raw_fd();
208
209        // Get current flags
210        let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
211        if flags < 0 {
212            return;
213        }
214
215        // Set non-blocking
216        if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
217            return;
218        }
219
220        // Read and discard any pending data
221        let mut buf = [0u8; 256];
222        let mut stdin_lock = stdin.lock();
223        while stdin_lock.read(&mut buf).unwrap_or(0) > 0 {}
224
225        // Restore original flags
226        unsafe { libc::fcntl(fd, libc::F_SETFL, flags) };
227    }
228
229    // On non-Unix platforms, we just do nothing - they typically don't have
230    // the same issues with termbg
231    #[cfg(not(unix))]
232    {}
233}
234
235/// There is no terminal to query in the browser; the page passes its theme in explicitly.
236#[cfg(target_arch = "wasm32")]
237pub fn detect_theme() -> Option<Theme> {
238    None
239}
240
241/// Parse theme from string (for CLI flag)
242pub fn parse_theme(s: &str) -> anyhow::Result<Option<Theme>> {
243    match s.to_lowercase().as_str() {
244        "auto" => Ok(None), // None means auto-detect
245        "light" => Ok(Some(Theme::Light)),
246        "dark" => Ok(Some(Theme::Dark)),
247        "neutral" => Ok(Some(Theme::Neutral)),
248        _ => anyhow::bail!(
249            "Invalid theme '{}'. Valid options: auto, light, dark, neutral",
250            s
251        ),
252    }
253}