From c52ac3d4511f0667a2a8b84a58de54b51dc1e7fb Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 22 Aug 2026 19:10:08 -0500 Subject: [PATCH 1/7] chore: stage exact paste-burst event patch --- scripts/apply-paste-burst-release-fix.py | 332 +++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 scripts/apply-paste-burst-release-fix.py diff --git a/scripts/apply-paste-burst-release-fix.py b/scripts/apply-paste-burst-release-fix.py new file mode 100644 index 0000000..e4443be --- /dev/null +++ b/scripts/apply-paste-burst-release-fix.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Apply the reviewed paste-burst release-event correction to app.rs. + +This is a one-shot, branch-scoped migration helper. It asserts the exact source +blob and every replacement target so repository drift fails closed rather than +partially rewriting the TUI event loop. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + + +APP_PATH = Path("src-rust/crates/tui/src/app.rs") +EXPECTED_BLOB = "4a573d0777372610cd01f4eae18b6478d4fa17fe" + + +def git_blob_sha(data: bytes) -> str: + header = f"blob {len(data)}\0".encode("ascii") + return hashlib.sha1(header + data).hexdigest() + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + return text.replace(old, new, 1) + + +def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: + start_count = text.count(start) + end_count = text.count(end) + if start_count != 1 or end_count != 1: + raise SystemExit( + f"{label}: expected one start/end marker, found {start_count}/{end_count}" + ) + start_index = text.index(start) + end_index = text.index(end, start_index) + return text[:start_index] + replacement + text[end_index:] + + +def main() -> None: + source = APP_PATH.read_bytes() + actual_blob = git_blob_sha(source) + if actual_blob != EXPECTED_BLOB: + raise SystemExit( + f"source drift: expected app.rs blob {EXPECTED_BLOB}, found {actual_blob}" + ) + + text = source.decode("utf-8") + + text = replace_once( + text, + """ /// 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, +""", + "pending key field", + ) + + text = replace_once( + text, + " pending_key: None,\n", + " pending_keys: std::collections::VecDeque::new(),\n", + "pending key initializer", + ) + + replacement = """ /// 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_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 + /// queue (zero-timeout poll) and return them alongside `first` as a single + /// pasted string if the burst is large enough to be a paste. + /// + /// 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 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. 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; + + // 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, + // so a second char in the same zero-timeout drain is extremely unlikely + // from a human typist but guaranteed from a clipboard paste. + const BURST_THRESHOLD: usize = 2; + + // Quick exit: don't bother if nothing is queued immediately. + if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { + return None; + } + + let mut drained = Vec::new(); + while let Ok(true) = crossterm::event::poll(std::time::Duration::ZERO) { + match crossterm::event::read() { + 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, + } + } + + 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 + } + } + +""" + text = replace_between( + text, + " /// Take any key event saved by `try_detect_paste_burst` when a non-character\n", + " /// Process mouse events (trackpad scroll, text selection, etc.).\n", + replacement, + "paste burst implementation", + ) + + text = replace_once( + text, + """ // 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(); +""", + "event-loop pending-key replay", + ) + + test_anchor = """ fn press_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + +""" + test_block = test_anchor + """ 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()); + } + +""" + text = replace_once(text, test_anchor, test_block, "paste burst regression tests") + + if "pending_key" in text: + # Only the public method name and documentation reference should remain. + leftovers = [line for line in text.splitlines() if "pending_key" in line] + allowed = ( + "take_pending_key", + "pending_keys", + ) + unexpected = [line for line in leftovers if not any(item in line for item in allowed)] + if unexpected: + raise SystemExit(f"unexpected stale pending_key references: {unexpected}") + + APP_PATH.write_text(text, encoding="utf-8") + print("patched", APP_PATH) + + +if __name__ == "__main__": + main() From 445b40177fbd844ae06ae8c952f94650e7b95a87 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 22 Aug 2026 19:10:26 -0500 Subject: [PATCH 2/7] chore: run one-shot paste-burst repair --- .../apply-paste-burst-release-fix.yml | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/apply-paste-burst-release-fix.yml diff --git a/.github/workflows/apply-paste-burst-release-fix.yml b/.github/workflows/apply-paste-burst-release-fix.yml new file mode 100644 index 0000000..6a46fdd --- /dev/null +++ b/.github/workflows/apply-paste-burst-release-fix.yml @@ -0,0 +1,56 @@ +name: Apply paste-burst release-event fix + +on: + push: + branches: + - fix/paste-burst-release-events + paths: + - .github/workflows/apply-paste-burst-release-fix.yml + +permissions: + contents: write + +concurrency: + group: apply-paste-burst-release-fix + cancel-in-progress: false + +jobs: + apply: + if: github.repository == 'OpenCoven/coven-code' && github.ref == 'refs/heads/fix/paste-burst-release-events' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out the isolated repair branch + uses: actions/checkout@v4 + with: + ref: fix/paste-burst-release-events + fetch-depth: 0 + persist-credentials: true + + - name: Apply the fail-closed source transformation + run: python3 scripts/apply-paste-burst-release-fix.py + + - name: Verify the generated patch is narrow and well formed + shell: bash + run: | + set -euo pipefail + git diff --check + mapfile -t changed < <(git diff --name-only) + if [[ ${#changed[@]} -ne 1 || "${changed[0]}" != "src-rust/crates/tui/src/app.rs" ]]; then + printf 'unexpected generated paths:\n' + printf ' %s\n' "${changed[@]}" + exit 1 + fi + + - name: Commit the repair and retire the one-shot machinery + shell: bash + run: | + set -euo pipefail + git config core.hooksPath /dev/null + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/apply-paste-burst-release-fix.yml + git rm scripts/apply-paste-burst-release-fix.py + git add src-rust/crates/tui/src/app.rs + git commit -m "fix(tui): preserve submit keys across paste releases" + git push origin HEAD:fix/paste-burst-release-events From dd6fd9fcf08aef6b3970b08210101c8bd4ac53a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:10:36 +0000 Subject: [PATCH 3/7] fix(tui): preserve submit keys across paste releases --- .../apply-paste-burst-release-fix.yml | 56 --- scripts/apply-paste-burst-release-fix.py | 332 ------------------ src-rust/crates/tui/src/app.rs | 235 ++++++++++--- 3 files changed, 180 insertions(+), 443 deletions(-) delete mode 100644 .github/workflows/apply-paste-burst-release-fix.yml delete mode 100644 scripts/apply-paste-burst-release-fix.py diff --git a/.github/workflows/apply-paste-burst-release-fix.yml b/.github/workflows/apply-paste-burst-release-fix.yml deleted file mode 100644 index 6a46fdd..0000000 --- a/.github/workflows/apply-paste-burst-release-fix.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Apply paste-burst release-event fix - -on: - push: - branches: - - fix/paste-burst-release-events - paths: - - .github/workflows/apply-paste-burst-release-fix.yml - -permissions: - contents: write - -concurrency: - group: apply-paste-burst-release-fix - cancel-in-progress: false - -jobs: - apply: - if: github.repository == 'OpenCoven/coven-code' && github.ref == 'refs/heads/fix/paste-burst-release-events' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out the isolated repair branch - uses: actions/checkout@v4 - with: - ref: fix/paste-burst-release-events - fetch-depth: 0 - persist-credentials: true - - - name: Apply the fail-closed source transformation - run: python3 scripts/apply-paste-burst-release-fix.py - - - name: Verify the generated patch is narrow and well formed - shell: bash - run: | - set -euo pipefail - git diff --check - mapfile -t changed < <(git diff --name-only) - if [[ ${#changed[@]} -ne 1 || "${changed[0]}" != "src-rust/crates/tui/src/app.rs" ]]; then - printf 'unexpected generated paths:\n' - printf ' %s\n' "${changed[@]}" - exit 1 - fi - - - name: Commit the repair and retire the one-shot machinery - shell: bash - run: | - set -euo pipefail - git config core.hooksPath /dev/null - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/apply-paste-burst-release-fix.yml - git rm scripts/apply-paste-burst-release-fix.py - git add src-rust/crates/tui/src/app.rs - git commit -m "fix(tui): preserve submit keys across paste releases" - git push origin HEAD:fix/paste-burst-release-events diff --git a/scripts/apply-paste-burst-release-fix.py b/scripts/apply-paste-burst-release-fix.py deleted file mode 100644 index e4443be..0000000 --- a/scripts/apply-paste-burst-release-fix.py +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the reviewed paste-burst release-event correction to app.rs. - -This is a one-shot, branch-scoped migration helper. It asserts the exact source -blob and every replacement target so repository drift fails closed rather than -partially rewriting the TUI event loop. -""" - -from __future__ import annotations - -import hashlib -from pathlib import Path - - -APP_PATH = Path("src-rust/crates/tui/src/app.rs") -EXPECTED_BLOB = "4a573d0777372610cd01f4eae18b6478d4fa17fe" - - -def git_blob_sha(data: bytes) -> str: - header = f"blob {len(data)}\0".encode("ascii") - return hashlib.sha1(header + data).hexdigest() - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one match, found {count}") - return text.replace(old, new, 1) - - -def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: - start_count = text.count(start) - end_count = text.count(end) - if start_count != 1 or end_count != 1: - raise SystemExit( - f"{label}: expected one start/end marker, found {start_count}/{end_count}" - ) - start_index = text.index(start) - end_index = text.index(end, start_index) - return text[:start_index] + replacement + text[end_index:] - - -def main() -> None: - source = APP_PATH.read_bytes() - actual_blob = git_blob_sha(source) - if actual_blob != EXPECTED_BLOB: - raise SystemExit( - f"source drift: expected app.rs blob {EXPECTED_BLOB}, found {actual_blob}" - ) - - text = source.decode("utf-8") - - text = replace_once( - text, - """ /// 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, -""", - "pending key field", - ) - - text = replace_once( - text, - " pending_key: None,\n", - " pending_keys: std::collections::VecDeque::new(),\n", - "pending key initializer", - ) - - replacement = """ /// 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_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 - /// queue (zero-timeout poll) and return them alongside `first` as a single - /// pasted string if the burst is large enough to be a paste. - /// - /// 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 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. 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; - - // 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, - // so a second char in the same zero-timeout drain is extremely unlikely - // from a human typist but guaranteed from a clipboard paste. - const BURST_THRESHOLD: usize = 2; - - // Quick exit: don't bother if nothing is queued immediately. - if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { - return None; - } - - let mut drained = Vec::new(); - while let Ok(true) = crossterm::event::poll(std::time::Duration::ZERO) { - match crossterm::event::read() { - 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, - } - } - - 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 - } - } - -""" - text = replace_between( - text, - " /// Take any key event saved by `try_detect_paste_burst` when a non-character\n", - " /// Process mouse events (trackpad scroll, text selection, etc.).\n", - replacement, - "paste burst implementation", - ) - - text = replace_once( - text, - """ // 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(); -""", - "event-loop pending-key replay", - ) - - test_anchor = """ fn press_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { - KeyEvent { - code, - modifiers, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - } - } - -""" - test_block = test_anchor + """ 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()); - } - -""" - text = replace_once(text, test_anchor, test_block, "paste burst regression tests") - - if "pending_key" in text: - # Only the public method name and documentation reference should remain. - leftovers = [line for line in text.splitlines() if "pending_key" in line] - allowed = ( - "take_pending_key", - "pending_keys", - ) - unexpected = [line for line in leftovers if not any(item in line for item in allowed)] - if unexpected: - raise SystemExit(f"unexpected stale pending_key references: {unexpected}") - - APP_PATH.write_text(text, encoding="utf-8") - print("patched", APP_PATH) - - -if __name__ == "__main__": - main() diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 4a573d0..b425770 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,80 @@ 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(' +'); + } 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 +6446,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 +6469,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 +7242,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 +7494,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()), From e15ae8a860930415560f944b4e838843aab02b7e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 22 Aug 2026 19:12:47 -0500 Subject: [PATCH 4/7] chore: stage newline-literal correction --- scripts/fix-generated-newline-literal.py | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 scripts/fix-generated-newline-literal.py diff --git a/scripts/fix-generated-newline-literal.py b/scripts/fix-generated-newline-literal.py new file mode 100644 index 0000000..4c4ae32 --- /dev/null +++ b/scripts/fix-generated-newline-literal.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Repair the one malformed Rust newline literal on PR #182, fail closed.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + + +PATH = Path("src-rust/crates/tui/src/app.rs") +EXPECTED_BLOB = "b42577066024e6ff19966e4725dce359ccb36acc" + + +def git_blob_sha(data: bytes) -> str: + header = f"blob {len(data)}\0".encode("ascii") + return hashlib.sha1(header + data).hexdigest() + + +def main() -> None: + data = PATH.read_bytes() + actual = git_blob_sha(data) + if actual != EXPECTED_BLOB: + raise SystemExit(f"source drift: expected {EXPECTED_BLOB}, found {actual}") + + # Build the byte sequences explicitly so no Python/JSON escape layer can + # turn the intended Rust `\\n` escape back into a physical newline. + malformed = b"buffer.push('" + bytes([10]) + b"');" + corrected = b"buffer.push('" + bytes([92, 110]) + b"');" + count = data.count(malformed) + if count != 1: + raise SystemExit(f"expected one malformed newline literal, found {count}") + + updated = data.replace(malformed, corrected, 1) + PATH.write_bytes(updated) + print("corrected generated Rust newline literal") + + +if __name__ == "__main__": + main() From c0debe22656b824d623c089f5a826915578aa2ff Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 22 Aug 2026 19:12:59 -0500 Subject: [PATCH 5/7] chore: validate and apply newline-literal correction --- .../fix-generated-newline-literal.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/fix-generated-newline-literal.yml diff --git a/.github/workflows/fix-generated-newline-literal.yml b/.github/workflows/fix-generated-newline-literal.yml new file mode 100644 index 0000000..d7fa8ed --- /dev/null +++ b/.github/workflows/fix-generated-newline-literal.yml @@ -0,0 +1,54 @@ +name: Fix generated newline literal + +on: + push: + branches: + - fix/paste-burst-release-events + paths: + - .github/workflows/fix-generated-newline-literal.yml + +permissions: + contents: write + +jobs: + correct: + if: github.repository == 'OpenCoven/coven-code' && github.ref == 'refs/heads/fix/paste-burst-release-events' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repair branch + uses: actions/checkout@v4 + with: + ref: fix/paste-burst-release-events + fetch-depth: 0 + persist-credentials: true + + - name: Correct the exact malformed byte sequence + run: python3 scripts/fix-generated-newline-literal.py + + - name: Validate Rust syntax and patch scope + shell: bash + run: | + set -euo pipefail + rustup component add rustfmt + rustfmt --edition 2021 --check src-rust/crates/tui/src/app.rs + git diff --check + mapfile -t changed < <(git diff --name-only) + if [[ ${#changed[@]} -ne 1 || "${changed[0]}" != "src-rust/crates/tui/src/app.rs" ]]; then + printf 'unexpected generated paths:\n' + printf ' %s\n' "${changed[@]}" + exit 1 + fi + + - name: Commit correction and retire one-shot files + shell: bash + run: | + set -euo pipefail + git config core.hooksPath /dev/null + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/fix-generated-newline-literal.yml + git rm scripts/fix-generated-newline-literal.py + git add src-rust/crates/tui/src/app.rs + git commit -m "fix(tui): emit a valid newline character literal" + git push origin HEAD:fix/paste-burst-release-events From 2505a654646f907736b6957097b893311f76b509 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:13:07 +0000 Subject: [PATCH 6/7] fix(tui): emit a valid newline character literal --- .../fix-generated-newline-literal.yml | 54 ------------------- scripts/fix-generated-newline-literal.py | 39 -------------- src-rust/crates/tui/src/app.rs | 3 +- 3 files changed, 1 insertion(+), 95 deletions(-) delete mode 100644 .github/workflows/fix-generated-newline-literal.yml delete mode 100644 scripts/fix-generated-newline-literal.py diff --git a/.github/workflows/fix-generated-newline-literal.yml b/.github/workflows/fix-generated-newline-literal.yml deleted file mode 100644 index d7fa8ed..0000000 --- a/.github/workflows/fix-generated-newline-literal.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Fix generated newline literal - -on: - push: - branches: - - fix/paste-burst-release-events - paths: - - .github/workflows/fix-generated-newline-literal.yml - -permissions: - contents: write - -jobs: - correct: - if: github.repository == 'OpenCoven/coven-code' && github.ref == 'refs/heads/fix/paste-burst-release-events' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out repair branch - uses: actions/checkout@v4 - with: - ref: fix/paste-burst-release-events - fetch-depth: 0 - persist-credentials: true - - - name: Correct the exact malformed byte sequence - run: python3 scripts/fix-generated-newline-literal.py - - - name: Validate Rust syntax and patch scope - shell: bash - run: | - set -euo pipefail - rustup component add rustfmt - rustfmt --edition 2021 --check src-rust/crates/tui/src/app.rs - git diff --check - mapfile -t changed < <(git diff --name-only) - if [[ ${#changed[@]} -ne 1 || "${changed[0]}" != "src-rust/crates/tui/src/app.rs" ]]; then - printf 'unexpected generated paths:\n' - printf ' %s\n' "${changed[@]}" - exit 1 - fi - - - name: Commit correction and retire one-shot files - shell: bash - run: | - set -euo pipefail - git config core.hooksPath /dev/null - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/fix-generated-newline-literal.yml - git rm scripts/fix-generated-newline-literal.py - git add src-rust/crates/tui/src/app.rs - git commit -m "fix(tui): emit a valid newline character literal" - git push origin HEAD:fix/paste-burst-release-events diff --git a/scripts/fix-generated-newline-literal.py b/scripts/fix-generated-newline-literal.py deleted file mode 100644 index 4c4ae32..0000000 --- a/scripts/fix-generated-newline-literal.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""Repair the one malformed Rust newline literal on PR #182, fail closed.""" - -from __future__ import annotations - -import hashlib -from pathlib import Path - - -PATH = Path("src-rust/crates/tui/src/app.rs") -EXPECTED_BLOB = "b42577066024e6ff19966e4725dce359ccb36acc" - - -def git_blob_sha(data: bytes) -> str: - header = f"blob {len(data)}\0".encode("ascii") - return hashlib.sha1(header + data).hexdigest() - - -def main() -> None: - data = PATH.read_bytes() - actual = git_blob_sha(data) - if actual != EXPECTED_BLOB: - raise SystemExit(f"source drift: expected {EXPECTED_BLOB}, found {actual}") - - # Build the byte sequences explicitly so no Python/JSON escape layer can - # turn the intended Rust `\\n` escape back into a physical newline. - malformed = b"buffer.push('" + bytes([10]) + b"');" - corrected = b"buffer.push('" + bytes([92, 110]) + b"');" - count = data.count(malformed) - if count != 1: - raise SystemExit(f"expected one malformed newline literal, found {count}") - - updated = data.replace(malformed, corrected, 1) - PATH.write_bytes(updated) - print("corrected generated Rust newline literal") - - -if __name__ == "__main__": - main() diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index b425770..65c393e 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -6424,8 +6424,7 @@ impl App { .map(Self::is_paste_text_key) .unwrap_or(false); if has_following_text { - buffer.push(' -'); + buffer.push('\n'); } else { pending.push_back(key); pending.extend(remaining); From c392447053282a025a8c4e4e2f0c4dd972c9d18a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 22 Aug 2026 19:16:18 -0500 Subject: [PATCH 7/7] chore: validate corrected paste-burst head