mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
fix(tui): filter focus reports after resume
This commit is contained in:
@@ -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<S: EventSource + Default + Unpin = CrosstermEventSourc
|
||||
suspend_context: crate::tui::job_control::SuspendContext,
|
||||
#[cfg(unix)]
|
||||
alt_screen_active: Arc<AtomicBool>,
|
||||
#[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<Instant>,
|
||||
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<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
@@ -167,6 +312,8 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
suspend_context,
|
||||
#[cfg(unix)]
|
||||
alt_screen_active,
|
||||
#[cfg(unix)]
|
||||
post_resume_input_guard: PostResumeInputGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,13 +382,29 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
|
||||
/// 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<TuiEvent> {
|
||||
#[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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user