Harden TUI startup input handling (#38641)

## Why

Terminal probes and other bootstrap work can leave keys or partial control
sequences buffered before an interactive startup screen is visible. Those
inputs must not accidentally select or confirm an action, while typeahead
intended for the composer should survive terminal initialization.

## What changed

- Replay user input consumed by Unix startup probes through Crossterm while
  filtering completed terminal color replies, and avoid consuming the Windows
  console input queue when detecting default colors.
- Drain decoded and unread input after rendering actionable startup screens,
  including onboarding trust, migration, update, provider, resume, and hooks
  prompts. Keep incomplete control sequences quarantined and fail closed when
  an input boundary cannot be resolved.
- Restore terminal modes if initialization exits early and bound terminal probe
  reads by time and byte limits.

## Testing

Add parser, boundary, onboarding, and PTY coverage for preserved typeahead,
split escape sequences, bracketed paste, delayed input, and fresh input after
an interactive screen becomes ready.

GitOrigin-RevId: 321198996cdd88fe9b43c4a762e750bff450482c
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-08-14 20:23:49 +00:00
1 parent 7e65e4f330
commit 58d2daba45
23 files changed
+939 -199

No files matched your search

+26 -4
View File
@@ -41,6 +41,10 @@ use tokio::sync::broadcast;
use tokio_stream::Stream;
pub use self::frame_requester::FrameRequester;
use self::input_boundary::TerminalInitializationGuard;
pub(crate) use self::input_boundary::discard_pending_terminal_input;
#[cfg(all(test, unix))]
use self::input_boundary::terminal_input_is_readable;
use crate::custom_terminal;
use crate::custom_terminal::Terminal as CustomTerminal;
use crate::insert_history::HistoryLineWrapPolicy;
@@ -61,10 +65,14 @@ mod event_stream;
mod frame_rate_limiter;
mod frame_requester;
mod history_tail;
mod input_boundary;
#[cfg(unix)]
mod job_control;
mod keyboard_modes;
mod screen_size;
#[cfg(all(test, unix))]
#[path = "tui_startup_tests.rs"]
mod startup_tests;
mod terminal_stderr;
#[cfg(test)]
pub(crate) mod test_support;
@@ -417,10 +425,9 @@ pub(crate) fn init() -> Result<InitializedTerminal> {
if !stdout().is_terminal() {
return Err(std::io::Error::other("stdout is not a terminal"));
}
let mut restore_guard = TerminalInitializationGuard { active: true };
set_modes()?;
flush_terminal_input_buffer();
set_panic_hook();
#[cfg(unix)]
@@ -494,11 +501,13 @@ pub(crate) fn init() -> Result<InitializedTerminal> {
let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?;
let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?;
Ok(InitializedTerminal {
let initialized_terminal = InitializedTerminal {
terminal: tui,
enhanced_keys_supported,
stderr_guard,
})
};
restore_guard.active = false;
Ok(initialized_terminal)
}
#[cfg(not(unix))]
@@ -684,6 +693,19 @@ impl Tui {
self.event_broker.resume_events();
}
/// Discard buffered typeahead before a startup screen that can confirm an action.
///
/// Startup probes can leave parsed key events in crossterm's queue, while later bootstrap
/// work can leave additional bytes in the terminal input buffer. Neither should activate an
/// update, trust, or migration prompt before the user has seen it. Pause the event stream,
/// drain all input through crossterm so incomplete bracketed paste remains safely framed.
pub(crate) fn discard_pending_input_before_interactive_screen(&mut self) -> Result<()> {
self.pause_events();
let drain_result = discard_pending_terminal_input();
self.resume_events();
drain_result
}
/// Temporarily restore terminal state to run an external interactive program `f`.
///
/// This pauses crossterm's stdin polling by dropping the underlying event stream, restores