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,39 @@ 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 =
324+ if std:: env:: var_os ( "SSH_CONNECTION" ) . is_some ( ) || std:: env:: var_os ( "SSH_TTY" ) . is_some ( ) {
325+ 1000
326+ } else {
327+ 200
328+ } ;
329+
330+ let deadline = Instant :: now ( ) + Duration :: from_millis ( timeout_ms) ;
257331 let mut last_data = Instant :: now ( ) ;
258332 let mut buffer = String :: new ( ) ;
259333 let mut foreground = None ;
334+ let mut da1_arrived_first = false ;
260335 let mut palette_colors: Vec < ( u8 , Option < Rgb > ) > =
261336 palette_indices. iter ( ) . copied ( ) . map ( |index| ( index, None ) ) . collect ( ) ;
262337
@@ -296,6 +371,10 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
296371 buffer = buffer[ keep_from..] . to_string ( ) ;
297372 }
298373
374+ // Try to parse OSC responses before checking for DA1. On fast
375+ // terminals all responses (OSC + DA1) may arrive in a single read,
376+ // so we must extract OSC data first to avoid a false "DA1 arrived
377+ // first" conclusion.
299378 if foreground. is_none ( ) {
300379 foreground = parse_osc10_rgb ( & buffer) ;
301380 }
@@ -305,18 +384,73 @@ fn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>
305384 }
306385 }
307386
387+ // DA1 response starts with `ESC [ ?`. If it arrives before any OSC
388+ // response, the terminal doesn't support OSC queries — bail out
389+ // immediately instead of waiting for the full timeout.
390+ if foreground. is_none ( ) && buffer. contains ( "\x1b [?" ) {
391+ da1_arrived_first = true ;
392+ break ;
393+ }
394+
308395 if foreground. is_some ( ) && palette_colors. iter ( ) . all ( |( _, color) | color. is_some ( ) ) {
396+ // All expected responses received. Drain the trailing DA1
397+ // response so it doesn't leak into the user's input buffer.
398+ drain_da1 ( & tty, & deadline) ;
309399 break ;
310400 }
311401 }
312402
403+ if da1_arrived_first {
404+ return ( None , vec ! [ ] ) ;
405+ }
406+
313407 let resolved = palette_colors
314408 . into_iter ( )
315409 . filter_map ( |( index, color) | color. map ( |rgb| ( index, rgb) ) )
316410 . collect ( ) ;
317411 ( foreground, resolved)
318412}
319413
414+ /// Consume the trailing DA1 response (`ESC [ ? ... c`) so it doesn't leak
415+ /// as visible text after raw mode is restored.
416+ #[ cfg( unix) ]
417+ fn drain_da1 ( tty : & std:: fs:: File , deadline : & Instant ) {
418+ use std:: os:: fd:: AsFd ;
419+
420+ use nix:: {
421+ poll:: { PollFd , PollFlags , PollTimeout , poll} ,
422+ unistd:: read,
423+ } ;
424+
425+ while Instant :: now ( ) < * deadline {
426+ let remaining = deadline. saturating_duration_since ( Instant :: now ( ) ) ;
427+ let wait = remaining. min ( Duration :: from_millis ( 20 ) ) ;
428+
429+ let mut fds = [ PollFd :: new ( tty. as_fd ( ) , PollFlags :: POLLIN ) ] ;
430+ let timeout = match PollTimeout :: try_from ( wait) {
431+ Ok ( value) => value,
432+ Err ( _) => break ,
433+ } ;
434+ let ready = match poll ( & mut fds, timeout) {
435+ Ok ( value) => value,
436+ Err ( _) => break ,
437+ } ;
438+ if ready == 0 {
439+ break ;
440+ }
441+
442+ let mut chunk = [ 0_u8 ; 64 ] ;
443+ let n = match read ( tty. as_fd ( ) , & mut chunk) {
444+ Ok ( value) => value,
445+ Err ( _) => break ,
446+ } ;
447+ // DA1 response ends with 'c'. Once we see it, we're done.
448+ if chunk[ ..n] . contains ( & b'c' ) {
449+ break ;
450+ }
451+ }
452+ }
453+
320454#[ cfg( not( unix) ) ]
321455fn query_terminal_colors ( _palette_indices : & [ u8 ] ) -> ( Option < Rgb > , Vec < ( u8 , Rgb ) > ) {
322456 ( None , vec ! [ ] )
@@ -376,7 +510,10 @@ pub fn vite_plus_header() -> String {
376510
377511#[ cfg( all( test, unix) ) ]
378512mod tests {
379- use super :: { Rgb , gradient_eased, parse_osc4_rgb, parse_osc10_rgb, to_8bit} ;
513+ use super :: {
514+ Rgb , gradient_eased, parse_osc4_rgb, parse_osc10_rgb, parse_rgb_triplet,
515+ query_terminal_colors, to_8bit,
516+ } ;
380517
381518 #[ test]
382519 fn to_8bit_matches_js_rules ( ) {
@@ -387,21 +524,137 @@ mod tests {
387524 assert_eq ! ( to_8bit( "fff" ) , Some ( 255 ) ) ;
388525 }
389526
527+ #[ test]
528+ fn to_8bit_single_digit ( ) {
529+ assert_eq ! ( to_8bit( "f" ) , Some ( 255 ) ) ;
530+ assert_eq ! ( to_8bit( "0" ) , Some ( 0 ) ) ;
531+ assert_eq ! ( to_8bit( "a" ) , Some ( 170 ) ) ;
532+ }
533+
534+ #[ test]
535+ fn to_8bit_three_digit ( ) {
536+ assert_eq ! ( to_8bit( "fff" ) , Some ( 255 ) ) ;
537+ assert_eq ! ( to_8bit( "000" ) , Some ( 0 ) ) ;
538+ assert_eq ! ( to_8bit( "800" ) , Some ( 128 ) ) ;
539+ }
540+
541+ #[ test]
542+ fn to_8bit_empty_returns_none ( ) {
543+ assert_eq ! ( to_8bit( "" ) , None ) ;
544+ }
545+
546+ #[ test]
547+ fn to_8bit_invalid_hex_returns_none ( ) {
548+ assert_eq ! ( to_8bit( "zz" ) , None ) ;
549+ assert_eq ! ( to_8bit( "gg" ) , None ) ;
550+ }
551+
552+ #[ test]
553+ fn parse_rgb_triplet_standard ( ) {
554+ assert_eq ! ( parse_rgb_triplet( "ff/ff/ff" ) , Some ( Rgb ( 255 , 255 , 255 ) ) ) ;
555+ assert_eq ! ( parse_rgb_triplet( "00/00/00" ) , Some ( Rgb ( 0 , 0 , 0 ) ) ) ;
556+ }
557+
558+ #[ test]
559+ fn parse_rgb_triplet_four_digit_channels ( ) {
560+ assert_eq ! ( parse_rgb_triplet( "ffff/ffff/ffff" ) , Some ( Rgb ( 255 , 255 , 255 ) ) ) ;
561+ assert_eq ! ( parse_rgb_triplet( "0000/0000/0000" ) , Some ( Rgb ( 0 , 0 , 0 ) ) ) ;
562+ assert_eq ! ( parse_rgb_triplet( "aaaa/bbbb/cccc" ) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
563+ }
564+
565+ #[ test]
566+ fn parse_rgb_triplet_mixed_digit_channels ( ) {
567+ // Single digit channels
568+ assert_eq ! ( parse_rgb_triplet( "f/e/d" ) , Some ( Rgb ( 255 , 238 , 221 ) ) ) ;
569+ }
570+
571+ #[ test]
572+ fn parse_rgb_triplet_trailing_junk_ignored ( ) {
573+ // The parser stops at non-hex chars for the blue channel
574+ assert_eq ! ( parse_rgb_triplet( "ff/ff/ff\x1b \\ " ) , Some ( Rgb ( 255 , 255 , 255 ) ) ) ;
575+ }
576+
577+ #[ test]
578+ fn parse_rgb_triplet_missing_channel_returns_none ( ) {
579+ assert_eq ! ( parse_rgb_triplet( "ff/ff" ) , None ) ;
580+ assert_eq ! ( parse_rgb_triplet( "ff" ) , None ) ;
581+ }
582+
390583 #[ test]
391584 fn parse_osc10_response_extracts_rgb ( ) {
392585 let response = "\x1b ]10;rgb:aaaa/bbbb/cccc\x1b \\ " ;
393586 assert_eq ! ( parse_osc10_rgb( response) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
394587 }
395588
589+ #[ test]
590+ fn parse_osc10_bel_terminated ( ) {
591+ let response = "\x1b ]10;rgb:aaaa/bbbb/cccc\x07 " ;
592+ assert_eq ! ( parse_osc10_rgb( response) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
593+ }
594+
595+ #[ test]
596+ fn parse_osc10_no_match_returns_none ( ) {
597+ assert_eq ! ( parse_osc10_rgb( "garbage" ) , None ) ;
598+ assert_eq ! ( parse_osc10_rgb( "" ) , None ) ;
599+ }
600+
396601 #[ test]
397602 fn parse_osc4_response_extracts_rgb ( ) {
398603 let response = "\x1b ]4;5;rgb:aaaa/bbbb/cccc\x1b \\ " ;
399604 assert_eq ! ( parse_osc4_rgb( response, 5 ) , Some ( Rgb ( 170 , 187 , 204 ) ) ) ;
400605 }
401606
607+ #[ test]
608+ fn parse_osc4_bel_terminated ( ) {
609+ let response = "\x1b ]4;4;rgb:5858/9292/ffff\x07 " ;
610+ assert_eq ! ( parse_osc4_rgb( response, 4 ) , Some ( Rgb ( 88 , 146 , 255 ) ) ) ;
611+ }
612+
613+ #[ test]
614+ fn parse_osc4_wrong_index_returns_none ( ) {
615+ let response = "\x1b ]4;5;rgb:aaaa/bbbb/cccc\x1b \\ " ;
616+ assert_eq ! ( parse_osc4_rgb( response, 4 ) , None ) ;
617+ }
618+
619+ #[ test]
620+ fn parse_osc4_no_match_returns_none ( ) {
621+ assert_eq ! ( parse_osc4_rgb( "garbage" , 5 ) , None ) ;
622+ assert_eq ! ( parse_osc4_rgb( "" , 0 ) , None ) ;
623+ }
624+
625+ #[ test]
626+ fn parse_osc_multiple_responses_in_buffer ( ) {
627+ // Simulates a buffer containing OSC 10 + OSC 4;4 + OSC 4;5 responses
628+ let buffer = "\x1b ]10;rgb:d0d0/d0d0/d0d0\x07 \
629+ \x1b ]4;4;rgb:5858/9292/ffff\x07 \
630+ \x1b ]4;5;rgb:bbbb/7474/f7f7\x07 ";
631+ assert_eq ! ( parse_osc10_rgb( buffer) , Some ( Rgb ( 208 , 208 , 208 ) ) ) ;
632+ assert_eq ! ( parse_osc4_rgb( buffer, 4 ) , Some ( Rgb ( 88 , 146 , 255 ) ) ) ;
633+ assert_eq ! ( parse_osc4_rgb( buffer, 5 ) , Some ( Rgb ( 187 , 116 , 247 ) ) ) ;
634+ }
635+
636+ #[ test]
637+ fn parse_osc_buffer_with_da1_response ( ) {
638+ // DA1 response mixed in — OSC parsers should still find their data
639+ let buffer = "\x1b ]10;rgb:d0d0/d0d0/d0d0\x07 \x1b [?64;1;2;4c" ;
640+ assert_eq ! ( parse_osc10_rgb( buffer) , Some ( Rgb ( 208 , 208 , 208 ) ) ) ;
641+ }
642+
402643 #[ test]
403644 fn gradient_counts_match ( ) {
404645 assert_eq ! ( gradient_eased( 0 , Rgb ( 0 , 0 , 0 ) , Rgb ( 255 , 255 , 255 ) , 1.0 ) . len( ) , 1 ) ;
405646 assert_eq ! ( gradient_eased( 5 , Rgb ( 10 , 20 , 30 ) , Rgb ( 40 , 50 , 60 ) , 1.0 ) . len( ) , 5 ) ;
406647 }
648+
649+ /// Regression test ported from terminal-colorsaurus (issue #38).
650+ /// In CI there is no real terminal, so `query_terminal_colors` must
651+ /// return `(None, vec![])` without hanging.
652+ #[ test]
653+ fn query_terminal_colors_does_not_hang ( ) {
654+ let ( fg, palette) = query_terminal_colors ( & [ 4 , 5 ] ) ;
655+ // In CI, the environment pre-screening or DA1 sandwich will cause an
656+ // early return. We don't assert specific values — just that it
657+ // completes promptly and doesn't panic.
658+ let _ = ( fg, palette) ;
659+ }
407660}
0 commit comments