diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 4a573d0..65c393e 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -1041,10 +1041,10 @@ pub struct App { pub voice_recording: bool, /// Receiver for VoiceEvent messages produced by the recorder task. pub voice_event_rx: Option>, - /// A single key event that was drained from the queue during paste-burst - /// detection but wasn't part of the burst (e.g. a modifier key that stopped - /// the burst). Replayed at the top of the next loop iteration. - pending_key: Option, + /// Key events drained from the queue during paste-burst detection that were + /// not part of the burst. Replayed in FIFO order at the top of later event + /// loop iterations so lookahead never drops submission or navigation keys. + pending_keys: std::collections::VecDeque, /// Receiver for model-list results fetched in the background when the /// /model picker opens. Drained each frame so models appear as soon as /// the fetch completes. @@ -1518,7 +1518,7 @@ impl App { voice_recorder: voice_recorder_from_env_and_settings(), voice_recording: false, voice_event_rx: None, - pending_key: None, + pending_keys: std::collections::VecDeque::new(), model_fetch_rx: None, user_question_rx: None, ask_user_dialog: crate::ask_user_dialog::AskUserDialogState::new(), @@ -6363,11 +6363,79 @@ impl App { && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert } - /// Take any key event saved by `try_detect_paste_burst` when a non-character - /// key terminated a paste burst. The caller should replay it as the next - /// event at the top of its event loop. + /// Take the next key event saved by `try_detect_paste_burst` when + /// lookahead reached a submission or non-text key. Events are replayed in + /// FIFO order at the top of later event-loop iterations. pub fn take_pending_key(&mut self) -> Option { - self.pending_key.take() + self.pending_keys.pop_front() + } + + fn is_paste_text_key(key: &crossterm::event::KeyEvent) -> bool { + use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + + if key.kind != KeyEventKind::Press { + return false; + } + + let text_modifiers = + key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT; + text_modifiers && matches!(key.code, KeyCode::Char(_) | KeyCode::Enter) + } + + /// Classify already-drained key events without depending on the terminal + /// event queue. Release/repeat events are ignored because both interactive + /// loops process press events only. Any meaningful key that does not belong + /// to the paste is retained for ordered replay. + fn classify_paste_burst_events( + first: char, + events: impl IntoIterator, + ) -> ( + String, + std::collections::VecDeque, + ) { + use crossterm::event::{KeyCode, KeyEventKind}; + use std::collections::VecDeque; + + let mut remaining = events + .into_iter() + .filter(|key| key.kind == KeyEventKind::Press) + .collect::>(); + let mut pending = VecDeque::new(); + let mut buffer = String::new(); + buffer.push(first); + + while let Some(key) = remaining.pop_front() { + if !Self::is_paste_text_key(&key) { + pending.push_back(key); + pending.extend(remaining); + break; + } + + match key.code.clone() { + KeyCode::Char(character) => buffer.push(character), + KeyCode::Enter => { + // Enter is an interior line break only when the next + // meaningful press is also paste text. Terminal keyboard + // protocols commonly place a Release event immediately + // after Enter; those releases were filtered above and must + // not make a final submit key look like interior text. + let has_following_text = remaining + .front() + .map(Self::is_paste_text_key) + .unwrap_or(false); + if has_following_text { + buffer.push('\n'); + } else { + pending.push_back(key); + pending.extend(remaining); + break; + } + } + _ => unreachable!("paste text predicate admitted a non-text key"), + } + } + + (buffer, pending) } /// Drain any immediately-available key events from the crossterm event @@ -6377,19 +6445,17 @@ impl App { /// On Windows Terminal, Ctrl+V causes the terminal emulator to write the /// clipboard content directly to stdin as raw character events — every /// newline becomes an Enter keypress and stray `v` characters trigger - /// voice PTT. Because a paste dumps ALL characters into the queue at - /// once, a zero-timeout drain immediately after the first character - /// reliably yields 3+ chars for any non-trivial paste, while normal - /// keyboard typing (even at 120 WPM) almost never queues more than one - /// char in the same 50 ms window. + /// voice PTT. Because a paste dumps its events into the queue together, a + /// zero-timeout drain immediately after the first character reliably finds + /// the rest of a non-trivial paste, while normal keyboard typing almost + /// never queues a second press in the same drain. /// - /// Returns `Some(text)` when a paste burst is detected (caller should - /// route through `handle_paste_data`). Returns `None` for a normal - /// single keystroke. If a non-character key is encountered while - /// draining, it is stored in `self.pending_key` and will be replayed at - /// the top of the next event-loop iteration. + /// Returns `Some(text)` when a paste burst is detected (caller should route + /// through `handle_paste_data`). Returns `None` for a normal single + /// keystroke. Submission and non-text press events reached during lookahead + /// are retained in `self.pending_keys` and replayed in FIFO order. pub fn try_detect_paste_burst(&mut self, first: char) -> Option { - use crossterm::event::{Event, KeyCode, KeyEventKind}; + use crossterm::event::Event; // Minimum number of chars (including `first`) to classify as a paste. // Two or more is enough: at 120 WPM the inter-key interval is ~60 ms, @@ -6402,44 +6468,21 @@ impl App { return None; } - let mut buf = String::new(); - buf.push(first); - + let mut drained = Vec::new(); while let Ok(true) = crossterm::event::poll(std::time::Duration::ZERO) { match crossterm::event::read() { - Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => match k.code { - KeyCode::Char(c) => buf.push(c), - KeyCode::Enter => { - // A newline with more input behind it is an interior - // line break of a multi-line paste, so it belongs in - // the text. A newline with nothing behind it is the - // keystroke that ends the line — replay it so the - // caller submits. Swallowing it here is how a pasted - // (or programmatically typed) message ends up sitting - // in the prompt with a trailing '\n', never sent. - if crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { - buf.push('\n'); - } else { - self.pending_key = Some(k); - break; - } - } - _ => { - // Non-character key — save it for replay. - self.pending_key = Some(k); - break; - } - }, - // Non-key event (mouse, resize, …) — leave in queue by - // not reading it; we already checked poll() so it will - // be re-read next iteration. But we already read it, so - // we just break (the event is consumed but benign). + Ok(Event::Key(key)) => drained.push(key), + // Preserve the existing contract for mouse/resize events: the + // first non-key event ends paste detection after being consumed. _ => break, } } - if buf.chars().count() >= BURST_THRESHOLD { - Some(buf) + let (buffer, pending) = Self::classify_paste_burst_events(first, drained); + self.pending_keys.extend(pending); + + if buffer.chars().count() >= BURST_THRESHOLD { + Some(buffer) } else { None } @@ -7198,9 +7241,10 @@ impl App { tracing::debug!(target: "osc8", "hyperlink overlay write failed: {err}"); } - // Replay a key that was saved by try_detect_paste_burst in a - // previous iteration (e.g. a modifier key that terminated a burst). - let pending = self.pending_key.take(); + // Replay the next key saved by try_detect_paste_burst. A FIFO is + // required because lookahead can encounter a final Enter followed + // by another meaningful key after terminal release events. + let pending = self.take_pending_key(); // Poll for events with a short timeout so we can redraw for animation let got_event = pending.is_some() || event::poll(std::time::Duration::from_millis(50))?; @@ -7449,6 +7493,86 @@ mod tests { } } + fn release_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + } + } + + #[test] + fn paste_burst_release_after_interior_enter_does_not_stop_multiline_text() { + let (text, pending) = App::classify_paste_burst_events( + 'a', + [ + press_key(KeyCode::Enter, KeyModifiers::NONE), + release_key(KeyCode::Enter, KeyModifiers::NONE), + press_key(KeyCode::Char('b'), KeyModifiers::NONE), + release_key(KeyCode::Char('b'), KeyModifiers::NONE), + press_key(KeyCode::Enter, KeyModifiers::NONE), + release_key(KeyCode::Enter, KeyModifiers::NONE), + ], + ); + + assert_eq!(text, "a\nb"); + assert_eq!( + pending.into_iter().map(|key| key.code).collect::>(), + vec![KeyCode::Enter] + ); + } + + #[test] + fn paste_burst_final_enter_survives_kitty_release_event() { + let (text, pending) = App::classify_paste_burst_events( + 'a', + [ + press_key(KeyCode::Char('b'), KeyModifiers::NONE), + release_key(KeyCode::Char('b'), KeyModifiers::NONE), + press_key(KeyCode::Enter, KeyModifiers::NONE), + release_key(KeyCode::Enter, KeyModifiers::NONE), + ], + ); + + assert_eq!(text, "ab"); + assert_eq!( + pending.into_iter().map(|key| key.code).collect::>(), + vec![KeyCode::Enter] + ); + } + + #[test] + fn paste_burst_replays_submit_before_following_non_text_key() { + let (text, pending) = App::classify_paste_burst_events( + 'a', + [ + press_key(KeyCode::Char('b'), KeyModifiers::NONE), + press_key(KeyCode::Enter, KeyModifiers::NONE), + release_key(KeyCode::Enter, KeyModifiers::NONE), + press_key(KeyCode::Left, KeyModifiers::NONE), + release_key(KeyCode::Left, KeyModifiers::NONE), + ], + ); + + assert_eq!(text, "ab"); + assert_eq!( + pending.into_iter().map(|key| key.code).collect::>(), + vec![KeyCode::Enter, KeyCode::Left] + ); + } + + #[test] + fn paste_burst_release_events_do_not_count_as_text() { + let (text, pending) = App::classify_paste_burst_events( + 'a', + [release_key(KeyCode::Char('a'), KeyModifiers::NONE)], + ); + + assert_eq!(text, "a"); + assert!(pending.is_empty()); + } + fn test_agent_definition() -> claurst_core::AgentDefinition { claurst_core::AgentDefinition { description: Some("custom".to_string()),