@@ -40,6 +40,12 @@ const SCRATCH_ARENA_CAPACITY: usize = 128 * MEBI;
4040#[ cfg( target_pointer_width = "64" ) ]
4141const SCRATCH_ARENA_CAPACITY : usize = 512 * MEBI ;
4242
43+ // After DA, briefly drain follow-up probe responses so split OSC replies do not leak into
44+ // the main input parser. Keep the window short because real user input is still on stdin.
45+ const TERMINAL_PROBE_QUIET_TIMEOUT : Duration = Duration :: from_millis ( 50 ) ;
46+ // Bound terminal probing for terminals that omit DA or leave a control sequence unfinished.
47+ const TERMINAL_PROBE_HARD_TIMEOUT : Duration = Duration :: from_secs ( 3 ) ;
48+
4349// NOTE: Before our main() gets called, Rust initializes its stdlib. This pulls in the entire
4450// std::io::{stdin, stdout, stderr} machinery, and probably some more, which amounts to about 20KB.
4551// It can technically be avoided nowadays with `#![no_main]`. Maybe a fun project for later? :)
@@ -87,11 +93,9 @@ fn run() -> apperr::Result<()> {
8793 // As such, we call this after `handle_args`.
8894 sys:: switch_modes ( ) ?;
8995
90- let mut vt_parser = vt:: Parser :: new ( ) ;
91- let mut input_parser = input:: Parser :: new ( ) ;
9296 let mut tui = Tui :: new ( ) ?;
9397
94- let _restore = setup_terminal ( & mut tui, & mut state, & mut vt_parser ) ;
98+ let _restore = setup_terminal ( & mut tui, & mut state) ;
9599
96100 state. menubar_color_bg = tui. indexed ( IndexedColor :: Background ) . oklab_blend ( tui. indexed_alpha (
97101 IndexedColor :: BrightBlue ,
@@ -115,6 +119,11 @@ fn run() -> apperr::Result<()> {
115119
116120 sys:: inject_window_size_into_stdin ( ) ;
117121
122+ // Startup probing may leave partial terminal responses in the probe parser.
123+ // Start application input parsing with an independent parser lifecycle.
124+ let mut vt_parser = vt:: Parser :: new ( ) ;
125+ let mut input_parser = input:: Parser :: new ( ) ;
126+
118127 #[ cfg( feature = "debug-latency" ) ]
119128 let mut last_latency_width = 0 ;
120129
@@ -565,7 +574,91 @@ impl Drop for RestoreModes {
565574 }
566575}
567576
568- fn setup_terminal ( tui : & mut Tui , state : & mut State , vt_parser : & mut vt:: Parser ) -> RestoreModes {
577+ struct TerminalProbe {
578+ seen_device_attributes : bool ,
579+ seen_cursor_position : bool ,
580+ seen_colors : [ bool ; framebuffer:: INDEXED_COLORS_COUNT ] ,
581+ color_responses : usize ,
582+ quiet_deadline : Option < std:: time:: Instant > ,
583+ }
584+
585+ impl TerminalProbe {
586+ fn new ( ) -> Self {
587+ Self {
588+ seen_device_attributes : false ,
589+ seen_cursor_position : false ,
590+ seen_colors : [ false ; framebuffer:: INDEXED_COLORS_COUNT ] ,
591+ color_responses : 0 ,
592+ quiet_deadline : None ,
593+ }
594+ }
595+
596+ fn record_device_attributes ( & mut self , now : std:: time:: Instant ) {
597+ self . seen_device_attributes = true ;
598+ self . quiet_deadline = Some ( now + TERMINAL_PROBE_QUIET_TIMEOUT ) ;
599+ }
600+
601+ fn record_activity_after_device_attributes ( & mut self , now : std:: time:: Instant ) {
602+ if self . seen_device_attributes {
603+ self . quiet_deadline = Some ( now + TERMINAL_PROBE_QUIET_TIMEOUT ) ;
604+ }
605+ }
606+
607+ fn record_cursor_position ( & mut self ) {
608+ self . seen_cursor_position = true ;
609+ }
610+
611+ fn record_color ( & mut self , index : usize ) {
612+ if !self . seen_colors [ index] {
613+ self . seen_colors [ index] = true ;
614+ self . color_responses += 1 ;
615+ }
616+ }
617+
618+ fn color_responses ( & self ) -> usize {
619+ self . color_responses
620+ }
621+
622+ fn is_complete ( & self ) -> bool {
623+ self . seen_device_attributes
624+ && self . seen_cursor_position
625+ && self . color_responses == framebuffer:: INDEXED_COLORS_COUNT
626+ }
627+
628+ fn should_finish (
629+ & self ,
630+ parser_is_ground : bool ,
631+ now : std:: time:: Instant ,
632+ hard_deadline : std:: time:: Instant ,
633+ ) -> bool {
634+ if parser_is_ground && self . is_complete ( ) {
635+ return true ;
636+ }
637+
638+ if parser_is_ground
639+ && self . seen_device_attributes
640+ && self . quiet_deadline . is_some_and ( |deadline| now >= deadline)
641+ {
642+ return true ;
643+ }
644+
645+ now >= hard_deadline
646+ }
647+
648+ fn next_read_deadline (
649+ & self ,
650+ parser_is_ground : bool ,
651+ hard_deadline : std:: time:: Instant ,
652+ ) -> std:: time:: Instant {
653+ if parser_is_ground {
654+ self . quiet_deadline . unwrap_or ( hard_deadline) . min ( hard_deadline)
655+ } else {
656+ hard_deadline
657+ }
658+ }
659+ }
660+
661+ fn setup_terminal ( tui : & mut Tui , state : & mut State ) -> RestoreModes {
569662 sys:: write_stdout ( concat ! (
570663 // 1049: Alternative Screen Buffer
571664 // I put the ASB switch in the beginning, just in case the terminal performs
@@ -592,32 +685,49 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser)
592685 "\x1b [c" ,
593686 ) ) ;
594687
595- let mut done = false ;
596688 let mut osc_buffer = String :: new ( ) ;
597689 let mut indexed_colors = framebuffer:: DEFAULT_THEME ;
598- let mut color_responses = 0 ;
599690 let mut ambiguous_width = 1 ;
691+ let mut probe = TerminalProbe :: new ( ) ;
692+ let mut vt_parser = vt:: Parser :: new ( ) ;
693+ let hard_deadline = std:: time:: Instant :: now ( ) + TERMINAL_PROBE_HARD_TIMEOUT ;
694+
695+ loop {
696+ let now = std:: time:: Instant :: now ( ) ;
697+ if probe. should_finish ( vt_parser. is_ground ( ) , now, hard_deadline) {
698+ break ;
699+ }
600700
601- while !done {
602701 let scratch = scratch_arena ( None ) ;
702+ let mut timeout = probe
703+ . next_read_deadline ( vt_parser. is_ground ( ) , hard_deadline)
704+ . saturating_duration_since ( now) ;
705+ timeout = timeout. min ( vt_parser. read_timeout ( ) ) ;
603706
604- // We explicitly set a high read timeout, because we're not
605- // waiting for user keyboard input. If we encounter a lone ESC,
606- // it's unlikely to be from a ESC keypress, but rather from a VT sequence.
607- let Some ( input) = sys:: read_stdin ( & scratch, Duration :: from_secs ( 3 ) ) else {
707+ let Some ( input) = sys:: read_stdin ( & scratch, timeout) else {
608708 break ;
609709 } ;
610710
711+ let got_input = !input. is_empty ( ) ;
712+ let mut probe_activity = false ;
611713 let mut vt_stream = vt_parser. parse ( & input) ;
612714 while let Some ( token) = vt_stream. next ( ) {
613715 match token {
614716 Token :: Csi ( csi) => match csi. final_byte {
615- 'c' => done = true ,
717+ 'c' => {
718+ probe. record_device_attributes ( std:: time:: Instant :: now ( ) ) ;
719+ probe_activity = true ;
720+ }
616721 // CPR (Cursor Position Report) response.
617- 'R' => ambiguous_width = csi. params [ 1 ] as CoordType - 1 ,
722+ 'R' => {
723+ probe. record_cursor_position ( ) ;
724+ ambiguous_width = csi. params [ 1 ] as CoordType - 1 ;
725+ probe_activity = true ;
726+ }
618727 _ => { }
619728 } ,
620729 Token :: Osc { mut data, partial } => {
730+ probe_activity = true ;
621731 if partial {
622732 osc_buffer. push_str ( data) ;
623733 continue ;
@@ -629,16 +739,16 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser)
629739
630740 let mut splits = data. split_terminator ( ';' ) ;
631741
632- let color = match splits. next ( ) . unwrap_or ( "" ) {
742+ let color_index = match splits. next ( ) . unwrap_or ( "" ) {
633743 // The response is `4;<color>;rgb:<r>/<g>/<b>`.
634744 "4" => match splits. next ( ) . unwrap_or ( "" ) . parse :: < usize > ( ) {
635- Ok ( val) if val < 16 => & mut indexed_colors [ val] ,
745+ Ok ( val) if val < 16 => val,
636746 _ => continue ,
637747 } ,
638748 // The response is `10;rgb:<r>/<g>/<b>`.
639- "10" => & mut indexed_colors [ IndexedColor :: Foreground as usize ] ,
749+ "10" => IndexedColor :: Foreground as usize ,
640750 // The response is `11;rgb:<r>/<g>/<b>`.
641- "11" => & mut indexed_colors [ IndexedColor :: Background as usize ] ,
751+ "11" => IndexedColor :: Background as usize ,
642752 _ => continue ,
643753 } ;
644754
@@ -664,21 +774,26 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser)
664774 }
665775 }
666776
667- * color = StraightRgba :: from_le ( rgb | 0xff000000 ) ;
668- color_responses += 1 ;
777+ indexed_colors [ color_index ] = StraightRgba :: from_le ( rgb | 0xff000000 ) ;
778+ probe . record_color ( color_index ) ;
669779 osc_buffer. clear ( ) ;
670780 }
781+ Token :: Dcs { .. } => probe_activity = true ,
671782 _ => { }
672783 }
673784 }
785+
786+ if probe_activity || got_input && !vt_parser. is_ground ( ) {
787+ probe. record_activity_after_device_attributes ( std:: time:: Instant :: now ( ) ) ;
788+ }
674789 }
675790
676791 if ambiguous_width == 2 {
677792 unicode:: setup_ambiguous_width ( 2 ) ;
678793 state. documents . reflow_all ( ) ;
679794 }
680795
681- if color_responses == indexed_colors. len ( ) {
796+ if probe . color_responses ( ) == indexed_colors. len ( ) {
682797 tui. setup_indexed_colors ( indexed_colors) ;
683798 }
684799
@@ -704,3 +819,60 @@ fn sanitize_control_chars(text: &str) -> Cow<'_, str> {
704819 Cow :: Borrowed ( text)
705820 }
706821}
822+
823+ #[ cfg( test) ]
824+ mod terminal_probe_tests {
825+ use super :: * ;
826+
827+ #[ test]
828+ fn probe_waits_past_quiet_window_when_parser_has_pending_sequence ( ) {
829+ let start = std:: time:: Instant :: now ( ) ;
830+ let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT ;
831+ let mut probe = TerminalProbe :: new ( ) ;
832+
833+ probe. record_device_attributes ( start) ;
834+ let quiet_deadline = start + TERMINAL_PROBE_QUIET_TIMEOUT ;
835+
836+ assert ! ( !probe. should_finish( false , quiet_deadline, hard_deadline) ) ;
837+ assert ! ( probe. should_finish( false , hard_deadline, hard_deadline) ) ;
838+ }
839+
840+ #[ test]
841+ fn probe_finishes_after_quiet_window_when_parser_is_ground ( ) {
842+ let start = std:: time:: Instant :: now ( ) ;
843+ let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT ;
844+ let mut probe = TerminalProbe :: new ( ) ;
845+
846+ probe. record_device_attributes ( start) ;
847+ let quiet_deadline = start + TERMINAL_PROBE_QUIET_TIMEOUT ;
848+
849+ assert ! ( probe. should_finish( true , quiet_deadline, hard_deadline) ) ;
850+ }
851+
852+ #[ test]
853+ fn probe_complete_fast_path_requires_ground_parser ( ) {
854+ let start = std:: time:: Instant :: now ( ) ;
855+ let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT ;
856+ let mut probe = TerminalProbe :: new ( ) ;
857+
858+ probe. record_device_attributes ( start) ;
859+ probe. record_cursor_position ( ) ;
860+ for index in 0 ..framebuffer:: INDEXED_COLORS_COUNT {
861+ probe. record_color ( index) ;
862+ }
863+
864+ assert ! ( !probe. should_finish( false , start, hard_deadline) ) ;
865+ assert ! ( probe. should_finish( true , start, hard_deadline) ) ;
866+ }
867+
868+ #[ test]
869+ fn probe_counts_unique_color_responses ( ) {
870+ let mut probe = TerminalProbe :: new ( ) ;
871+
872+ probe. record_color ( IndexedColor :: Foreground as usize ) ;
873+ probe. record_color ( IndexedColor :: Foreground as usize ) ;
874+ probe. record_color ( IndexedColor :: Background as usize ) ;
875+
876+ assert_eq ! ( probe. color_responses( ) , 2 ) ;
877+ }
878+ }
0 commit comments