Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 179 additions & 55 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<tokio::sync::mpsc::Receiver<claurst_core::voice::VoiceEvent>>,
/// 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<crossterm::event::KeyEvent>,
/// 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<crossterm::event::KeyEvent>,
/// 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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<crossterm::event::KeyEvent> {
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<Item = crossterm::event::KeyEvent>,
) -> (
String,
std::collections::VecDeque<crossterm::event::KeyEvent>,
) {
use crossterm::event::{KeyCode, KeyEventKind};
use std::collections::VecDeque;

let mut remaining = events
.into_iter()
.filter(|key| key.kind == KeyEventKind::Press)
.collect::<VecDeque<_>>();
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
Expand All @@ -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<String> {
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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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))?;
Expand Down Expand Up @@ -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<_>>(),
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<_>>(),
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<_>>(),
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()),
Expand Down
Loading