fix(tui): reassemble Windows key-burst pastes so repaste-to-expand works

On Windows, crossterm's console backend never emits Event::Paste: a
terminal paste arrives as a per-character key burst that the event loop
reassembles with timing heuristics. Three of its bounds split real
pastes into fragments:

- the 10 ms continue window is below one Windows scheduler quantum
  (~15.6 ms), so a routine mid-burst gap ended collection early;
- the 5000-event safety cap truncated any paste past ~5 KB;
- the 2 ms detection window missed bursts whose second chunk lagged,
  so collection never started.

Each fragment became its own synthetic paste. The paste chip then held
only the first fragment, and pasting again could never byte-equal the
chip content, so repaste-to-expand inserted a duplicate instead of
expanding — while macOS worked, because the unix parser buffers a
bracketed paste to the end marker and always delivers one whole event.

Widen the continue window past one quantum (25 ms), raise the cap above
any real paste (200k events), and skip the detection window entirely
when the batch already holds a >=8-key burst, which typing and
auto-repeat never produce. Unix paths are untouched: batches containing
a real Event::Paste never enter this reassembly.

Covered by a paused-clock test that trickles a burst tail with
one-quantum gaps and asserts it reassembles into a single paste.
This commit is contained in:
2026-07-24 12:41:26 -04:00
parent f5b9000c5d
commit 7ade135e76
2 changed files with 97 additions and 6 deletions
+2
View File
@@ -154,6 +154,8 @@ windows-sys = { version = "0.59", features = ["Win32_System_Console"] }
[dev-dependencies]
# Enable the render crate's test-only helpers for the pager's test build.
kigi-pager-render = { path = "../kigi-pager-render", features = ["test-support"] }
# `start_paused = true` for deterministic paste-reassembly timeout tests.
tokio = { workspace = true, features = ["test-util"] }
pretty_assertions = { workspace = true }
insta = { workspace = true }
criterion = { workspace = true }
+95 -6
View File
@@ -2333,8 +2333,14 @@ async fn drain_and_process(
}
// On terminals without bracketed paste, try to capture more events
// that may still be in transit from the input reader thread.
if should_extend_for_paste(&raw_events) && detect_paste(&mut raw_events, input_rx).await {
// that may still be in transit from the input reader thread. A batch
// that already holds a key burst skips the short detection window:
// its 2 ms budget is below one Windows scheduler quantum, so a
// mid-paste gap would end collection before it starts and the paste
// tail would arrive as a separate fragment.
if should_extend_for_paste(&raw_events)
&& (is_paste_burst(&raw_events) || detect_paste(&mut raw_events, input_rx).await)
{
collect_remaining_paste(&mut raw_events, input_rx).await;
// The paste extension pulled more events off the channel without
// running them through the still-armed filter — a late or split
@@ -2535,11 +2541,20 @@ async fn drain_and_process(
/// arrives within this window the batch was a normal keystroke.
const PASTE_DETECT_TIMEOUT: Duration = Duration::from_millis(2);
/// Timeout for subsequent rounds once paste has been detected.
const PASTE_CONTINUE_TIMEOUT: Duration = Duration::from_millis(10);
/// Timeout for subsequent rounds once paste has been detected. Must exceed
/// one Windows scheduler quantum (~15.6 ms): ConPTY delivers a paste as a
/// per-character key burst, and a mid-burst gap of one quantum is routine.
/// A smaller window ends collection mid-paste; the tail then arrives as a
/// second synthetic paste, whose content can never match the paste chip, so
/// repaste-to-expand inserts a duplicate instead of expanding.
const PASTE_CONTINUE_TIMEOUT: Duration = Duration::from_millis(25);
/// Safety cap on events accumulated in one extension pass.
const PASTE_EXTEND_MAX_EVENTS: usize = 5_000;
/// Safety cap on events accumulated in one extension pass. On the
/// no-bracketed-paste path a paste is one key event per character, so the
/// cap must exceed any real paste (~200 KB of text); hitting it splits the
/// paste with the same duplicate-on-repaste effect as a timeout split. The
/// idle timeout above, not this cap, is the normal terminator.
const PASTE_EXTEND_MAX_EVENTS: usize = 200_000;
/// Returns `true` when the batch contains pasteable key events but no
/// `Event::Paste` (i.e. bracketed paste is not handling it).
@@ -2548,6 +2563,17 @@ fn should_extend_for_paste(events: &[Event]) -> bool {
&& events.iter().any(is_pasteable_key_event)
}
/// Minimum pasteable key events already in one batch to classify it as a
/// paste burst in progress. Human typing and key auto-repeat deliver one
/// or two events per batch; only a paste chunk lands more at once.
const PASTE_BURST_BATCH_EVENTS: usize = 8;
/// True when the batch alone proves a paste is in progress, without
/// waiting on [`PASTE_DETECT_TIMEOUT`].
fn is_paste_burst(events: &[Event]) -> bool {
events.iter().filter(|e| is_pasteable_key_event(e)).count() >= PASTE_BURST_BATCH_EVENTS
}
/// Wait [`PASTE_DETECT_TIMEOUT`] for a follow-up event. Returns `true`
/// if a **pasteable key event** arrives within the window. Non-key events
/// (mouse, focus, releases) are collected but do not count as paste evidence.
@@ -3151,6 +3177,69 @@ mod tests {
assert_eq!(result[0], Event::Paste("ab\ncd".to_string()));
}
#[test]
fn paste_burst_detected_from_batch_size_alone() {
let chunk: Vec<Event> = "long pasted chunk"
.chars()
.map(|c| press(KeyCode::Char(c)))
.collect();
assert!(is_paste_burst(&chunk));
}
#[test]
fn typing_and_release_storms_are_not_a_paste_burst() {
// A short typed run stays below the burst threshold.
let typed = vec![press(KeyCode::Char('h')), press(KeyCode::Char('i'))];
assert!(!is_paste_burst(&typed));
// Release events carry no content and must not count toward it.
let releases: Vec<Event> = "releases only!"
.chars()
.map(|c| release(KeyCode::Char(c)))
.collect();
assert!(!is_paste_burst(&releases));
}
/// A key-burst paste whose tail trickles in with sub-window gaps must
/// reassemble into ONE synthetic paste. A split here is what made
/// repaste-to-expand insert a duplicate chip on Windows: the fragment
/// could never byte-equal the chip content.
#[tokio::test(start_paused = true)]
async fn burst_tail_with_scheduler_gaps_reassembles_into_one_paste() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let head = "first chunk of a long paste\n";
let tail = "tail chunk one\ntail chunk two\n";
let mut batch: Vec<Event> = head.chars().map(char_or_enter).collect();
assert!(is_paste_burst(&batch), "head chunk must classify as burst");
// Trickle the tail with 15 ms gaps — one Windows scheduler quantum,
// larger than the old 10 ms window, smaller than the current one.
tokio::spawn(async move {
for c in tail.chars() {
tokio::time::sleep(Duration::from_millis(15)).await;
let _ = tx.send(char_or_enter(c));
}
});
collect_remaining_paste(&mut batch, &mut rx).await;
let result = coalesce_rapid_keys(batch);
assert_eq!(result.len(), 1, "one reassembled paste, got {result:?}");
assert_eq!(
result[0],
Event::Paste(format!("{head}{tail}")),
"full content must survive reassembly"
);
}
fn char_or_enter(c: char) -> Event {
if c == '\n' {
press(KeyCode::Enter)
} else {
press(KeyCode::Char(c))
}
}
#[test]
fn coalesce_filters_release_events() {
// Press+Release pairs (Windows Terminal, Kitty) must not break runs.