fix(tui): restore TUI after suspend (#28342)

## Why

On Linux, suspending Codex with `Ctrl+Z` and returning with `fg` can
leave the composer misaligned or inject terminal response bytes such as
focus reports into the prompt. Shell job-control output moves the cursor
while Codex is suspended, and terminal input polling can race with the
responses used to restore the inline viewport.

Fixes #26564.

## What changed

- preserve and restore keyboard reporting without disturbing the parent
terminal stack
- pause terminal event polling while Codex is suspended and flush
buffered input before resuming it
- force crossterm's cached raw-mode state back in sync after the shell
completes its `fg` handoff
- probe the actual post-`fg` cursor position with the tolerant
terminal-response parser, then realign the inline viewport before
redrawing

## How to Test

1. On Linux, start the development TUI with `just c`.
2. Type text into the composer without submitting it.
3. Press `Ctrl+Z`, run any harmless shell command, then run `fg`.
4. Confirm the composer redraws below the shell output, the draft text
is preserved, and no raw escape sequences appear.
5. Repeat the suspend/resume cycle and confirm normal typing still
works.

Targeted tests:

- `cargo test -p codex-tui --lib parses_cursor_position_as_zero_based -j
1`
- `cargo test -p codex-tui --lib tui::event_stream::tests -j 1`
This commit is contained in:
Felipe Coury authored and GitHub committed 2026-06-16 09:09:24 -07:00
1 parent 7162030b37
commit 76135cbe7e
4 files changed
+82 -25

No files matched your search

+22 -11
View File
@@ -1,16 +1,16 @@
//! Short, best-effort terminal response probes for TUI startup. //! Short, best-effort terminal response probes for TUI startup and resume.
//! //!
//! Crossterm's public helpers wait up to two seconds for terminal responses. That is too long for //! Crossterm's public helpers wait up to two seconds for terminal responses. That is too long for
//! TUI startup, where unsupported terminals should simply fall back to conservative defaults. //! TUI startup and resume, where unsupported terminals should simply fall back to conservative
//! defaults.
//! This module sends the same kinds of optional terminal queries with a caller-provided deadline, //! This module sends the same kinds of optional terminal queries with a caller-provided deadline,
//! prefers duplicated stdio handles, falls back to the controlling terminal path when stdio is //! prefers duplicated stdio handles, falls back to the controlling terminal path when stdio is
//! unavailable, and reports `None` when a response is unavailable. //! unavailable, and reports `None` when a response is unavailable.
//! //!
//! The probes run before the crossterm event stream is created, so they do not share crossterm's //! Probes run only while the crossterm event stream is absent or paused, so they do not share
//! internal skipped-event queue. Bytes read while looking for probe responses are consumed from the //! crossterm's internal skipped-event queue. Bytes read while looking for probe responses are
//! terminal; keeping the timeout short is part of the contract that makes this acceptable for //! consumed from the terminal; callers must therefore own terminal input for the duration of the
//! startup. A future input-preservation layer would need to replay unrelated bytes through the same //! short timeout and accept that unrelated buffered input will be discarded.
//! parser that normal TUI input uses.
use std::time::Duration; use std::time::Duration;
@@ -58,7 +58,7 @@ mod imp {
Skip, Skip,
} }
/// Temporary terminal handle used while a startup probe owns terminal input. /// Temporary terminal handle used while a probe owns terminal input.
/// ///
/// The preferred path is duplicated stdin/stdout, because terminal replies are delivered to the /// The preferred path is duplicated stdin/stdout, because terminal replies are delivered to the
/// same input stream crossterm reads from. Some embedded or redirected environments expose a /// same input stream crossterm reads from. Some embedded or redirected environments expose a
@@ -72,7 +72,7 @@ mod imp {
} }
impl Tty { impl Tty {
/// Opens an isolated reader and writer for startup probes. /// Opens an isolated reader and writer for terminal probes.
/// ///
/// The reader and writer must be separate file descriptions so switching the reader into /// The reader and writer must be separate file descriptions so switching the reader into
/// nonblocking mode does not also make writes fail with `WouldBlock` under terminal /// nonblocking mode does not also make writes fail with `WouldBlock` under terminal
@@ -232,6 +232,17 @@ mod imp {
Ok(Some(colors)) Ok(Some(colors))
} }
/// Queries the terminal cursor position while normal input polling is paused.
///
/// Resume can emit a focus report immediately before the cursor-position response. Reusing
/// the startup parser lets the probe find the response without leaking either sequence into
/// the composer.
pub(crate) fn cursor_position(timeout: Duration) -> io::Result<Option<Position>> {
let mut tty = Tty::open()?;
tty.write_all(b"\x1B[6n")?;
read_until(&mut tty, timeout, parse_cursor_position)
}
/// Runs the optional terminal queries needed during TUI startup under one shared deadline. /// Runs the optional terminal queries needed during TUI startup under one shared deadline.
/// ///
/// Keeping these queries batched avoids paying one timeout per unsupported capability before /// Keeping these queries batched avoids paying one timeout per unsupported capability before
@@ -255,8 +266,8 @@ mod imp {
/// Reads available terminal bytes until `parse` recognizes a probe response or time expires. /// Reads available terminal bytes until `parse` recognizes a probe response or time expires.
/// ///
/// The accumulated buffer may include unrelated terminal input. This helper intentionally does /// The accumulated buffer may include unrelated terminal input. This helper intentionally does
/// not try to replay those bytes, so it must stay limited to short startup probes that run /// not try to replay those bytes, so callers must use it only during short, exclusive probe
/// before normal crossterm input polling begins. /// windows before normal crossterm input polling begins or while that polling is paused.
fn read_until<T>( fn read_until<T>(
tty: &mut Tty, tty: &mut Tty,
timeout: Duration, timeout: Duration,
+14 -2
View File
@@ -281,6 +281,18 @@ pub fn restore() -> Result<()> {
restore_common(RawModeRestore::Disable, KeyboardRestore::PopStack) restore_common(RawModeRestore::Disable, KeyboardRestore::PopStack)
} }
/// Force crossterm's cached raw-mode state back in sync with the terminal after `fg`.
///
/// A shell may restore the job's saved termios after the process receives `SIGCONT`. When that
/// races with [`set_modes`], crossterm still believes raw mode is enabled even though the terminal
/// has returned to canonical, echoing mode. Clearing crossterm's saved state before enabling raw
/// mode again makes the kernel state authoritative once the shell has completed its handoff.
#[cfg(unix)]
pub(super) fn reapply_raw_mode_after_resume() -> Result<()> {
disable_raw_mode()?;
enable_raw_mode()
}
/// Restore the terminal after Codex is exiting. /// Restore the terminal after Codex is exiting.
/// ///
/// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a /// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a
@@ -873,7 +885,7 @@ impl Tui {
#[cfg(unix)] #[cfg(unix)]
let mut prepared_resume = self let mut prepared_resume = self
.suspend_context .suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); .prepare_resume_action(&mut self.alt_saved_viewport);
// Precompute any viewport updates that need a cursor-position query before entering // Precompute any viewport updates that need a cursor-position query before entering
// the synchronized update, to avoid racing with the event reader. // the synchronized update, to avoid racing with the event reader.
@@ -1009,7 +1021,7 @@ impl Tui {
#[cfg(unix)] #[cfg(unix)]
let mut prepared_resume = self let mut prepared_resume = self
.suspend_context .suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); .prepare_resume_action(&mut self.alt_saved_viewport);
ensure_virtual_terminal_processing()?; ensure_virtual_terminal_processing()?;
+10 -1
View File
@@ -239,7 +239,16 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
Event::Key(key_event) => { Event::Key(key_event) => {
#[cfg(unix)] #[cfg(unix)]
if crate::tui::job_control::SUSPEND_KEY.is_press(key_event) { if crate::tui::job_control::SUSPEND_KEY.is_press(key_event) {
let _ = self.suspend_context.suspend(&self.alt_screen_active); self.broker.pause_events();
let suspend_result = self.suspend_context.suspend(&self.alt_screen_active);
self.broker.resume_events();
if let Err(err) = suspend_result {
tracing::warn!(
event = "tui_suspend_failed",
error = %err,
"failed to suspend TUI process"
);
}
return Some(TuiEvent::Draw); return Some(TuiEvent::Draw);
} }
Some(TuiEvent::Key(key_event)) Some(TuiEvent::Key(key_event))
+36 -11
View File
@@ -13,7 +13,6 @@ use crossterm::event::KeyCode;
use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::LeaveAlternateScreen;
use ratatui::crossterm::execute; use ratatui::crossterm::execute;
use ratatui::layout::Position;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use crate::key_hint; use crate::key_hint;
@@ -72,7 +71,30 @@ impl SuspendContext {
} }
let y = self.suspend_cursor_y.load(Ordering::Relaxed); let y = self.suspend_cursor_y.load(Ordering::Relaxed);
let _ = execute!(stdout(), MoveTo(0, y), Show); let _ = execute!(stdout(), MoveTo(0, y), Show);
suspend_process() suspend_process()?;
super::reapply_raw_mode_after_resume()?;
// The shell writes its job-control status and the resumed command after `fg`, so the
// cursor may no longer be on the row cached before suspending. The event stream remains
// paused until this method returns, which makes it safe for the probe to consume both an
// interleaved focus report and the cursor-position response without racing the background
// input reader.
match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) {
Ok(Some(position)) => self.set_cursor_y(position.y),
Ok(None) => tracing::debug!("terminal cursor position unavailable after resume"),
Err(err) => tracing::debug!(
error = %err,
"failed to read terminal cursor position after resume"
),
}
super::flush_terminal_input_buffer();
tracing::trace!(
event = "tui_suspend_resumed",
cursor_y = self.cursor_y(),
"restored terminal state after resume"
);
Ok(())
} }
/// Consume the pending resume intent and precompute any viewport changes needed post-resume. /// Consume the pending resume intent and precompute any viewport changes needed post-resume.
@@ -81,23 +103,22 @@ impl SuspendContext {
/// resumes; returns `None` when there was no pending suspend intent. /// resumes; returns `None` when there was no pending suspend intent.
pub(crate) fn prepare_resume_action( pub(crate) fn prepare_resume_action(
&self, &self,
terminal: &mut Terminal,
alt_saved_viewport: &mut Option<Rect>, alt_saved_viewport: &mut Option<Rect>,
) -> Option<PreparedResumeAction> { ) -> Option<PreparedResumeAction> {
let action = self.take_resume_action()?; let action = self.take_resume_action()?;
match action { match action {
ResumeAction::RealignInline => { ResumeAction::RealignInline => {
let cursor_pos = terminal let viewport = Rect::new(
.get_cursor_position() /*x*/ 0,
.unwrap_or(terminal.last_known_cursor_pos); self.cursor_y(),
let viewport = Rect::new(0, cursor_pos.y, 0, 0); /*width*/ 0,
/*height*/ 0,
);
Some(PreparedResumeAction::RealignViewport(viewport)) Some(PreparedResumeAction::RealignViewport(viewport))
} }
ResumeAction::RestoreAlt => { ResumeAction::RestoreAlt => {
if let Ok(Position { y, .. }) = terminal.get_cursor_position() if let Some(saved) = alt_saved_viewport.as_mut() {
&& let Some(saved) = alt_saved_viewport.as_mut() saved.y = self.cursor_y();
{
saved.y = y;
} }
Some(PreparedResumeAction::RestoreAltScreen) Some(PreparedResumeAction::RestoreAltScreen)
} }
@@ -112,6 +133,10 @@ impl SuspendContext {
self.suspend_cursor_y.store(value, Ordering::Relaxed); self.suspend_cursor_y.store(value, Ordering::Relaxed);
} }
fn cursor_y(&self) -> u16 {
self.suspend_cursor_y.load(Ordering::Relaxed)
}
/// Record a pending resume action to apply after SIGTSTP returns control. /// Record a pending resume action to apply after SIGTSTP returns control.
fn set_resume_action(&self, value: ResumeAction) { fn set_resume_action(&self, value: ResumeAction) {
*self *self