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
2026-08-14 20:23:49 +00:00
committed by copyberry
parent 7e65e4f330
commit 58d2daba45
23 changed files with 939 additions and 199 deletions
+1 -1
View File
@@ -1052,7 +1052,7 @@
"git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81_runfiles": "{\"dependencies\":[],\"features\":{},\"strip_prefix\":\"\"}",
"git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"nucleo-matcher\",\"optional\":false},{\"default_features\":true,\"features\":[\"send_guard\",\"arc_lock\"],\"name\":\"parking_lot\",\"optional\":false},{\"name\":\"rayon\"}],\"features\":{},\"strip_prefix\":\"\"}",
"git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo-matcher": "{\"dependencies\":[{\"name\":\"memchr\"},{\"default_features\":true,\"features\":[],\"name\":\"unicode-segmentation\",\"optional\":true}],\"features\":{\"default\":[\"unicode-normalization\",\"unicode-casefold\",\"unicode-segmentation\"],\"unicode-casefold\":[],\"unicode-normalization\":[],\"unicode-segmentation\":[\"dep:unicode-segmentation\"]},\"strip_prefix\":\"matcher\"}",
"git+https://github.com/openai-oss-forks/crossterm?rev=f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5#f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"base64\",\"optional\":true,\"req\":\"0.22\"},{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false,\"req\":\"2.9\"},{\"default_features\":true,\"features\":[\"is_variant\"],\"name\":\"derive_more\",\"optional\":true,\"req\":\"2.0.0\"},{\"name\":\"document-features\",\"req\":\"0.2.11\"},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true,\"req\":\"0.3\"},{\"name\":\"parking_lot\",\"req\":\"0.12\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"1.0\"},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"0.8\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"req\":\"0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"1.0\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"req\":\"1\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"req\":\"0.3.17\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"0.2.4\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"0.9.1\",\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"events\",\"windows\",\"derive-more\"],\"derive-more\":[\"dep:derive_more\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"osc52\":[\"dep:base64\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/crossterm?rev=45fecb9508105988f42fe6ff0441783ed3717f92#45fecb9508105988f42fe6ff0441783ed3717f92_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"base64\",\"optional\":true,\"req\":\"0.22\"},{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false,\"req\":\"2.9\"},{\"default_features\":true,\"features\":[\"is_variant\"],\"name\":\"derive_more\",\"optional\":true,\"req\":\"2.0.0\"},{\"name\":\"document-features\",\"req\":\"0.2.11\"},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true,\"req\":\"0.3\"},{\"name\":\"parking_lot\",\"req\":\"0.12\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"1.0\"},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"0.8\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"req\":\"0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"1.0\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"req\":\"1\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"req\":\"0.3.17\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"0.2.4\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"0.9.1\",\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"events\",\"windows\",\"derive-more\"],\"derive-more\":[\"dep:derive_more\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"osc52\":[\"dep:base64\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}",
+1 -1
View File
@@ -5149,7 +5149,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "git+https://github.com/openai-oss-forks/crossterm?rev=f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5#f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5"
source = "git+https://github.com/openai-oss-forks/crossterm?rev=45fecb9508105988f42fe6ff0441783ed3717f92#45fecb9508105988f42fe6ff0441783ed3717f92"
dependencies = [
"bitflags 2.13.1",
"crossterm_winapi",
+1 -1
View File
@@ -585,7 +585,7 @@ opt-level = 0
[patch.crates-io]
# Uncomment to debug local changes.
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5" }
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "45fecb9508105988f42fe6ff0441783ed3717f92" }
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
+1 -1
View File
@@ -840,7 +840,7 @@ impl App {
&app_event_tx,
&available_models,
)
.await;
.await?;
if let Some(exit_info) = exit_info {
app_server
.shutdown()
+3
View File
@@ -181,6 +181,9 @@ impl App {
);
}
Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled) => {}
Ok(ExternalAgentConfigMigrationFlowOutcome::TerminalError(err)) => {
return Err(err.into());
}
Err(error_message) => {
self.chat_widget.add_error_message(error_message);
}
+10 -9
View File
@@ -272,7 +272,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
model: &str,
app_event_tx: &AppEventSender,
available_models: &[ModelPreset],
) -> Option<AppExitInfo> {
) -> std::io::Result<Option<AppExitInfo>> {
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
@@ -288,7 +288,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
}) = upgrade
{
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return None;
return Ok(None);
}
let target_model = target_model.to_string();
@@ -298,12 +298,13 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
&config.notices.model_migrations,
available_models,
) {
return None;
return Ok(None);
}
let current_preset = available_models.iter().find(|preset| preset.model == model);
let target_preset = target_preset_for_upgrade(available_models, &target_model);
let target_preset = target_preset?;
let Some(target_preset) = target_preset_for_upgrade(available_models, &target_model) else {
return Ok(None);
};
let target_display_name = target_preset.display_name.clone();
let heading_label = if target_display_name == model {
target_model.clone()
@@ -323,7 +324,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
target_description,
can_opt_out,
);
match run_model_migration_prompt(tui, prompt_copy).await {
match run_model_migration_prompt(tui, prompt_copy).await? {
ModelMigrationOutcome::Accepted => {
apply_accepted_model_migration(
config,
@@ -340,18 +341,18 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
});
}
ModelMigrationOutcome::Exit => {
return Some(AppExitInfo {
return Ok(Some(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
resume_hint: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}));
}
}
}
None
Ok(None)
}
pub(super) fn normalize_harness_overrides_for_cwd(
mut overrides: ConfigOverrides,
+1
View File
@@ -124,6 +124,7 @@ pub(crate) async fn run_cwd_selection_prompt(
frame.render_widget_ref(&screen, frame.area());
})?;
tui.discard_pending_input_before_interactive_screen()?;
let events = tui.event_stream();
tokio::pin!(events);
@@ -25,6 +25,7 @@ pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
Started(Vec<Line<'static>>),
NoItems,
Cancelled,
TerminalError(std::io::Error),
}
struct DetectedExternalAgentConfigSource {
@@ -312,10 +313,11 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
.iter()
.map(|detected| detected.source)
.collect::<Vec<_>>();
let Some(source) = run_external_agent_config_source_prompt(tui, &sources).await else {
return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled);
};
source
match run_external_agent_config_source_prompt(tui, &sources).await {
Ok(Some(source)) => source,
Ok(None) => return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled),
Err(err) => return Ok(ExternalAgentConfigMigrationFlowOutcome::TerminalError(err)),
}
};
let Some(detected_source) = detected_sources
.into_iter()
@@ -337,7 +339,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
)
.await
{
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
Ok(ExternalAgentConfigMigrationOutcome::Proceed(items)) => {
selected_items = items.clone();
match app_server
.external_agent_config_import(
@@ -367,9 +369,10 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
}
}
}
ExternalAgentConfigMigrationOutcome::Skip => {
Ok(ExternalAgentConfigMigrationOutcome::Skip) => {
return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled);
}
Err(err) => return Ok(ExternalAgentConfigMigrationFlowOutcome::TerminalError(err)),
}
}
}
@@ -84,7 +84,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
items: &[ExternalAgentConfigMigrationItem],
selected_items: &[ExternalAgentConfigMigrationItem],
error: Option<&str>,
) -> ExternalAgentConfigMigrationOutcome {
) -> std::io::Result<ExternalAgentConfigMigrationOutcome> {
let mut screen = ExternalAgentConfigMigrationScreen::new(
tui.frame_requester(),
items,
@@ -92,10 +92,14 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
error.map(str::to_owned),
);
let _ = tui.draw(u16::MAX, |frame| {
if let Err(err) = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}) {
tracing::warn!("failed to draw config migration prompt: {err}");
return Ok(ExternalAgentConfigMigrationOutcome::Skip);
}
tui.discard_pending_input_before_interactive_screen()?;
let events = tui.event_stream();
tokio::pin!(events);
@@ -117,7 +121,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
}
}
screen.outcome()
Ok(screen.outcome())
}
struct ExternalAgentConfigMigrationScreen {
@@ -47,12 +47,16 @@ impl ExternalAgentConfigMigrationSource {
pub(crate) async fn run_external_agent_config_source_prompt(
tui: &mut Tui,
sources: &[ExternalAgentConfigMigrationSource],
) -> Option<ExternalAgentConfigMigrationSource> {
) -> std::io::Result<Option<ExternalAgentConfigMigrationSource>> {
let mut screen = ExternalAgentConfigSourceScreen::new(tui.frame_requester(), sources);
let _ = tui.draw(u16::MAX, |frame| {
if let Err(err) = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}) {
tracing::warn!("failed to draw config migration source prompt: {err}");
return Ok(None);
}
tui.discard_pending_input_before_interactive_screen()?;
let events = tui.event_stream();
tokio::pin!(events);
@@ -73,7 +77,7 @@ pub(crate) async fn run_external_agent_config_source_prompt(
}
}
screen.selection()
Ok(screen.selection())
}
struct ExternalAgentConfigSourceScreen {
+5 -4
View File
@@ -137,14 +137,15 @@ pub(crate) fn migration_copy_for_models(
pub(crate) async fn run_model_migration_prompt(
tui: &mut Tui,
copy: ModelMigrationCopy,
) -> ModelMigrationOutcome {
) -> std::io::Result<ModelMigrationOutcome> {
let alt = AltScreenGuard::enter(tui);
let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy);
let _ = alt.tui.draw(u16::MAX, |frame| {
alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
})?;
alt.tui.discard_pending_input_before_interactive_screen()?;
let events = alt.tui.event_stream();
tokio::pin!(events);
@@ -166,7 +167,7 @@ pub(crate) async fn run_model_migration_prompt(
}
}
screen.outcome()
Ok(screen.outcome())
}
struct ModelMigrationScreen {
@@ -227,6 +227,14 @@ impl OnboardingScreen {
self.should_exit
}
/// Check whether the first unfinished onboarding step can grant directory trust.
fn is_trust_step_active(&self) -> bool {
self.steps
.iter()
.find(|step| matches!(step.get_step_state(), StepState::InProgress))
.is_some_and(|step| matches!(step, Step::TrustDirectory(_)))
}
fn cancel_auth_if_active(&self) {
for step in &self.steps {
if let Step::Auth(widget) = step {
@@ -490,8 +498,10 @@ pub(crate) async fn run_onboarding_app(
frame.render_widget_ref(&onboarding_screen, frame.area());
})?;
tui.discard_pending_input_before_interactive_screen()?;
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
let mut trust_step_was_active = onboarding_screen.is_trust_step_active();
while !onboarding_screen.is_done() {
tokio::select! {
@@ -570,6 +580,18 @@ pub(crate) async fn run_onboarding_app(
}
}
}
discard_pending_input_on_trust_step_transition(
&onboarding_screen,
&mut trust_step_was_active,
|onboarding_screen| {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(onboarding_screen, frame.area());
})?;
tui.discard_pending_input_before_interactive_screen()?;
Ok(())
},
)?;
}
Ok(OnboardingResult {
directory_trust_persisted,
@@ -577,6 +599,22 @@ pub(crate) async fn run_onboarding_app(
})
}
/// Render a newly activated trust step before discarding keys from the preceding screen.
///
/// Once the step is active, preserve fresh navigation and confirmation input.
fn discard_pending_input_on_trust_step_transition(
onboarding_screen: &OnboardingScreen,
trust_step_was_active: &mut bool,
render_and_discard: impl FnOnce(&OnboardingScreen) -> Result<()>,
) -> Result<()> {
let trust_step_is_active = onboarding_screen.is_trust_step_active();
if trust_step_is_active && !*trust_step_was_active {
render_and_discard(onboarding_screen)?;
}
*trust_step_was_active = trust_step_is_active;
Ok(())
}
async fn persist_selected_trust(
onboarding_screen: &mut OnboardingScreen,
request_handle: Option<AppServerRequestHandle>,
@@ -630,8 +668,10 @@ mod tests {
use super::OnboardingScreen;
use super::Step;
use super::StepStateProvider;
use super::discard_pending_input_on_trust_step_transition;
use super::persist_selected_trust;
use super::suppress_quit_while_typing_api_key;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::tui::FrameRequester;
@@ -689,6 +729,66 @@ mod tests {
assert!(!suppressed);
}
#[test]
fn buffered_enter_is_discarded_when_trust_step_becomes_active() {
let mut onboarding_screen = OnboardingScreen {
request_frame: FrameRequester::test_dummy(),
steps: vec![Step::TrustDirectory(TrustDirectoryWidget {
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
should_quit: false,
selection: None,
highlighted: TrustDirectorySelection::Trust,
error: None,
})],
is_done: false,
should_exit: false,
};
let mut buffered_keys = vec![KeyEvent::from(KeyCode::Enter)];
let mut trust_step_was_active = false;
let mut drain_count = 0;
discard_pending_input_on_trust_step_transition(
&onboarding_screen,
&mut trust_step_was_active,
|_| {
drain_count += 1;
buffered_keys.clear();
Ok(())
},
)
.expect("discard buffered trust input");
for key in buffered_keys {
onboarding_screen.handle_key_event(key);
}
assert!(onboarding_screen.is_trust_step_active());
let Step::TrustDirectory(widget) = &onboarding_screen.steps[0] else {
panic!("trust step should remain present");
};
assert_eq!(widget.selection, None);
assert_eq!(widget.highlighted, TrustDirectorySelection::Trust);
let mut navigation_keys = vec![KeyEvent::from(KeyCode::Down)];
discard_pending_input_on_trust_step_transition(
&onboarding_screen,
&mut trust_step_was_active,
|_| {
drain_count += 1;
navigation_keys.clear();
Ok(())
},
)
.expect("retain fresh trust navigation");
assert_eq!(drain_count, 1);
onboarding_screen.handle_key_event(navigation_keys.remove(0));
let Step::TrustDirectory(widget) = &onboarding_screen.steps[0] else {
panic!("trust step should remain present");
};
assert_eq!(widget.highlighted, TrustDirectorySelection::Quit);
}
#[tokio::test]
async fn trust_persistence_failure_keeps_trust_step_in_progress() {
let mut onboarding_screen = OnboardingScreen {
+16 -9
View File
@@ -359,20 +359,27 @@ pub async fn select_oss_provider() -> io::Result<OssProviderSelection> {
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = loop {
let result = (|| {
terminal.draw(|f| {
(&widget).render_ref(f.area(), f.buffer_mut());
})?;
crate::tui::discard_pending_terminal_input()?;
if let Event::Key(key_event) = event::read()?
&& let Some(selection) = widget.handle_key_event(key_event)
{
break Ok(OssProviderSelection {
provider: selection,
manually_selected: true,
});
loop {
if let Event::Key(key_event) = event::read()?
&& let Some(selection) = widget.handle_key_event(key_event)
{
break Ok(OssProviderSelection {
provider: selection,
manually_selected: true,
});
}
terminal.draw(|f| {
(&widget).render_ref(f.area(), f.buffer_mut());
})?;
}
};
})();
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
+9
View File
@@ -535,6 +535,15 @@ async fn run_session_picker_with_loader(
state.start_initial_load();
state.request_frame();
if let Ok(size) = alt.tui.terminal.size() {
let list_height = size.height.saturating_sub(PICKER_CHROME_HEIGHT) as usize;
state.update_viewport(list_height, list_viewport_width(size.width));
state.ensure_minimum_rows_for_view(list_height);
}
draw_picker(alt.tui, &state, alt.tui.terminal.last_known_screen_size)?;
if state.launch_context == SessionPickerLaunchContext::Startup {
alt.tui.discard_pending_input_before_interactive_screen()?;
}
let mut tui_events = alt.tui.event_stream().fuse();
let mut background_events = UnboundedReceiverStream::new(bg_rx).fuse();
+1
View File
@@ -101,6 +101,7 @@ async fn run_startup_hooks_review_app(
let mut chord_matcher = crate::keymap::KeyChordMatcher::default();
draw_view(tui, &view)?;
tui.discard_pending_input_before_interactive_screen()?;
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
+174 -154
View File
@@ -7,13 +7,15 @@
//! prefers duplicated stdio handles, falls back to the controlling terminal path when stdio is
//! unavailable, and reports `None` when a response is unavailable.
//!
//! Probes run only while the crossterm event stream is absent or paused, so they do not share
//! crossterm's internal skipped-event queue. Bytes read while looking for probe responses are
//! consumed from the terminal; callers must therefore own terminal input for the duration of the
//! short timeout and accept that unrelated buffered input will be discarded.
//! Probes run only while the crossterm event stream is absent or paused. Startup replays consumed
//! terminal bytes through crossterm's parser so interleaved user input remains available after the
//! probe completes.
use std::time::Duration;
#[cfg(unix)]
mod startup_replay;
/// Default wall-clock budget for each startup probe group.
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
@@ -31,6 +33,7 @@ pub(crate) struct DefaultColors {
mod imp {
use super::DefaultColors;
use super::parse_default_colors;
use super::startup_replay::startup_replay_input;
use std::fs::File;
use std::fs::OpenOptions;
use std::io;
@@ -43,6 +46,11 @@ mod imp {
use crossterm::event::KeyboardEnhancementFlags;
use ratatui::layout::Position;
/// Leave larger pastes in the terminal queue for the normal event reader.
const MAX_TERMINAL_PROBE_BYTES: usize = 1024;
const MAX_STARTUP_PROBE_BYTES: usize = 64 * 1024;
const MAX_TERMINAL_CSI_BYTES: usize = 64;
/// Results from the TUI's one-shot startup terminal probe.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) struct StartupProbe {
@@ -140,14 +148,24 @@ mod imp {
self.writer.flush()
}
fn read_available(&mut self, buffer: &mut Vec<u8>) -> io::Result<()> {
fn read_available(
&mut self,
buffer: &mut Vec<u8>,
deadline: Instant,
max_bytes: usize,
) -> io::Result<()> {
let mut chunk = [0_u8; 256];
loop {
if buffer.len() >= max_bytes || Instant::now() >= deadline {
return Ok(());
}
let bytes_to_read = chunk.len().min(max_bytes.saturating_sub(buffer.len()));
let count = unsafe {
libc::read(
self.reader.as_raw_fd(),
chunk.as_mut_ptr().cast::<libc::c_void>(),
chunk.len(),
bytes_to_read,
)
};
if count > 0 {
@@ -245,8 +263,8 @@ mod imp {
/// 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
/// the first frame can render.
/// Bytes read while waiting for terminal responses are replayed through crossterm's event
/// parser so keys, bracketed paste, and incomplete sequences survive the startup probe.
pub(crate) fn startup(
timeout: Duration,
keyboard_probe: StartupKeyboardEnhancementProbe,
@@ -260,7 +278,11 @@ mod imp {
tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?;
}
}
read_startup_probe(&mut tty, timeout, keyboard_probe)
let mut buffer = Vec::new();
let result = read_startup_probe(&mut tty, timeout, keyboard_probe, &mut buffer);
crossterm::event::buffer_input(&startup_replay_input(&buffer))?;
result
}
/// Reads available terminal bytes until `parse` recognizes a probe response or time expires.
@@ -276,12 +298,12 @@ mod imp {
let deadline = Instant::now() + timeout;
let mut buffer = Vec::new();
loop {
tty.read_available(&mut buffer)?;
tty.read_available(&mut buffer, deadline, MAX_TERMINAL_PROBE_BYTES)?;
if let Some(value) = parse(&buffer) {
return Ok(Some(value));
}
let now = Instant::now();
if now >= deadline {
if now >= deadline || buffer.len() >= MAX_TERMINAL_PROBE_BYTES {
return Ok(None);
}
if !tty.poll_readable(deadline.saturating_duration_since(now))? {
@@ -294,9 +316,9 @@ mod imp {
tty: &mut Tty,
timeout: Duration,
keyboard_probe: StartupKeyboardEnhancementProbe,
buffer: &mut Vec<u8>,
) -> io::Result<StartupProbe> {
let deadline = Instant::now() + timeout;
let mut buffer = Vec::new();
let mut probe = StartupProbe {
cursor_position: None,
default_colors: None,
@@ -304,18 +326,18 @@ mod imp {
};
let mut saw_supported_keyboard = false;
loop {
tty.read_available(&mut buffer)?;
tty.read_available(buffer, deadline, MAX_STARTUP_PROBE_BYTES)?;
update_startup_probe(
&mut probe,
&mut saw_supported_keyboard,
&buffer,
buffer,
keyboard_probe,
);
if startup_probe_complete(&probe, keyboard_probe) {
return Ok(probe);
}
let now = Instant::now();
if now >= deadline {
if now >= deadline || buffer.len() >= MAX_STARTUP_PROBE_BYTES {
finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard);
return Ok(probe);
}
@@ -382,7 +404,11 @@ mod imp {
fn parse_cursor_position(buffer: &[u8]) -> Option<Position> {
for start in find_all_subslices(buffer, b"\x1B[") {
let rest = &buffer[start + 2..];
let Some(end) = rest.iter().position(|b| *b == b'R') else {
let Some(end) = rest
.iter()
.take(MAX_TERMINAL_CSI_BYTES)
.position(|b| *b == b'R')
else {
continue;
};
let Ok(payload) = std::str::from_utf8(&rest[..end]) else {
@@ -435,7 +461,11 @@ mod imp {
fn find_keyboard_flags(buffer: &[u8]) -> Option<KeyboardEnhancementFlags> {
for start in find_all_subslices(buffer, b"\x1B[?") {
let rest = &buffer[start + 3..];
let Some(end) = rest.iter().position(|b| *b == b'u') else {
let Some(end) = rest
.iter()
.take(MAX_TERMINAL_CSI_BYTES)
.position(|b| *b == b'u')
else {
continue;
};
if end == 0 {
@@ -468,7 +498,11 @@ mod imp {
fn find_primary_device_attributes(buffer: &[u8]) -> Option<()> {
for start in find_all_subslices(buffer, b"\x1B[?") {
let rest = &buffer[start + 3..];
let Some(end) = rest.iter().position(|b| *b == b'c') else {
let Some(end) = rest
.iter()
.take(MAX_TERMINAL_CSI_BYTES)
.position(|b| *b == b'c')
else {
continue;
};
if end > 0 && rest[..end].iter().all(|b| b.is_ascii_digit() || *b == b';') {
@@ -492,6 +526,66 @@ mod imp {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
fn tty_with_buffered_input(input: &[u8]) -> Tty {
let (reader, mut writer) = UnixStream::pair().expect("create terminal input pair");
writer.write_all(input).expect("buffer terminal input");
let reader: OwnedFd = reader.into();
let writer: OwnedFd = writer.into();
Tty::new(reader.into(), writer.into()).expect("create terminal probe")
}
#[test]
fn terminal_probe_stops_reading_when_its_deadline_expires() {
let mut tty = tty_with_buffered_input(b"still queued");
let mut buffer = Vec::new();
tty.read_available(&mut buffer, Instant::now(), MAX_TERMINAL_PROBE_BYTES)
.expect("respect terminal probe deadline");
assert_eq!(buffer, Vec::<u8>::new());
let mut queued = [0; 12];
let count = unsafe {
libc::read(
tty.reader.as_raw_fd(),
queued.as_mut_ptr().cast::<libc::c_void>(),
queued.len(),
)
};
assert_eq!(count, queued.len() as isize);
assert_eq!(&queued, b"still queued");
}
#[test]
fn terminal_probe_leaves_input_queued_after_reaching_its_byte_budget() {
for max_bytes in [MAX_TERMINAL_PROBE_BYTES, MAX_STARTUP_PROBE_BYTES] {
let input = [b'x'; 256];
let mut tty = tty_with_buffered_input(&input);
let mut buffer = vec![b'x'; max_bytes - 32];
tty.read_available(
&mut buffer,
Instant::now() + Duration::from_secs(/*secs*/ 1),
max_bytes,
)
.expect("respect terminal probe byte budget");
assert_eq!(buffer.len(), max_bytes);
let mut queued = [0; 224];
let count = unsafe {
libc::read(
tty.reader.as_raw_fd(),
queued.as_mut_ptr().cast::<libc::c_void>(),
queued.len(),
)
};
assert_eq!(count, queued.len() as isize);
assert_eq!(queued, [b'x'; 224]);
}
}
#[test]
fn parses_cursor_position_as_zero_based() {
@@ -560,62 +654,84 @@ mod imp {
StartupKeyboardEnhancementProbe::Query
));
}
#[test]
fn startup_probe_finds_responses_after_large_typeahead() {
let mut input = vec![b'x'; 2_048];
input.extend_from_slice(
b"\x1B[20;10R\x1B]11;rgb:1111/1111/1111\x07\x1B[?64;1;2c\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[?7u",
);
let mut tty = tty_with_buffered_input(&input);
let mut buffer = Vec::new();
let probe = read_startup_probe(
&mut tty,
Duration::from_secs(/*secs*/ 1),
StartupKeyboardEnhancementProbe::Query,
&mut buffer,
)
.expect("read terminal responses after buffered typeahead");
assert_eq!(
probe,
StartupProbe {
cursor_position: Some(Position { x: 9, y: 19 }),
default_colors: Some(DefaultColors {
fg: (238, 238, 238),
bg: (17, 17, 17),
}),
keyboard_enhancement_supported: Some(true),
}
);
let mut expected_replay = vec![b'x'; 2_048];
expected_replay.extend_from_slice(b"\x1B[20;10R\x1B[?64;1;2c\x1B[?7u");
assert_eq!(startup_replay_input(&buffer), expected_replay);
}
#[test]
fn startup_probe_finds_valid_responses_after_many_incomplete_sequences() {
let mut input = Vec::new();
for _ in 0..2_048 {
input.extend_from_slice(b"\x1b[?");
}
input.extend_from_slice(b"\x1b[20;10R\x1b[?64;1;2c\x1b[?7u");
assert_eq!(
parse_cursor_position(&input),
Some(Position { x: 9, y: 19 })
);
assert_eq!(
parse_keyboard_enhancement_support(&input),
KeyboardProbeState::SupportedAndFallback
);
}
}
}
#[cfg(windows)]
mod imp {
use super::DefaultColors;
use super::parse_default_colors;
use std::io;
use std::io::ErrorKind;
use std::time::Duration;
use std::time::Instant;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
use windows_sys::Win32::Storage::FileSystem::ReadFile;
use windows_sys::Win32::Storage::FileSystem::WriteFile;
use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX;
use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT;
use windows_sys::Win32::System::Console::GetConsoleMode;
use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx;
use windows_sys::Win32::System::Console::GetStdHandle;
use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE;
use windows_sys::Win32::System::Console::SetConsoleMode;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
/// Queries OSC 10 and OSC 11 default colors under one shared deadline.
/// Reads default colors from the console screen buffer without consuming terminal input.
///
/// The Windows path uses raw console handles because crossterm's public color query helper is
/// currently Unix-only. Failures and missing responses are reported as `Ok(None)` by callers so
/// terminals without OSC 10/11 support keep the existing conservative palette fallback.
pub(crate) fn default_colors(timeout: Duration) -> io::Result<Option<DefaultColors>> {
/// OSC replies share the console input queue with user keystrokes on Windows. Reading them
/// directly during startup can discard composer typeahead, so use the non-reading console
/// color fallback instead.
pub(crate) fn default_colors(_timeout: Duration) -> io::Result<Option<DefaultColors>> {
let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else {
return Ok(None);
};
if let Ok(input) = std_handle(STD_INPUT_HANDLE)
&& let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout)
{
return Ok(Some(colors));
}
Ok(query_console_default_colors(output).ok().flatten())
}
fn query_osc_default_colors(
input: HANDLE,
output: HANDLE,
timeout: Duration,
) -> io::Result<Option<DefaultColors>> {
let _vt_input = VirtualTerminalInputMode::enable(input)?;
write_all(output, b"\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?;
read_until(input, timeout, parse_default_colors)
}
fn query_console_default_colors(output: HANDLE) -> io::Result<Option<DefaultColors>> {
let mut info = unsafe { std::mem::zeroed::<CONSOLE_SCREEN_BUFFER_INFOEX>() };
info.cbSize = std::mem::size_of::<CONSOLE_SCREEN_BUFFER_INFOEX>() as u32;
@@ -655,108 +771,6 @@ mod imp {
Ok(handle)
}
struct VirtualTerminalInputMode {
handle: HANDLE,
original_mode: u32,
}
impl VirtualTerminalInputMode {
fn enable(handle: HANDLE) -> io::Result<Self> {
let mut original_mode = 0;
if unsafe { GetConsoleMode(handle, &mut original_mode) } == 0 {
return Err(io::Error::last_os_error());
}
let requested_mode = original_mode | ENABLE_VIRTUAL_TERMINAL_INPUT;
if unsafe { SetConsoleMode(handle, requested_mode) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(Self {
handle,
original_mode,
})
}
}
impl Drop for VirtualTerminalInputMode {
fn drop(&mut self) {
unsafe {
SetConsoleMode(self.handle, self.original_mode);
}
}
}
fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> {
while !bytes.is_empty() {
let mut written = 0;
let ok = unsafe {
WriteFile(
handle,
bytes.as_ptr().cast(),
bytes.len().min(u32::MAX as usize) as u32,
&mut written,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
if written == 0 {
return Err(io::Error::from(ErrorKind::WriteZero));
}
bytes = &bytes[written as usize..];
}
Ok(())
}
fn read_until<T>(
handle: HANDLE,
timeout: Duration,
mut parse: impl FnMut(&[u8]) -> Option<T>,
) -> io::Result<Option<T>> {
let deadline = Instant::now() + timeout;
let mut buffer = Vec::new();
loop {
if let Some(value) = parse(&buffer) {
return Ok(Some(value));
}
let now = Instant::now();
if now >= deadline {
return Ok(None);
}
let timeout_ms = deadline
.saturating_duration_since(now)
.as_millis()
.min(u32::MAX as u128) as u32;
match unsafe { WaitForSingleObject(handle, timeout_ms) } {
WAIT_OBJECT_0 => read_once(handle, &mut buffer)?,
WAIT_TIMEOUT => return Ok(None),
_ => return Err(io::Error::last_os_error()),
}
}
}
fn read_once(handle: HANDLE, buffer: &mut Vec<u8>) -> io::Result<()> {
let mut chunk = [0_u8; 256];
let mut read = 0;
let ok = unsafe {
ReadFile(
handle,
chunk.as_mut_ptr().cast(),
chunk.len() as u32,
&mut read,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
buffer.extend_from_slice(&chunk[..read as usize]);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -824,6 +838,7 @@ mod imp {
}
}
#[cfg(any(unix, test))]
fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> {
let prefix = format!("\x1B]{slot};");
let start = find_subslice(buffer, prefix.as_bytes())?;
@@ -834,12 +849,14 @@ fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> {
parse_osc_rgb(payload)
}
#[cfg(any(unix, test))]
fn parse_default_colors(buffer: &[u8]) -> Option<DefaultColors> {
let fg = parse_osc_color(buffer, /*slot*/ 10)?;
let bg = parse_osc_color(buffer, /*slot*/ 11)?;
Some(DefaultColors { fg, bg })
}
#[cfg(any(unix, test))]
fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> {
let mut idx = 0;
while idx < buffer.len() {
@@ -852,6 +869,7 @@ fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> {
None
}
#[cfg(any(unix, test))]
fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> {
let (prefix, values) = payload.trim().split_once(':')?;
if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") {
@@ -868,6 +886,7 @@ fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> {
parts.next().is_none().then_some((r, g, b))
}
#[cfg(any(unix, test))]
fn parse_osc_component(component: &str) -> Option<u8> {
match component.len() {
2 => u8::from_str_radix(component, 16).ok(),
@@ -878,6 +897,7 @@ fn parse_osc_component(component: &str) -> Option<u8> {
}
}
#[cfg(any(unix, test))]
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
@@ -0,0 +1,66 @@
//! Preserve startup terminal input while filtering completed terminal-color replies.
const MAX_REPLAYED_OSC_BYTES: usize = 1024;
/// Preserve input and bracketed paste while omitting completed terminal-color replies.
///
/// Keep bounded incomplete OSC prefixes so unread continuations stay parser-framed.
pub(super) fn startup_replay_input(input: &[u8]) -> Vec<u8> {
const PASTE_START: &[u8] = b"\x1b[200~";
const PASTE_END: &[u8] = b"\x1b[201~";
let mut replay = Vec::with_capacity(input.len());
let mut cursor = 0;
let mut inside_paste = false;
while cursor < input.len() {
if inside_paste {
if input[cursor..].starts_with(PASTE_END) {
replay.extend_from_slice(PASTE_END);
cursor += PASTE_END.len();
inside_paste = false;
} else if let Some((slot, _)) = [(10, b"\x1b]10;"), (11, b"\x1b]11;")]
.into_iter()
.find(|(_, prefix)| input[cursor..].starts_with(*prefix))
&& let Some((end, terminator_len)) = super::osc_payload_end(
&input[cursor + 2..input.len().min(cursor + MAX_REPLAYED_OSC_BYTES)],
)
&& let response = &input[cursor..cursor + 2 + end + terminator_len]
&& !response
.windows(PASTE_END.len())
.any(|window| window == PASTE_END)
&& super::parse_osc_color(response, slot).is_some()
{
cursor += 2 + end + terminator_len;
} else {
replay.push(input[cursor]);
cursor += 1;
}
continue;
}
if input[cursor..].starts_with(PASTE_START) {
replay.extend_from_slice(PASTE_START);
cursor += PASTE_START.len();
inside_paste = true;
continue;
}
if input[cursor..].starts_with(b"\x1b]") {
if let Some((end, terminator_len)) = super::osc_payload_end(&input[cursor + 2..]) {
cursor += 2 + end + terminator_len;
continue;
}
let end = input.len().min(cursor + MAX_REPLAYED_OSC_BYTES);
replay.extend_from_slice(&input[cursor..end]);
break;
}
replay.push(input[cursor]);
cursor += 1;
}
replay
}
#[cfg(test)]
#[path = "startup_replay_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
use super::MAX_REPLAYED_OSC_BYTES;
use super::startup_replay_input;
use pretty_assertions::assert_eq;
#[test]
fn startup_replay_bounds_oversized_osc_without_exposing_its_payload() {
let mut input = b"typed\x1b]10;".to_vec();
input.extend(std::iter::repeat_n(b'x', MAX_REPLAYED_OSC_BYTES));
input.extend_from_slice(b"1\r");
assert_eq!(
startup_replay_input(&input),
input[..b"typed".len() + MAX_REPLAYED_OSC_BYTES]
);
input.push(b'\x07');
input.extend_from_slice(b"suffix");
assert_eq!(startup_replay_input(&input), b"typedsuffix");
assert_eq!(
startup_replay_input(b"typed\x1b]10;partial\x1b"),
b"typed\x1b]10;partial\x1b"
);
}
#[test]
fn startup_replay_preserves_osc_looking_bracketed_paste() {
let mut input = b"prefix\x1b[200~\x1b]10;".to_vec();
input.extend(std::iter::repeat_n(b'x', 2_048));
input.extend_from_slice(b"\x1b[201~suffix");
assert_eq!(startup_replay_input(&input), input);
}
#[test]
fn startup_replay_omits_terminal_color_replies_interleaved_with_bracketed_paste() {
let input = b"prefix\x1b[200~hello\x1b]10;rgb:eeee/eeee/eeee\x07 \
\x1b]11;rgb:1111/1111/1111\x1b\\world\x1b[201~suffix";
assert_eq!(
startup_replay_input(input),
b"prefix\x1b[200~hello world\x1b[201~suffix"
);
let unfinished_paste = b"\x1b[200~hello\x1b]10;rgb:eeee/eeee/eeee\x07 world";
assert_eq!(
startup_replay_input(unfinished_paste),
b"\x1b[200~hello world"
);
let unrelated_osc = b"\x1b[200~\x1b]52;preserve me\x07\x1b[201~";
assert_eq!(startup_replay_input(unrelated_osc), unrelated_osc);
let response_after_paste = b"\x1b[200~\x1b]10;unfinished\x1b[201~suffix\x07";
assert_eq!(
startup_replay_input(response_after_paste),
response_after_paste
);
}
+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
+117
View File
@@ -0,0 +1,117 @@
//! Restore terminal ownership on initialization failures and quarantine protected-screen input.
use std::io;
#[cfg(unix)]
use std::io::IsTerminal;
use std::io::Result;
#[cfg(unix)]
use std::io::stdin;
use std::time::Duration;
use std::time::Instant;
/// Restores terminal modes when initialization fails before ownership is transferred.
pub(super) struct TerminalInitializationGuard {
pub(super) active: bool,
}
impl Drop for TerminalInitializationGuard {
fn drop(&mut self) {
if self.active {
let _ = super::restore_after_exit();
}
}
}
/// Drain decoded and unread input before a screen can accept a security-sensitive decision.
///
/// Partial control sequences remain quarantined; an unresolved boundary fails after one second.
pub(crate) fn discard_pending_terminal_input() -> Result<()> {
#[cfg(unix)]
let _ = crossterm::event::discard_buffered_input()?;
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 1);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out discarding buffered terminal input",
));
}
if crossterm::event::poll(remaining.min(Duration::from_millis(/*millis*/ 10)))? {
let _ = crossterm::event::read()?;
continue;
}
#[cfg(unix)]
if crossterm::event::discard_buffered_input()?
!= crossterm::event::InputDiscardStatus::Complete
{
continue;
}
#[cfg(any(unix, windows))]
match terminal_input_is_readable() {
Ok(true) => continue,
Ok(false) => {}
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
}
return Ok(());
}
}
/// Check Crossterm's terminal input source without consuming input or changing descriptor flags.
#[cfg(unix)]
pub(super) fn terminal_input_is_readable() -> Result<bool> {
use std::os::fd::AsRawFd;
let terminal = if stdin().is_terminal() {
None
} else {
Some(std::fs::File::open("/dev/tty")?)
};
let mut fd = libc::pollfd {
fd: terminal
.as_ref()
.map_or(libc::STDIN_FILENO, AsRawFd::as_raw_fd),
events: libc::POLLIN,
revents: 0,
};
// Safety: `fd` is valid for the duration of this zero-timeout poll.
let result = unsafe {
libc::poll(&mut fd, /*nfds*/ 1, /*timeout*/ 0)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
if fd.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 {
return Err(io::Error::other(format!(
"terminal input poll failed with events {:#x}",
fd.revents
)));
}
Ok(result > 0 && fd.revents & libc::POLLIN != 0)
}
/// Check the same Windows console input queue consumed by Crossterm.
#[cfg(windows)]
fn terminal_input_is_readable() -> Result<bool> {
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Console::GetNumberOfConsoleInputEvents;
use windows_sys::Win32::System::Console::GetStdHandle;
use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
if handle == INVALID_HANDLE_VALUE || handle == 0 {
return Err(io::Error::other("terminal input handle is unavailable"));
}
let mut pending = 0;
if unsafe { GetNumberOfConsoleInputEvents(handle, &mut pending) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(pending != 0)
}
-1
View File
@@ -247,7 +247,6 @@ mod tests {
write_stderr("hidden after resume\n")?;
finish()?;
write_stderr("visible after finish\n")?;
drop(capture);
output.rewind()?;
let mut captured = String::new();
+323
View File
@@ -0,0 +1,323 @@
use std::fs::File;
use std::io::Read as _;
use std::io::Write as _;
use std::os::fd::FromRawFd;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use super::InitializedTerminal;
use super::Tui;
const STARTUP_INPUT_CHILD_TEST: &str = "tui::startup_tests::startup_typeahead_pty_child";
#[test]
fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw() {
let mut master = -1;
let mut slave = -1;
let mut size = libc::winsize {
ws_row: 24,
ws_col: 80,
ws_xpixel: 0,
ws_ypixel: 0,
};
// Safety: `openpty` initializes the two file descriptors and reads the supplied size.
assert_eq!(
unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::addr_of_mut!(size),
)
},
0,
"failed to create a test pty: {}",
std::io::Error::last_os_error()
);
// Safety: `openpty` returned owned descriptors on success.
let mut master = unsafe { File::from_raw_fd(master) };
// Safety: `openpty` returned owned descriptors on success.
let slave = unsafe { File::from_raw_fd(slave) };
// Queue canonical-mode typeahead before raw mode. Reinserting the old startup `tcflush`
// drops this input before the bounded probe can preserve it.
master
.write_all(b"helo\x7Flo\x04")
.expect("queue typeahead");
let signals = tempfile::tempdir().expect("create pty synchronization directory");
let action_ready = signals.path().join("action-ready");
let action_queued = signals.path().join("action-queued");
let fresh_ready = signals.path().join("fresh-ready");
let fresh_queued = signals.path().join("fresh-queued");
let boundary_ready = signals.path().join("boundary-ready");
let boundary_queued = signals.path().join("boundary-queued");
let boundary_consumed = signals.path().join("boundary-consumed");
let boundary_action_queued = signals.path().join("boundary-action-queued");
let control_ready = signals.path().join("control-ready");
let control_queued = signals.path().join("control-queued");
let mut child = Command::new(std::env::current_exe().expect("current test binary"))
.arg("--exact")
.arg(STARTUP_INPUT_CHILD_TEST)
.arg("--ignored")
.arg("--nocapture")
.env("CODEX_TUI_STARTUP_INPUT_PTY_DIR", signals.path())
.stdin(Stdio::from(slave.try_clone().expect("clone pty slave")))
.stdout(Stdio::from(slave))
.stderr(Stdio::piped())
.spawn()
.expect("spawn startup input pty child");
let result = (|| -> Result<(), String> {
for suffix in ["y", "1"] {
let ready = signals.path().join(format!("alt-{suffix}-ready"));
wait_for_file(&ready)?;
std::thread::sleep(Duration::from_millis(/*millis*/ 60));
master
.write_all(suffix.as_bytes())
.map_err(|err| format!("failed to queue delayed modifier suffix: {err}"))?;
std::fs::write(signals.path().join(format!("alt-{suffix}-queued")), [])
.map_err(|err| format!("failed to signal delayed modifier suffix: {err}"))?;
}
wait_for_file(&action_ready)?;
master
.write_all(b"y")
.map_err(|err| format!("failed to queue buffered action key: {err}"))?;
std::fs::write(&action_queued, [])
.map_err(|err| format!("failed to signal buffered action key: {err}"))?;
std::thread::sleep(Duration::from_millis(/*millis*/ 30));
master
.write_all(b"1y\r\x1b[201~1\r")
.map_err(|err| format!("failed to queue delayed bracketed-paste suffix: {err}"))?;
wait_for_file(&fresh_ready)?;
master
.write_all(b"n")
.map_err(|err| format!("failed to queue fresh key: {err}"))?;
std::fs::write(&fresh_queued, [])
.map_err(|err| format!("failed to signal fresh key: {err}"))?;
wait_for_file(&boundary_ready)?;
master
.write_all(b"\x1b[201~\x1b[12;3R")
.map_err(|err| format!("failed to queue completed paste boundary: {err}"))?;
std::fs::write(&boundary_queued, [])
.map_err(|err| format!("failed to signal queued paste boundary: {err}"))?;
wait_for_file(&boundary_consumed)?;
master
.write_all(b"1\r")
.map_err(|err| format!("failed to queue unread action after paste boundary: {err}"))?;
std::fs::write(&boundary_action_queued, [])
.map_err(|err| format!("failed to signal queued unread action: {err}"))?;
wait_for_file(&control_ready)?;
master
.write_all(b"1y\r\x1b")
.map_err(|err| format!("failed to queue delayed control-sequence payload: {err}"))?;
std::fs::write(&control_queued, [])
.map_err(|err| format!("failed to signal queued control-sequence payload: {err}"))?;
std::thread::sleep(Duration::from_millis(/*millis*/ 30));
master
.write_all(b"\\")
.map_err(|err| format!("failed to terminate the delayed control sequence: {err}"))?;
let started_at = Instant::now();
while child
.try_wait()
.map_err(|err| format!("failed to poll startup input pty child: {err}"))?
.is_none()
{
if started_at.elapsed() >= Duration::from_secs(/*secs*/ 5) {
return Err("timed out waiting for startup input pty child".to_string());
}
std::thread::sleep(Duration::from_millis(/*millis*/ 10));
}
Ok(())
})();
if result.is_err() {
let _ = child.kill();
}
let status = child.wait().expect("wait for startup input pty child");
let mut stderr = String::new();
child
.stderr
.take()
.expect("child stderr")
.read_to_string(&mut stderr)
.expect("read child stderr");
assert!(
result.is_ok() && status.success(),
"startup input pty child failed: {}\n{stderr}",
result
.err()
.unwrap_or_else(|| "child exited unsuccessfully".to_string())
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore]
async fn startup_typeahead_pty_child() {
let Some(signals) = std::env::var_os("CODEX_TUI_STARTUP_INPUT_PTY_DIR") else {
return;
};
let signals = PathBuf::from(signals);
let InitializedTerminal {
terminal,
enhanced_keys_supported,
stderr_guard,
} = super::init().expect("initialize terminal");
let mut typeahead = Vec::new();
for _ in 0..5 {
assert!(
crossterm::event::poll(Duration::from_secs(/*secs*/ 1))
.expect("poll startup typeahead"),
"startup typeahead was lost"
);
match crossterm::event::read().expect("read startup typeahead") {
Event::Key(key) => typeahead.push(key.code),
event => panic!("unexpected startup event: {event:?}"),
}
}
assert_eq!(
typeahead,
vec![
KeyCode::Char('h'),
KeyCode::Char('e'),
KeyCode::Char('l'),
KeyCode::Char('l'),
KeyCode::Char('o'),
]
);
if crossterm::event::poll(Duration::ZERO).expect("poll the canonical-mode EOF") {
// Canonical EOF does not have the same decoded representation on every Unix platform.
crossterm::event::read().expect("read the canonical-mode EOF");
}
crossterm::event::buffer_input(b"\x1b").expect("buffer an incomplete escape sequence");
crossterm::event::buffer_input(b"[A").expect("complete the buffered escape sequence");
assert!(crossterm::event::poll(Duration::from_secs(/*secs*/ 1)).expect("poll split arrow"));
assert!(matches!(
crossterm::event::read().expect("read split arrow"),
Event::Key(key) if key.code == KeyCode::Up
));
let mut tui = Tui::new(terminal, enhanced_keys_supported, stderr_guard);
tui.draw(u16::MAX, |_| {}).expect("draw actionable screen");
crossterm::event::buffer_input(b"\x1b").expect("buffer a standalone escape");
let escape_started_at = Instant::now();
assert!(
crossterm::event::poll(Duration::from_millis(/*millis*/ 250))
.expect("poll the standalone escape")
);
assert!(matches!(
crossterm::event::read().expect("read the standalone escape"),
Event::Key(key) if key.code == KeyCode::Esc
));
assert!(escape_started_at.elapsed() < Duration::from_millis(/*millis*/ 250));
for suffix in ["y", "1"] {
crossterm::event::buffer_input(b"\x1b")
.expect("buffer an incomplete modifier before the security screen");
std::fs::write(signals.join(format!("alt-{suffix}-ready")), [])
.expect("signal incomplete modifier");
tui.discard_pending_input_before_interactive_screen()
.expect("discard the delayed modifier suffix");
wait_for_file(&signals.join(format!("alt-{suffix}-queued")))
.expect("wait for the delayed modifier suffix");
assert!(
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
.expect("poll discarded modifier suffix"),
"delayed modifier suffix became an unmodified action key"
);
}
crossterm::event::buffer_input(b"y\x1b[200~unfinished")
.expect("buffer action key and incomplete bracketed paste before the security screen");
std::fs::write(signals.join("action-ready"), []).expect("signal actionable screen");
wait_for_file(&signals.join("action-queued")).expect("wait for the buffered action key");
tui.discard_pending_input_before_interactive_screen()
.expect("discard buffered action key");
assert!(
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
.expect("poll discarded action key"),
"buffered action key was delivered after the first draw"
);
std::fs::write(signals.join("fresh-ready"), []).expect("signal fresh input readiness");
wait_for_file(&signals.join("fresh-queued")).expect("wait for the fresh input");
assert!(crossterm::event::poll(Duration::from_secs(/*secs*/ 1)).expect("poll fresh key"));
assert!(matches!(
crossterm::event::read().expect("read fresh key"),
Event::Key(key) if key.code == KeyCode::Char('n')
));
crossterm::event::buffer_input(b"\x1b[200~unfinished")
.expect("buffer bracketed paste before an unread action boundary");
assert_eq!(
crossterm::event::discard_buffered_input().expect("quarantine unfinished paste"),
crossterm::event::InputDiscardStatus::BracketedPasteInProgress
);
std::fs::write(signals.join("boundary-ready"), []).expect("signal paste-boundary readiness");
wait_for_file(&signals.join("boundary-queued")).expect("wait for the paste boundary");
assert!(
!crossterm::event::poll(Duration::ZERO).expect("poll a completed paste with unread input")
);
assert_eq!(
crossterm::event::discard_buffered_input().expect("discard completed paste boundary"),
crossterm::event::InputDiscardStatus::Complete
);
std::fs::write(signals.join("boundary-consumed"), []).expect("signal completed paste boundary");
wait_for_file(&signals.join("boundary-action-queued")).expect("wait for the unread action");
assert!(super::terminal_input_is_readable().expect("check unread input after an elapsed poll"));
tui.discard_pending_input_before_interactive_screen()
.expect("discard actionable unread input after the paste boundary");
assert!(
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
.expect("poll discarded unread action"),
"actionable input queued after the paste boundary survived the security drain"
);
crossterm::event::buffer_input(b"\x1b]10;unfinished")
.expect("buffer an unfinished control sequence before the security screen");
assert_eq!(
crossterm::event::discard_buffered_input().expect("quarantine unfinished control sequence"),
crossterm::event::InputDiscardStatus::ControlSequenceInProgress
);
std::fs::write(signals.join("control-ready"), []).expect("signal control-sequence readiness");
wait_for_file(&signals.join("control-queued")).expect("wait for the control sequence");
tui.discard_pending_input_before_interactive_screen()
.expect("discard delayed control-sequence payload");
assert!(
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
.expect("poll discarded control-sequence payload"),
"control-sequence payload survived the security drain"
);
crossterm::event::buffer_input(b"\x1b")
.expect("buffer an ambiguous escape before the security screen");
let err = tui
.discard_pending_input_before_interactive_screen()
.expect_err("an unresolved escape at a protected boundary must fail closed");
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
super::restore_after_exit().expect("restore terminal");
}
fn wait_for_file(path: &Path) -> Result<(), String> {
let started_at = Instant::now();
while !path.exists() {
if started_at.elapsed() >= Duration::from_secs(/*secs*/ 5) {
return Err(format!("timed out waiting for {}", path.display()));
}
std::thread::sleep(Duration::from_millis(/*millis*/ 10));
}
Ok(())
}
+1
View File
@@ -50,6 +50,7 @@ pub(crate) async fn run_update_prompt_if_needed(
frame.render_widget_ref(&screen, frame.area());
})?;
tui.discard_pending_input_before_interactive_screen()?;
let events = tui.event_stream();
tokio::pin!(events);