From cca440697f2e6aebf671f71fbc43c0d6d0dc2628 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 30 Sep 2026 22:56:10 +0000 Subject: [PATCH] Flush expired paste bursts before handling Enter (#49810) ## Why Without a UI flush tick, expired paste detection can cause Enter to be treated as part of a paste instead of submitting buffered input. ## What changed Flush due characters and paste buffers before classifying Enter during submission, including in Vim insert mode. Preserve the buffered text when submission is blocked for a parent-owned thread. ## Testing Add regression tests for held characters and buffered bursts with and without Vim mode and parent-owned threads, plus a snapshot test for Enter submitting expired input in Vim insert mode. GitOrigin-RevId: 1d0d309b57e9d2151e0d9c1774a3401d05413d3c --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 33 +++---- .../bottom_pane/chat_composer/paste_input.rs | 1 + .../bottom_pane/chat_composer_enter_tests.rs | 87 +++++++++++++++++++ ..._tests__vim_enter_after_expired_paste.snap | 13 +++ 4 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer_enter_tests.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_enter_after_expired_paste.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index fdb7de8b2b..8921eaa66c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -234,31 +234,20 @@ //! //! # Non-bracketed Paste Bursts //! -//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of -//! `KeyCode::Char`, `KeyCode::Enter`, and `KeyCode::Tab` key events instead of a single paste event. -//! -//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like -//! shortcut overlays toggling on a pasted `?`), we feed text-producing character events (plain, -//! Shift, or Windows AltGr) into -//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them -//! through [`ChatComposer::handle_paste`]. +//! Some terminals, especially Windows, deliver pastes as rapid `KeyCode::Char`/`Enter`/`Tab` events. +//! [`PasteBurst`](super::paste_burst::PasteBurst) buffers text-producing keys (plain, Shift, or +//! Windows AltGr) and integrates buffered text through the composer, avoiding transient shortcut UI. +//! It briefly holds the first ASCII char to detect a burst. Non-ASCII chars appear immediately +//! for IME responsiveness, but still participate in burst detection. //! Parent views must keep flushing editors that lose focus while input is buffered; a hidden //! editor's pending burst can otherwise keep the shared draw loop waiting indefinitely. //! -//! The burst detector intentionally treats ASCII and non-ASCII differently: -//! -//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the -//! stream is paste-like. -//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow -//! burst detection for actual paste streams. -//! -//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state -//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the -//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. +//! Submission flushes expired characters and buffers before classifying Enter, independent of +//! UI flush ticks. `disable_paste_burst` bypasses detection; setting it flushes and clears in-flight state. //! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`]. Confirmed //! copies clear the selection while preserving the draft and cursor. //! -//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`. +//! See `codex-rs/tui/src/bottom_pane/paste_burst.rs` for the detailed state machine. //! //! # PasteBurst Integration Points //! @@ -3163,6 +3152,8 @@ impl ChatComposer { should_queue: bool, now: Instant, ) -> (InputResult, bool) { + self.handle_paste_burst_flush(now); + if !should_queue && self.handle_paste_enter(now) { return (InputResult::None, true); } @@ -5056,6 +5047,10 @@ mod agents_navigation_tests; #[path = "chat_composer_effort_tests.rs"] mod effort_tests; +#[cfg(test)] +#[path = "chat_composer_enter_tests.rs"] +mod enter_tests; + #[cfg(test)] #[path = "chat_composer/embedded_input_tests.rs"] mod embedded_input_tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index eecc926631..21481ddfc0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -91,6 +91,7 @@ impl ChatComposer { /// /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. + /// - Submission, before deciding whether Enter belongs to an active paste burst. pub(super) fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { match self.draft.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_enter_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer_enter_tests.rs new file mode 100644 index 0000000000..ea059b4100 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer_enter_tests.rs @@ -0,0 +1,87 @@ +//! Enter must expire paste detection even when no UI flush tick has run. + +use std::time::Duration; +use std::time::Instant; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +use super::InputResult; +use super::PasteBurst; +use super::tests::new_test_composer; +use super::tests::snapshot_composer_state_with_width; + +#[test] +fn enter_expires_paste_state_without_a_ui_tick() { + for vim_enabled in [false, true] { + for parent_owned in [false, true] { + for text in ["x", "hi"] { + let (mut composer, _rx) = new_test_composer(); + composer.set_vim_enabled(vim_enabled); + composer.draft.textarea.enter_vim_insert_mode(); + if parent_owned { + composer.set_parent_owned_thread(); + } + let now = Instant::now(); + for ch in text.chars() { + composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + } + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.current_text(), ""); + + let result = composer.handle_submission_with_time( + /*should_queue*/ false, + now + PasteBurst::recommended_active_flush_delay(), + ); + let expected = if parent_owned { + InputResult::ParentOwnedInputBlocked + } else { + InputResult::Submitted { + text: text.to_owned(), + text_elements: Vec::new(), + } + }; + assert_eq!(result, (expected, true)); + assert_eq!( + composer.current_text(), + if parent_owned { text } else { "" } + ); + assert!(!composer.is_in_paste_burst()); + } + } + } +} + +#[test] +fn plain_enter_submits_expired_vim_insert_input() { + snapshot_composer_state_with_width( + "vim_enter_after_expired_paste", + /*width*/ 60, + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_vim_enabled(/*enabled*/ true); + composer.draft.textarea.enter_vim_insert_mode(); + let typed_at = Instant::now() - Duration::from_secs(1); + composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + typed_at, + ); + assert_eq!( + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ( + InputResult::Submitted { + text: "x".to_owned(), + text_elements: Vec::new(), + }, + true, + ) + ); + assert_eq!(composer.current_text(), ""); + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_enter_after_expired_paste.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_enter_after_expired_paste.snap new file mode 100644 index 0000000000..607cc64642 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_enter_after_expired_paste.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left | Vim: Insert "