Skip to main content

cookie_agent_tui/
terminal_detect.rs

1//! Startup-only terminal background detection for automatic theme selection.
2
3use std::{env, fmt, io};
4
5use crate::{
6    config::ThemePreference,
7    theme::{Theme, ThemeKind},
8};
9
10#[cfg(any(unix, windows))]
11const OSC11_QUERY: &[u8] = b"\x1b]11;?\x1b\\";
12#[cfg(any(unix, windows))]
13const OSC11_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(75);
14#[cfg(any(unix, windows))]
15const OSC11_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20);
16#[cfg(any(unix, windows))]
17const OSC11_RESPONSE_CAP: usize = 512;
18#[cfg(any(unix, windows))]
19const OSC11_DRAIN_CAP: usize = 1024;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum ThemeDetectionSource {
23    Explicit,
24    Osc11,
25    ColorFgBg,
26    Fallback,
27}
28
29impl fmt::Display for ThemeDetectionSource {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str(match self {
32            Self::Explicit => "explicit",
33            Self::Osc11 => "osc11",
34            Self::ColorFgBg => "colorfgbg",
35            Self::Fallback => "fallback",
36        })
37    }
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct ThemeDetection {
42    pub kind: ThemeKind,
43    pub source: ThemeDetectionSource,
44}
45
46trait Osc11Query {
47    fn is_eligible(&self) -> bool;
48    fn query(&mut self) -> Option<Vec<u8>>;
49}
50
51struct SystemOsc11Query;
52
53impl Osc11Query for SystemOsc11Query {
54    fn is_eligible(&self) -> bool {
55        terminal_is_eligible()
56    }
57
58    fn query(&mut self) -> Option<Vec<u8>> {
59        query_osc11()
60    }
61}
62
63/// Resolve a TUI startup theme, querying the terminal only when neither config
64/// nor `COOKIE_THEME` chooses an explicit theme.
65pub fn detect_startup_theme(preference: Option<ThemePreference>) -> ThemeDetection {
66    let cookie_theme = env::var("COOKIE_THEME").ok();
67    #[cfg(unix)]
68    let colorfgbg = env::var("COLORFGBG").ok();
69    #[cfg(windows)]
70    let colorfgbg: Option<String> = None;
71    detect_with_query(
72        preference,
73        cookie_theme.as_deref(),
74        colorfgbg.as_deref(),
75        &mut SystemOsc11Query,
76    )
77}
78
79/// Resolve themes for headless and test app construction without terminal I/O.
80pub(crate) fn theme_without_terminal_detection(preference: Option<ThemePreference>) -> Theme {
81    let cookie_theme = env::var("COOKIE_THEME").ok();
82    let detection = detect_with_query(preference, cookie_theme.as_deref(), None, &mut NoOsc11Query);
83    Theme::with_kind_from_env(detection.kind)
84}
85
86struct NoOsc11Query;
87
88impl Osc11Query for NoOsc11Query {
89    fn is_eligible(&self) -> bool {
90        false
91    }
92
93    fn query(&mut self) -> Option<Vec<u8>> {
94        None
95    }
96}
97
98fn detect_with_query(
99    preference: Option<ThemePreference>,
100    cookie_theme: Option<&str>,
101    colorfgbg: Option<&str>,
102    query: &mut impl Osc11Query,
103) -> ThemeDetection {
104    match requested_theme(preference, cookie_theme) {
105        RequestedTheme::Explicit(kind) => ThemeDetection {
106            kind,
107            source: ThemeDetectionSource::Explicit,
108        },
109        RequestedTheme::Auto => {
110            let response = if query.is_eligible() {
111                query.query()
112            } else {
113                None
114            };
115            if let Some(kind) = response
116                .as_deref()
117                .and_then(parse_osc11_response)
118                .map(theme_for_rgb)
119            {
120                return ThemeDetection {
121                    kind,
122                    source: ThemeDetectionSource::Osc11,
123                };
124            }
125            if let Some(kind) = colorfgbg.and_then(parse_colorfgbg) {
126                return ThemeDetection {
127                    kind,
128                    source: ThemeDetectionSource::ColorFgBg,
129                };
130            }
131            ThemeDetection {
132                kind: platform_fallback_theme(),
133                source: ThemeDetectionSource::Fallback,
134            }
135        }
136    }
137}
138
139#[cfg(unix)]
140fn platform_fallback_theme() -> ThemeKind {
141    ThemeKind::Default
142}
143
144#[cfg(windows)]
145fn platform_fallback_theme() -> ThemeKind {
146    ThemeKind::Dark
147}
148
149enum RequestedTheme {
150    Auto,
151    Explicit(ThemeKind),
152}
153
154fn requested_theme(
155    preference: Option<ThemePreference>,
156    cookie_theme: Option<&str>,
157) -> RequestedTheme {
158    if let Some(preference) = preference {
159        return preference
160            .explicit_kind()
161            .map_or(RequestedTheme::Auto, RequestedTheme::Explicit);
162    }
163    let cookie_theme = cookie_theme.map(str::to_ascii_lowercase);
164    match cookie_theme.as_deref() {
165        None | Some("auto") => RequestedTheme::Auto,
166        Some("dark" | "dark-roast" | "darkroast") => RequestedTheme::Explicit(ThemeKind::Dark),
167        Some("mono" | "monochrome") => RequestedTheme::Explicit(ThemeKind::Mono),
168        Some("high-contrast" | "high_contrast" | "contrast") => {
169            RequestedTheme::Explicit(ThemeKind::HighContrast)
170        }
171        Some("default") => RequestedTheme::Explicit(ThemeKind::Default),
172        // Preserve the previous fail-open behavior for unknown explicit values.
173        Some(_) => RequestedTheme::Explicit(ThemeKind::Default),
174    }
175}
176
177fn parse_osc11_response(response: &[u8]) -> Option<(f64, f64, f64)> {
178    let response = std::str::from_utf8(response).ok()?;
179    let channels = response.get(response.find("rgb:")? + 4..)?;
180    let mut channels = channels.split('/');
181    let (red, red_digits) = parse_rgb_component(channels.next()?)?;
182    let (green, green_digits) = parse_rgb_component(channels.next()?)?;
183    let (blue, blue_digits) = parse_rgb_component(channels.next()?)?;
184    if red_digits != green_digits || red_digits != blue_digits {
185        return None;
186    }
187    Some((red, green, blue))
188}
189
190fn parse_rgb_component(component: &str) -> Option<(f64, usize)> {
191    let digits = component
192        .as_bytes()
193        .iter()
194        .take_while(|byte| byte.is_ascii_hexdigit())
195        .count();
196    if digits != 2 && digits != 4 {
197        return None;
198    }
199    let value = u16::from_str_radix(component.get(..digits)?, 16).ok()?;
200    Some((
201        f64::from(value) / if digits == 2 { 255.0 } else { 65_535.0 },
202        digits,
203    ))
204}
205
206fn theme_for_rgb(rgb: (f64, f64, f64)) -> ThemeKind {
207    if relative_luminance(rgb) < 0.5 {
208        ThemeKind::Dark
209    } else {
210        ThemeKind::Default
211    }
212}
213
214fn relative_luminance((red, green, blue): (f64, f64, f64)) -> f64 {
215    fn linearize(channel: f64) -> f64 {
216        if channel <= 0.04045 {
217            channel / 12.92
218        } else {
219            ((channel + 0.055) / 1.055).powf(2.4)
220        }
221    }
222    0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue)
223}
224
225fn parse_colorfgbg(value: &str) -> Option<ThemeKind> {
226    let mut fields = value.split(';').map(str::trim);
227    let foreground = fields.next()?;
228    let second = fields.next()?;
229    let third = fields.next();
230    if fields.next().is_some() || foreground.parse::<u8>().ok()? > 15 {
231        return None;
232    }
233    let background = match third {
234        None => second,
235        Some(background) if second.eq_ignore_ascii_case("default") => background,
236        Some(_) => return None,
237    };
238    if background.eq_ignore_ascii_case("default") {
239        return None;
240    }
241    match background.parse::<u8>().ok()? {
242        0..=6 | 8 => Some(ThemeKind::Dark),
243        7 | 9..=15 => Some(ThemeKind::Default),
244        _ => None,
245    }
246}
247
248fn terminal_is_eligible() -> bool {
249    use std::io::IsTerminal as _;
250
251    io::stdout().is_terminal() && io::stdin().is_terminal()
252}
253
254#[cfg(any(unix, windows))]
255fn query_osc11_io(
256    input: &mut impl io::Read,
257    output: &mut impl io::Write,
258    query_timeout: std::time::Duration,
259    drain_timeout: std::time::Duration,
260) -> Option<Vec<u8>> {
261    use std::{thread, time::Instant};
262
263    output.write_all(OSC11_QUERY).ok()?;
264    output.flush().ok()?;
265
266    let deadline = Instant::now() + query_timeout;
267    let mut response = Vec::with_capacity(64);
268    let mut buffer = [0_u8; 128];
269    while Instant::now() < deadline && response.len() < OSC11_RESPONSE_CAP {
270        match input.read(&mut buffer) {
271            Ok(0) => break,
272            Ok(read) => {
273                response.extend_from_slice(&buffer[..read]);
274                if response.contains(&b'\x07')
275                    || response.windows(2).any(|window| window == b"\x1b\\")
276                {
277                    break;
278                }
279            }
280            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
281                thread::sleep(std::time::Duration::from_millis(2));
282            }
283            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
284            Err(_) => break,
285        }
286    }
287
288    // A terminal may begin its reply just after the main deadline or split it
289    // across reads. Consume a short, bounded grace window before EventStream
290    // takes ownership of stdin so those bytes cannot become key events.
291    let drain_deadline = Instant::now() + drain_timeout;
292    let mut drained = 0;
293    while Instant::now() < drain_deadline && drained < OSC11_DRAIN_CAP {
294        match input.read(&mut buffer) {
295            Ok(0) => break,
296            Ok(read) => drained += read,
297            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
298                thread::sleep(std::time::Duration::from_millis(2));
299            }
300            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
301            Err(_) => break,
302        }
303    }
304
305    (!response.is_empty()).then_some(response)
306}
307
308#[cfg(unix)]
309fn query_osc11() -> Option<Vec<u8>> {
310    use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
311    use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
312
313    struct RawModeGuard;
314    impl Drop for RawModeGuard {
315        fn drop(&mut self) {
316            let _ = disable_raw_mode();
317        }
318    }
319
320    enable_raw_mode().ok()?;
321    let _raw_mode = RawModeGuard;
322    let stdin = io::stdin();
323    let original_flags = fcntl_getfl(&stdin).ok()?;
324    fcntl_setfl(&stdin, original_flags | OFlags::NONBLOCK).ok()?;
325    struct NonblockingGuard<'a> {
326        input: &'a io::Stdin,
327        original_flags: OFlags,
328    }
329    impl Drop for NonblockingGuard<'_> {
330        fn drop(&mut self) {
331            let _ = fcntl_setfl(self.input, self.original_flags);
332        }
333    }
334    let _nonblocking = NonblockingGuard {
335        input: &stdin,
336        original_flags,
337    };
338    let mut input = stdin.lock();
339    let mut output = io::stdout().lock();
340    query_osc11_io(&mut input, &mut output, OSC11_TIMEOUT, OSC11_DRAIN_TIMEOUT)
341}
342
343#[cfg(windows)]
344fn query_osc11() -> Option<Vec<u8>> {
345    use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
346    use windows_sys::Win32::{
347        Foundation::{INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
348        Storage::FileSystem::ReadFile,
349        System::{
350            Console::{GetStdHandle, STD_INPUT_HANDLE},
351            Threading::WaitForSingleObject,
352        },
353    };
354
355    struct RawModeGuard;
356    impl Drop for RawModeGuard {
357        fn drop(&mut self) {
358            let _ = disable_raw_mode();
359        }
360    }
361
362    struct PolledStdin(windows_sys::Win32::Foundation::HANDLE);
363    impl io::Read for PolledStdin {
364        fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
365            let wait = unsafe { WaitForSingleObject(self.0, 0) };
366            match wait {
367                WAIT_TIMEOUT => return Err(io::ErrorKind::WouldBlock.into()),
368                WAIT_FAILED => return Err(io::Error::last_os_error()),
369                WAIT_OBJECT_0 => {}
370                _ => return Err(io::Error::other("unexpected stdin wait result")),
371            }
372            let length = u32::try_from(buffer.len()).unwrap_or(u32::MAX);
373            let mut read = 0;
374            let success = unsafe {
375                ReadFile(
376                    self.0,
377                    buffer.as_mut_ptr(),
378                    length,
379                    &mut read,
380                    std::ptr::null_mut(),
381                )
382            };
383            if success == 0 {
384                Err(io::Error::last_os_error())
385            } else {
386                Ok(read as usize)
387            }
388        }
389    }
390
391    enable_raw_mode().ok()?;
392    let _raw_mode = RawModeGuard;
393    let input_handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
394    if input_handle.is_null() || input_handle == INVALID_HANDLE_VALUE {
395        return None;
396    }
397    let mut input = PolledStdin(input_handle);
398    let mut output = io::stdout().lock();
399    query_osc11_io(&mut input, &mut output, OSC11_TIMEOUT, OSC11_DRAIN_TIMEOUT)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    struct CannedQuery {
407        response: Option<Vec<u8>>,
408        calls: usize,
409        eligible: bool,
410    }
411
412    impl Default for CannedQuery {
413        fn default() -> Self {
414            Self {
415                response: None,
416                calls: 0,
417                eligible: true,
418            }
419        }
420    }
421
422    impl Osc11Query for CannedQuery {
423        fn is_eligible(&self) -> bool {
424            self.eligible
425        }
426
427        fn query(&mut self) -> Option<Vec<u8>> {
428            self.calls += 1;
429            self.response.take()
430        }
431    }
432
433    #[test]
434    fn parses_osc11_rgb_responses() {
435        assert_eq!(
436            parse_osc11_response(b"\x1b]11;rgb:ffff/8080/0000\x1b\\"),
437            Some((1.0, 32_896.0 / 65_535.0, 0.0))
438        );
439        assert_eq!(
440            parse_osc11_response(b"\x1b]11;rgb:ff/80/00\x07"),
441            Some((1.0, 128.0 / 255.0, 0.0))
442        );
443        assert_eq!(parse_osc11_response(b"garbage"), None);
444        assert_eq!(parse_osc11_response(b""), None);
445        assert_eq!(parse_osc11_response(b"rgb:fff/0000/0000"), None);
446        assert_eq!(parse_osc11_response(b"rgb:ff/0000/0000"), None);
447    }
448
449    #[test]
450    fn colorfgbg_classifies_documented_indices() {
451        for index in [0, 1, 2, 3, 4, 5, 6, 8] {
452            assert_eq!(
453                parse_colorfgbg(&format!("15;{index}")),
454                Some(ThemeKind::Dark)
455            );
456        }
457        for index in [7, 9, 10, 11, 12, 13, 14, 15] {
458            assert_eq!(
459                parse_colorfgbg(&format!("0;default;{index}")),
460                Some(ThemeKind::Default)
461            );
462        }
463        for value in [
464            "0",
465            "0;default",
466            "default",
467            "0;16",
468            "0;nope",
469            "0;default;1;7",
470            "0;wrong;7",
471            "x;7",
472            "16;7",
473            "",
474        ] {
475            assert_eq!(parse_colorfgbg(value), None, "{value:?}");
476        }
477    }
478
479    #[cfg(unix)]
480    #[test]
481    fn delayed_osc_reply_is_drained_before_event_handling() {
482        use std::{
483            io::{Read as _, Write as _},
484            os::unix::net::UnixStream,
485            thread,
486            time::Duration,
487        };
488
489        let (mut app_input, mut terminal) = UnixStream::pair().expect("terminal pair");
490        app_input
491            .set_nonblocking(true)
492            .expect("nonblocking app input");
493        let mut query_output = app_input.try_clone().expect("query output");
494        let terminal_reply = thread::spawn(move || {
495            let mut query = [0_u8; OSC11_QUERY.len()];
496            terminal.read_exact(&mut query).expect("read query");
497            assert_eq!(&query, OSC11_QUERY);
498            thread::sleep(Duration::from_millis(15));
499            terminal
500                .write_all(b"\x1b]11;rgb:0000/0000/0000\x1b\\")
501                .expect("write delayed reply");
502            // Keep the peer open until the assertion so an empty read is
503            // distinguishable from EOF.
504            thread::sleep(Duration::from_millis(80));
505        });
506
507        let _ = query_osc11_io(
508            &mut app_input,
509            &mut query_output,
510            Duration::from_millis(10),
511            OSC11_DRAIN_TIMEOUT,
512        );
513        let mut event_bytes = [0_u8; 64];
514        let read = app_input.read(&mut event_bytes);
515        assert!(
516            matches!(read, Err(ref error) if error.kind() == io::ErrorKind::WouldBlock),
517            "delayed OSC bytes reached event handling: {read:?}"
518        );
519        terminal_reply.join().expect("terminal reply thread");
520    }
521
522    #[test]
523    fn luminance_threshold_selects_dark_below_half() {
524        assert_eq!(theme_for_rgb((0.0, 0.0, 0.0)), ThemeKind::Dark);
525        assert_eq!(theme_for_rgb((1.0, 1.0, 1.0)), ThemeKind::Default);
526        assert_eq!(theme_for_rgb((0.735, 0.735, 0.735)), ThemeKind::Dark);
527        assert_eq!(theme_for_rgb((0.736, 0.736, 0.736)), ThemeKind::Default);
528    }
529
530    #[test]
531    fn explicit_theme_beats_detection_and_reports_source() {
532        let mut query = CannedQuery {
533            response: Some(b"rgb:0000/0000/0000".to_vec()),
534            ..CannedQuery::default()
535        };
536        let detection = detect_with_query(
537            Some(ThemePreference::Default),
538            Some("dark"),
539            Some("0;0"),
540            &mut query,
541        );
542        assert_eq!(
543            detection,
544            ThemeDetection {
545                kind: ThemeKind::Default,
546                source: ThemeDetectionSource::Explicit,
547            }
548        );
549        assert_eq!(query.calls, 0);
550        assert_eq!(ThemeDetectionSource::Explicit.to_string(), "explicit");
551
552        let mut cookie_query = CannedQuery {
553            response: Some(b"rgb:ffff/ffff/ffff".to_vec()),
554            ..CannedQuery::default()
555        };
556        assert_eq!(
557            detect_with_query(None, Some("dark"), Some("0;15"), &mut cookie_query),
558            ThemeDetection {
559                kind: ThemeKind::Dark,
560                source: ThemeDetectionSource::Explicit,
561            }
562        );
563        assert_eq!(cookie_query.calls, 0);
564    }
565
566    #[test]
567    fn non_tty_skips_the_query_entirely() {
568        let mut query = CannedQuery {
569            response: Some(b"rgb:0000/0000/0000".to_vec()),
570            eligible: false,
571            ..CannedQuery::default()
572        };
573        assert_eq!(
574            detect_with_query(None, None, Some("0;15"), &mut query),
575            ThemeDetection {
576                kind: ThemeKind::Default,
577                source: ThemeDetectionSource::ColorFgBg,
578            }
579        );
580        assert_eq!(query.calls, 0, "ineligible terminal was queried");
581    }
582
583    #[test]
584    fn auto_uses_osc_then_colorfgbg_then_fallback() {
585        let mut osc = CannedQuery {
586            response: Some(b"\x1b]11;rgb:0000/0000/0000\x1b\\".to_vec()),
587            ..CannedQuery::default()
588        };
589        assert_eq!(
590            detect_with_query(None, None, Some("0;15"), &mut osc),
591            ThemeDetection {
592                kind: ThemeKind::Dark,
593                source: ThemeDetectionSource::Osc11,
594            }
595        );
596
597        let mut absent = CannedQuery::default();
598        assert_eq!(
599            detect_with_query(
600                Some(ThemePreference::Auto),
601                Some("dark"),
602                Some("0;15"),
603                &mut absent
604            ),
605            ThemeDetection {
606                kind: ThemeKind::Default,
607                source: ThemeDetectionSource::ColorFgBg,
608            }
609        );
610        let mut invalid = CannedQuery {
611            response: Some(b"not an OSC response".to_vec()),
612            ..CannedQuery::default()
613        };
614        assert_eq!(
615            detect_with_query(None, None, Some("0;0"), &mut invalid),
616            ThemeDetection {
617                kind: ThemeKind::Dark,
618                source: ThemeDetectionSource::ColorFgBg,
619            }
620        );
621        assert_eq!(
622            detect_with_query(None, Some("auto"), Some("default"), &mut absent),
623            ThemeDetection {
624                kind: platform_fallback_theme(),
625                source: ThemeDetectionSource::Fallback,
626            }
627        );
628    }
629
630    #[test]
631    fn no_color_coercion_happens_after_detection() {
632        let mut query = CannedQuery {
633            response: Some(b"rgb:0000/0000/0000".to_vec()),
634            ..CannedQuery::default()
635        };
636        let detection = detect_with_query(None, None, None, &mut query);
637        assert_eq!(detection.kind, ThemeKind::Dark);
638        let theme =
639            Theme::from_kind_environment(detection.kind, true, "xterm-256color", "truecolor");
640        assert_eq!(theme.key().kind, ThemeKind::Mono);
641    }
642
643    #[cfg(windows)]
644    #[test]
645    fn unavailable_automatic_detection_falls_back_to_dark() {
646        let mut query = CannedQuery::default();
647        assert_eq!(
648            detect_with_query(None, None, None, &mut query),
649            ThemeDetection {
650                kind: ThemeKind::Dark,
651                source: ThemeDetectionSource::Fallback,
652            }
653        );
654    }
655}