diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index a3c25a8001..3396f9fa38 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -24,8 +24,16 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::task::Context; use std::task::Poll; +#[cfg(unix)] +use std::time::Duration; +#[cfg(unix)] +use std::time::Instant; use crossterm::event::Event; +#[cfg(unix)] +use crossterm::event::KeyCode; +#[cfg(unix)] +use crossterm::event::KeyEventKind; use tokio::sync::broadcast; use tokio::sync::watch; use tokio_stream::Stream; @@ -146,6 +154,143 @@ pub struct TuiEventStream, + #[cfg(unix)] + post_resume_input_guard: PostResumeInputGuard, +} + +#[cfg(unix)] +const POST_RESUME_INPUT_GUARD_DURATION: Duration = Duration::from_millis(/*millis*/ 500); + +#[cfg(unix)] +#[derive(Debug, Default)] +struct PostResumeInputGuard { + expires_at: Option, + focus_sequence_index: u8, +} + +#[cfg(unix)] +impl PostResumeInputGuard { + fn arm(&mut self, now: Instant) { + self.expires_at = now.checked_add(POST_RESUME_INPUT_GUARD_DURATION); + self.focus_sequence_index = 0; + tracing::trace!( + event = "tui_post_resume_input_guard_armed", + duration_ms = %POST_RESUME_INPUT_GUARD_DURATION.as_millis(), + "armed post-resume terminal input guard" + ); + } + + fn should_drop(&mut self, event: &Event, now: Instant) -> bool { + if !self.expires_at.is_some_and(|expires_at| now <= expires_at) { + self.disarm(); + return false; + } + + self.trace_event(event); + + match event { + Event::Paste(pasted) => { + let should_drop = pasted.as_bytes() == b"\x1b[I"; + if should_drop { + tracing::debug!( + event = "tui_post_resume_focus_report_suppressed", + delivery = "paste", + "suppressed literal focus report after resume" + ); + } + self.disarm(); + should_drop + } + Event::FocusGained => { + self.disarm(); + false + } + Event::Key(key_event) + if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let matches_focus_sequence = match self.focus_sequence_index { + 0 => matches!(key_event.code, KeyCode::Esc | KeyCode::Char('\x1b')), + 1 => matches!(key_event.code, KeyCode::Char('[')), + 2 => matches!(key_event.code, KeyCode::Char('I')), + _ => false, + }; + if !matches_focus_sequence { + self.disarm(); + return false; + } + + self.focus_sequence_index += 1; + if self.focus_sequence_index == 3 { + tracing::debug!( + event = "tui_post_resume_focus_report_suppressed", + delivery = "split_key_events", + "suppressed split focus report after resume" + ); + self.disarm(); + } + true + } + Event::Key(_) => false, + _ => false, + } + } + + fn trace_event(&self, event: &Event) { + let event_kind = match event { + Event::Key(key_event) => match key_event.code { + KeyCode::Esc => "key_escape", + KeyCode::Char('\x1b') => "key_escape_char", + KeyCode::Char('^') => "key_caret", + KeyCode::Char('[') => "key_left_bracket", + KeyCode::Char('I') => "key_upper_i", + KeyCode::Char(_) => "key_char_other", + _ => "key_other", + }, + Event::Paste(pasted) if pasted.as_bytes() == b"\x1b[I" => "paste_focus_report", + Event::Paste(_) => "paste_other", + Event::FocusGained => "focus_gained", + Event::FocusLost => "focus_lost", + Event::Resize(_, _) => "resize", + _ => "other", + }; + let paste_byte_len = match event { + Event::Paste(pasted) => Some(pasted.len()), + _ => None, + }; + let paste_contains_escape = match event { + Event::Paste(pasted) => Some(pasted.contains('\x1b')), + _ => None, + }; + let paste_matches_visible_focus_report = match event { + Event::Paste(pasted) => Some(pasted == "^[[I"), + _ => None, + }; + let key_kind = match event { + Event::Key(key_event) => Some(key_event.kind), + _ => None, + }; + let key_modifiers = match event { + Event::Key(key_event) => Some(key_event.modifiers), + _ => None, + }; + + tracing::trace!( + event = "tui_post_resume_input_observed", + event_kind, + focus_sequence_index = self.focus_sequence_index, + ?paste_byte_len, + ?paste_contains_escape, + ?paste_matches_visible_focus_report, + ?key_kind, + ?key_modifiers, + "observed terminal input after resume" + ); + } + + fn disarm(&mut self) { + self.expires_at = None; + self.focus_sequence_index = 0; + } } impl TuiEventStream { @@ -167,6 +312,8 @@ impl TuiEventStream { suspend_context, #[cfg(unix)] alt_screen_active, + #[cfg(unix)] + post_resume_input_guard: PostResumeInputGuard::default(), } } @@ -235,13 +382,29 @@ impl TuiEventStream { /// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.). fn map_crossterm_event(&mut self, event: Event) -> Option { + #[cfg(unix)] + if self + .post_resume_input_guard + .should_drop(&event, Instant::now()) + { + return None; + } + match event { Event::Key(key_event) => { #[cfg(unix)] if crate::tui::job_control::SUSPEND_KEY.is_press(key_event) { self.broker.pause_events(); - let _ = self.suspend_context.suspend(&self.alt_screen_active); + let suspend_result = self.suspend_context.suspend(&self.alt_screen_active); self.broker.resume_events(); + match suspend_result { + Ok(()) => self.post_resume_input_guard.arm(Instant::now()), + Err(err) => tracing::warn!( + event = "tui_suspend_failed", + error = %err, + "failed to suspend TUI process" + ), + } return Some(TuiEvent::Draw); } Some(TuiEvent::Key(key_event)) @@ -303,6 +466,7 @@ mod tests { use std::task::Context; use std::task::Poll; use std::time::Duration; + use std::time::Instant; use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::time::timeout; @@ -390,6 +554,56 @@ mod tests { (broker, handle, draw_tx, draw_rx, terminal_focused) } + #[cfg(unix)] + #[test] + fn post_resume_guard_suppresses_literal_focus_report_paste() { + let now = Instant::now(); + let mut guard = PostResumeInputGuard::default(); + guard.arm(now); + + assert!(guard.should_drop(&Event::Paste("\x1b[I".to_string()), now)); + assert!(!guard.should_drop( + &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + now + )); + } + + #[cfg(unix)] + #[test] + fn post_resume_guard_suppresses_split_focus_report_keys() { + let now = Instant::now(); + let mut guard = PostResumeInputGuard::default(); + guard.arm(now); + + assert!(guard.should_drop( + &Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + now + )); + assert!(guard.should_drop( + &Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE)), + now + )); + assert!(guard.should_drop( + &Event::Key(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT)), + now + )); + } + + #[cfg(unix)] + #[test] + fn post_resume_guard_expires_before_filtering_user_input() { + let now = Instant::now(); + let mut guard = PostResumeInputGuard::default(); + guard.arm(now); + let after_guard = + now + POST_RESUME_INPUT_GUARD_DURATION + Duration::from_millis(/*millis*/ 1); + + assert!(!guard.should_drop( + &Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + after_guard + )); + } + #[tokio::test(flavor = "current_thread")] async fn key_event_skips_unmapped() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index 1669615ccc..68530d5941 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -176,14 +176,39 @@ impl PreparedResumeAction { /// Deliver SIGTSTP after restoring terminal state, then re-applies terminal modes once resumed. fn suspend_process() -> Result<()> { + tracing::trace!( + event = "tui_suspend_stage_changed", + stage = "restoring_terminal", + "restoring terminal before suspend" + ); super::restore_for_suspend()?; super::terminal_stderr::pause()?; + tracing::trace!( + event = "tui_suspend_stage_changed", + stage = "sending_sigtstp", + "sending SIGTSTP to process group" + ); unsafe { libc::kill(/*pid*/ 0, libc::SIGTSTP) }; + tracing::trace!( + event = "tui_suspend_stage_changed", + stage = "resumed", + "process resumed after SIGTSTP" + ); // After the process resumes, reapply terminal modes so drawing can continue. super::terminal_stderr::resume()?; super::set_modes()?; + tracing::trace!( + event = "tui_suspend_stage_changed", + stage = "terminal_modes_reenabled", + "re-enabled terminal modes after resume" + ); super::flush_terminal_input_buffer(); + tracing::trace!( + event = "tui_suspend_stage_changed", + stage = "terminal_input_flushed", + "flushed terminal input after resume" + ); Ok(()) }