44//! - Colorization and truecolor capability gates
55//! - Foreground color OSC query (`ESC ] 10 ; ? ESC \\`) with timeout
66//! - ANSI palette queries for blue/magenta with timeout
7+ //! - DA1 sandwich technique to detect unsupported terminals
78//! - Gradient/fade generation and RGB ANSI coloring
89
910use std:: {
@@ -179,6 +180,79 @@ fn parse_osc4_rgb(buffer: &str, index: u8) -> Option<Rgb> {
179180 parse_rgb_triplet ( & tail[ rgb_start + 4 ..] )
180181}
181182
183+ /// Returns `true` if the terminal is known to not support OSC color queries
184+ /// or if the environment is unreliable for escape-sequence round-trips.
185+ ///
186+ /// Modelled after `terminal-colorsaurus`'s quirks detection, extended with
187+ /// additional checks for Docker, CI, devcontainers, and other environments.
188+ #[ cfg( unix) ]
189+ fn is_osc_query_unsupported ( ) -> bool {
190+ static UNSUPPORTED : OnceLock < bool > = OnceLock :: new ( ) ;
191+ * UNSUPPORTED . get_or_init ( || {
192+ if !std:: io:: stdout ( ) . is_terminal ( ) || !std:: io:: stdin ( ) . is_terminal ( ) {
193+ return true ;
194+ }
195+
196+ // CI environments have no real terminal emulator behind the PTY.
197+ if std:: env:: var_os ( "CI" ) . is_some ( ) || std:: env:: var_os ( "GITHUB_ACTIONS" ) . is_some ( ) {
198+ return true ;
199+ }
200+
201+ // Warp terminal does not respond to OSC color queries in its
202+ // block-mode renderer, causing a hang until the user presses a key.
203+ if is_warp_terminal ( ) {
204+ return true ;
205+ }
206+
207+ // Emacs terminal emulators (ansi-term, vterm, eshell) don't support
208+ // OSC queries.
209+ if std:: env:: var_os ( "INSIDE_EMACS" ) . is_some ( ) {
210+ return true ;
211+ }
212+
213+ // Docker containers and devcontainers may have a PTY with no real
214+ // terminal emulator, causing OSC responses to leak as visible text.
215+ if std:: path:: Path :: new ( "/.dockerenv" ) . exists ( )
216+ || std:: env:: var_os ( "REMOTE_CONTAINERS" ) . is_some ( )
217+ || std:: env:: var_os ( "CODESPACES" ) . is_some ( )
218+ || std:: env:: var_os ( "KUBERNETES_SERVICE_HOST" ) . is_some ( )
219+ {
220+ return true ;
221+ }
222+
223+ match std:: env:: var ( "TERM" ) {
224+ // Missing or non-unicode TERM is highly suspect.
225+ Err ( _) => return true ,
226+ // `TERM=dumb` indicates a minimal terminal with no escape support.
227+ Ok ( term) if term == "dumb" => return true ,
228+ // GNU Screen responds to OSC queries in the wrong order, breaking
229+ // the DA1 sandwich technique. It also only supports OSC 11, not
230+ // OSC 10 or OSC 4.
231+ Ok ( term) if term == "screen" || term. starts_with ( "screen." ) => return true ,
232+ // Eterm doesn't support DA1, so we skip to avoid the timeout.
233+ Ok ( term) if term == "Eterm" => return true ,
234+ _ => { }
235+ }
236+
237+ // tmux and GNU Screen (via STY) do not reliably forward OSC color
238+ // query responses back to the child process.
239+ if std:: env:: var_os ( "TMUX" ) . is_some ( ) || std:: env:: var_os ( "STY" ) . is_some ( ) {
240+ return true ;
241+ }
242+
243+ false
244+ } )
245+ }
246+
247+ /// DA1 (Primary Device Attributes) query — supported by virtually all
248+ /// terminals. Used as a sentinel in the "DA1 sandwich" technique:
249+ /// we send our OSC queries followed by DA1, then read responses. If the
250+ /// DA1 response (`ESC [ ? ...`) arrives first, the terminal doesn't
251+ /// support OSC queries and we bail out immediately instead of waiting
252+ /// for a timeout.
253+ #[ cfg( unix) ]
254+ const DA1 : & str = "\x1b [c" ;
255+
182256#[ cfg( unix) ]
183257fn query_terminal_colors ( palette_indices : & [ u8 ] ) -> ( Option < Rgb > , Vec < ( u8 , Rgb ) > ) {
184258 use std:: {
@@ -192,20 +266,7 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
192266 unistd:: read,
193267 } ;
194268
195- if std:: env:: var_os ( "CI" ) . is_some ( ) {
196- return ( None , vec ! [ ] ) ;
197- }
198-
199- // Warp terminal does not respond to OSC color queries in its block-mode
200- // renderer. Sending the queries causes the process to appear stuck until
201- // the user presses a key (which is consumed as a fake "response").
202- if is_warp_terminal ( ) {
203- return ( None , vec ! [ ] ) ;
204- }
205-
206- // tmux does not reliably forward OSC color query responses back to the
207- // child process, causing the same hang-until-keypress behavior as Warp.
208- if std:: env:: var_os ( "TMUX" ) . is_some ( ) {
269+ if is_osc_query_unsupported ( ) {
209270 return ( None , vec ! [ ] ) ;
210271 }
211272
@@ -214,10 +275,6 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
214275 Err ( _) => return ( None , vec ! [ ] ) ,
215276 } ;
216277
217- if !std:: io:: stdout ( ) . is_terminal ( ) {
218- return ( None , vec ! [ ] ) ;
219- }
220-
221278 struct RawGuard {
222279 fd : RawFd ,
223280 original : Termios ,
@@ -242,21 +299,40 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
242299 }
243300 let _guard = RawGuard { fd : tty. as_raw_fd ( ) , original } ;
244301
245- let mut query = format ! ( "{ESC}]10;?{ESC}\\ " ) ;
302+ // Build the query: OSC 10 (foreground) + OSC 4 (palette) + DA1 (sentinel).
303+ // BEL (\x07) is used as string terminator instead of ST (\x1b\\) because
304+ // urxvt has a bug where it terminates responses with bare ESC instead of
305+ // ST, causing a parse hang. BEL-terminated queries produce BEL-terminated
306+ // responses, avoiding this issue.
307+ let mut query = format ! ( "{ESC}]10;?\x07 " ) ;
246308 for index in palette_indices {
247- query. push_str ( & format ! ( "{ESC}]4;{index};?{ESC} \\ " ) ) ;
309+ query. push_str ( & format ! ( "{ESC}]4;{index};?\x07 " ) ) ;
248310 }
311+ // DA1 sentinel — its response acts as a fence to detect unsupported
312+ // terminals.
313+ query. push_str ( DA1 ) ;
314+
249315 if tty. write_all ( query. as_bytes ( ) ) . is_err ( ) {
250316 return ( None , vec ! [ ] ) ;
251317 }
252318 if tty. flush ( ) . is_err ( ) {
253319 return ( None , vec ! [ ] ) ;
254320 }
255321
256- let deadline = Instant :: now ( ) + Duration :: from_millis ( 100 ) ;
322+ // Use a longer timeout for SSH to account for round-trip latency.
323+ let timeout_ms = if std:: env:: var_os ( "SSH_CONNECTION" ) . is_some ( )
324+ || std:: env:: var_os ( "SSH_TTY" ) . is_some ( )
325+ {
326+ 1000
327+ } else {
328+ 200
329+ } ;
330+
331+ let deadline = Instant :: now ( ) + Duration :: from_millis ( timeout_ms) ;
257332 let mut last_data = Instant :: now ( ) ;
258333 let mut buffer = String :: new ( ) ;
259334 let mut foreground = None ;
335+ let mut da1_arrived_first = false ;
260336 let mut palette_colors: Vec < ( u8 , Option < Rgb > ) > =
261337 palette_indices. iter ( ) . copied ( ) . map ( |index| ( index, None ) ) . collect ( ) ;
262338
@@ -296,6 +372,14 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
296372 buffer = buffer[ keep_from..] . to_string ( ) ;
297373 }
298374
375+ // DA1 response starts with `ESC [ ?`. If it arrives before any OSC
376+ // response, the terminal doesn't support OSC queries — bail out
377+ // immediately instead of waiting for the full timeout.
378+ if foreground. is_none ( ) && buffer. contains ( "\x1b [?" ) {
379+ da1_arrived_first = true ;
380+ break ;
381+ }
382+
299383 if foreground. is_none ( ) {
300384 foreground = parse_osc10_rgb ( & buffer) ;
301385 }
@@ -306,17 +390,64 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
306390 }
307391
308392 if foreground. is_some ( ) && palette_colors. iter ( ) . all ( |( _, color) | color. is_some ( ) ) {
393+ // All expected responses received. Drain the trailing DA1
394+ // response so it doesn't leak into the user's input buffer.
395+ drain_da1 ( & tty, & deadline) ;
309396 break ;
310397 }
311398 }
312399
400+ if da1_arrived_first {
401+ return ( None , vec ! [ ] ) ;
402+ }
403+
313404 let resolved = palette_colors
314405 . into_iter ( )
315406 . filter_map ( |( index, color) | color. map ( |rgb| ( index, rgb) ) )
316407 . collect ( ) ;
317408 ( foreground, resolved)
318409}
319410
411+ /// Consume the trailing DA1 response (`ESC [ ? ... c`) so it doesn't leak
412+ /// as visible text after raw mode is restored.
413+ #[ cfg( unix) ]
414+ fn drain_da1 ( tty : & std:: fs:: File , deadline : & Instant ) {
415+ use std:: os:: fd:: AsFd ;
416+
417+ use nix:: {
418+ poll:: { PollFd , PollFlags , PollTimeout , poll} ,
419+ unistd:: read,
420+ } ;
421+
422+ while Instant :: now ( ) < * deadline {
423+ let remaining = deadline. saturating_duration_since ( Instant :: now ( ) ) ;
424+ let wait = remaining. min ( Duration :: from_millis ( 20 ) ) ;
425+
426+ let mut fds = [ PollFd :: new ( tty. as_fd ( ) , PollFlags :: POLLIN ) ] ;
427+ let timeout = match PollTimeout :: try_from ( wait) {
428+ Ok ( value) => value,
429+ Err ( _) => break ,
430+ } ;
431+ let ready = match poll ( & mut fds, timeout) {
432+ Ok ( value) => value,
433+ Err ( _) => break ,
434+ } ;
435+ if ready == 0 {
436+ break ;
437+ }
438+
439+ let mut chunk = [ 0_u8 ; 64 ] ;
440+ let n = match read ( tty. as_fd ( ) , & mut chunk) {
441+ Ok ( value) => value,
442+ Err ( _) => break ,
443+ } ;
444+ // DA1 response ends with 'c'. Once we see it, we're done.
445+ if chunk[ ..n] . contains ( & b'c' ) {
446+ break ;
447+ }
448+ }
449+ }
450+
320451#[ cfg( not( unix) ) ]
321452fn query_terminal_colors ( _palette_indices : & [ u8 ] ) -> ( Option < Rgb > , Vec < ( u8 , Rgb ) > ) {
322453 ( None , vec ! [ ] )
@@ -376,7 +507,10 @@ pub fn vite_plus_header() -> String {
376507
377508#[ cfg( all( test, unix) ) ]
378509mod tests {
379- use super :: { Rgb , gradient_eased, parse_osc4_rgb, parse_osc10_rgb, to_8bit} ;
510+ use super :: {
511+ Rgb , gradient_eased, parse_osc4_rgb, parse_osc10_rgb, parse_rgb_triplet,
512+ query_terminal_colors, to_8bit,
513+ } ;
380514
381515 #[ test]
382516 fn to_8bit_matches_js_rules ( ) {
@@ -387,21 +521,137 @@ mod tests {
387521 assert_eq ! ( to_8bit( "fff" ) , Some ( 255 ) ) ;
388522 }
389523
524+ #[ test]
525+ fn to_8bit_single_digit ( ) {
526+ assert_eq ! ( to_8bit( "f" ) , Some ( 255 ) ) ;
527+ assert_eq ! ( to_8bit( "0" ) , Some ( 0 ) ) ;
528+ assert_eq ! ( to_8bit( "a" ) , Some ( 170 ) ) ;
529+ }
530+
531+ #[ test]
532+ fn to_8bit_three_digit ( ) {
533+ assert_eq ! ( to_8bit( "fff" ) , Some ( 255 ) ) ;
534+ assert_eq ! ( to_8bit( "000" ) , Some ( 0 ) ) ;
535+ assert_eq ! ( to_8bit( "800" ) , Some ( 128 ) ) ;
536+ }
537+
538+ #[ test]
539+ fn to_8bit_empty_returns_none ( ) {
540+ assert_eq ! ( to_8bit( "" ) , None ) ;
541+ }
542+
543+ #[ test]
544+ fn to_8bit_invalid_hex_returns_none ( ) {
545+ assert_eq ! ( to_8bit( "zz" ) , None ) ;
546+ assert_eq ! ( to_8bit( "gg" ) , None ) ;
547+ }
548+
549+ #[ test]
550+ fn parse_rgb_triplet_standard ( ) {
551+ assert_eq ! ( parse_rgb_triplet( "ff/ff/ff" ) , Some ( Rgb ( 255 , 255 , 255 ) ) ) ;
552+ assert_eq ! ( parse_rgb_triplet( "00/00/00" ) , Some ( Rgb ( 0 , 0 , 0 ) ) ) ;
553+ }
554+
555+ #[ test]
556+ fn parse_rgb_triplet_four_digit_channels ( ) {
557+ assert_eq ! ( parse_rgb_triplet( "ffff/ffff/ffff" ) , Some ( Rgb ( 255 , 255 , 255 ) ) ) ;
558+ assert_eq ! ( parse_rgb_triplet( "0000/0000/0000" ) , Some ( Rgb ( 0 , 0 , 0 ) ) ) ;
559+ assert_eq ! ( parse_rgb_triplet( "aaaa/bbbb/cccc" ) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
560+ }
561+
562+ #[ test]
563+ fn parse_rgb_triplet_mixed_digit_channels ( ) {
564+ // Single digit channels
565+ assert_eq ! ( parse_rgb_triplet( "f/e/d" ) , Some ( Rgb ( 255 , 238 , 221 ) ) ) ;
566+ }
567+
568+ #[ test]
569+ fn parse_rgb_triplet_trailing_junk_ignored ( ) {
570+ // The parser stops at non-hex chars for the blue channel
571+ assert_eq ! ( parse_rgb_triplet( "ff/ff/ff\x1b \\ " ) , Some ( Rgb ( 255 , 255 , 255 ) ) ) ;
572+ }
573+
574+ #[ test]
575+ fn parse_rgb_triplet_missing_channel_returns_none ( ) {
576+ assert_eq ! ( parse_rgb_triplet( "ff/ff" ) , None ) ;
577+ assert_eq ! ( parse_rgb_triplet( "ff" ) , None ) ;
578+ }
579+
390580 #[ test]
391581 fn parse_osc10_response_extracts_rgb ( ) {
392582 let response = "\x1b ]10;rgb:aaaa/bbbb/cccc\x1b \\ " ;
393583 assert_eq ! ( parse_osc10_rgb( response) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
394584 }
395585
586+ #[ test]
587+ fn parse_osc10_bel_terminated ( ) {
588+ let response = "\x1b ]10;rgb:aaaa/bbbb/cccc\x07 " ;
589+ assert_eq ! ( parse_osc10_rgb( response) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
590+ }
591+
592+ #[ test]
593+ fn parse_osc10_no_match_returns_none ( ) {
594+ assert_eq ! ( parse_osc10_rgb( "garbage" ) , None ) ;
595+ assert_eq ! ( parse_osc10_rgb( "" ) , None ) ;
596+ }
597+
396598 #[ test]
397599 fn parse_osc4_response_extracts_rgb ( ) {
398600 let response = "\x1b ]4;5;rgb:aaaa/bbbb/cccc\x1b \\ " ;
399601 assert_eq ! ( parse_osc4_rgb( response, 5 ) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
400602 }
401603
604+ #[ test]
605+ fn parse_osc4_bel_terminated ( ) {
606+ let response = "\x1b ]4;4;rgb:5858/9292/ffff\x07 " ;
607+ assert_eq ! ( parse_osc4_rgb( response, 4 ) , Some ( Rgb ( 88 , 146 , 255 ) ) ) ;
608+ }
609+
610+ #[ test]
611+ fn parse_osc4_wrong_index_returns_none ( ) {
612+ let response = "\x1b ]4;5;rgb:aaaa/bbbb/cccc\x1b \\ " ;
613+ assert_eq ! ( parse_osc4_rgb( response, 4 ) , None ) ;
614+ }
615+
616+ #[ test]
617+ fn parse_osc4_no_match_returns_none ( ) {
618+ assert_eq ! ( parse_osc4_rgb( "garbage" , 5 ) , None ) ;
619+ assert_eq ! ( parse_osc4_rgb( "" , 0 ) , None ) ;
620+ }
621+
622+ #[ test]
623+ fn parse_osc_multiple_responses_in_buffer ( ) {
624+ // Simulates a buffer containing OSC 10 + OSC 4;4 + OSC 4;5 responses
625+ let buffer = "\x1b ]10;rgb:d0d0/d0d0/d0d0\x07 \
626+ \x1b ]4;4;rgb:5858/9292/ffff\x07 \
627+ \x1b ]4;5;rgb:bbbb/7474/f7f7\x07 ";
628+ assert_eq ! ( parse_osc10_rgb( buffer) , Some ( Rgb ( 208 , 208 , 208 ) ) ) ;
629+ assert_eq ! ( parse_osc4_rgb( buffer, 4 ) , Some ( Rgb ( 88 , 146 , 255 ) ) ) ;
630+ assert_eq ! ( parse_osc4_rgb( buffer, 5 ) , Some ( Rgb ( 187 , 116 , 247 ) ) ) ;
631+ }
632+
633+ #[ test]
634+ fn parse_osc_buffer_with_da1_response ( ) {
635+ // DA1 response mixed in — OSC parsers should still find their data
636+ let buffer = "\x1b ]10;rgb:d0d0/d0d0/d0d0\x07 \x1b [?64;1;2;4c" ;
637+ assert_eq ! ( parse_osc10_rgb( buffer) , Some ( Rgb ( 208 , 208 , 208 ) ) ) ;
638+ }
639+
402640 #[ test]
403641 fn gradient_counts_match ( ) {
404642 assert_eq ! ( gradient_eased( 0 , Rgb ( 0 , 0 , 0 ) , Rgb ( 255 , 255 , 255 ) , 1.0 ) . len( ) , 1 ) ;
405643 assert_eq ! ( gradient_eased( 5 , Rgb ( 10 , 20 , 30 ) , Rgb ( 40 , 50 , 60 ) , 1.0 ) . len( ) , 5 ) ;
406644 }
645+
646+ /// Regression test ported from terminal-colorsaurus (issue #38).
647+ /// In CI there is no real terminal, so `query_terminal_colors` must
648+ /// return `(None, vec![])` without hanging.
649+ #[ test]
650+ fn query_terminal_colors_does_not_hang ( ) {
651+ let ( fg, palette) = query_terminal_colors ( & [ 4 , 5 ] ) ;
652+ // In CI, the environment pre-screening or DA1 sandwich will cause an
653+ // early return. We don't assert specific values — just that it
654+ // completes promptly and doesn't panic.
655+ let _ = ( fg, palette) ;
656+ }
407657}
0 commit comments