atomcode_tuix/input/reader.rs
1// crates/atomcode-tuix/src/input/reader.rs
2use std::sync::mpsc::{self as stdmpsc, TryRecvError};
3use std::time::Duration;
4
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use crossterm::event::{DisableFocusChange, EnableFocusChange};
7use crossterm::execute;
8use tokio::sync::mpsc;
9
10use super::InputEvent;
11
12/// If a Key event could plausibly be part of a paste burst, return the
13/// character it contributes. Enter maps to `\n`, Tab to `\t`, Char(c) to
14/// itself. Modifier-carrying keys (Ctrl/Alt) and non-Press kinds are
15/// excluded — those are commands, not pasted content.
16fn paste_candidate_char(ev: &Event) -> Option<char> {
17 let Event::Key(KeyEvent {
18 kind,
19 code,
20 modifiers,
21 ..
22 }) = ev
23 else {
24 return None;
25 };
26 if *kind != KeyEventKind::Press {
27 return None;
28 }
29 // Shift is fine (Shift+letter on paste of uppercase). Anything else
30 // means the user is issuing a command.
31 let allowed = KeyModifiers::SHIFT | KeyModifiers::NONE;
32 if !(modifiers.difference(allowed).is_empty()) {
33 return None;
34 }
35 match code {
36 KeyCode::Char(c) => Some(*c),
37 // Shift+Enter is "insert newline", a user command — never a
38 // paste-burst char. Real pasted newlines arrive as Event::Paste
39 // (bracketed paste) or as plain Enter with NO modifier (conhost
40 // char-by-char). If we let Shift+Enter in here, the single-event
41 // else-branch at the bottom reconstructs KeyEvent with NONE
42 // modifiers and classify then collapses it to Submit.
43 KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => None,
44 KeyCode::Enter => Some('\n'),
45 KeyCode::Tab => Some('\t'),
46 _ => None,
47 }
48}
49
50/// True when an aggregated `paste_candidate_char` burst should be treated
51/// as a real `InputEvent::Paste` rather than emitted as individual key
52/// events. Conjuncted conditions:
53///
54/// 1. **At least 2 chars** — singletons are normal typing.
55/// 2. **Contains `\n`** — the unambiguous "this is multi-line content"
56/// signal. Bursts of plain printable chars (someone typing fast) get
57/// handled per-key just fine without aggregation.
58/// 3. **At least one non-whitespace char** — distinguishes a real paste
59/// from buffered Enter/Tab keystrokes left in the tty input queue at
60/// startup. Without this guard, two Enters mashed by the user before
61/// atomcode took over the terminal (e.g. while waiting for a slow
62/// `cargo build` to finish) get aggregated into `Paste("\n\n")` and
63/// inserted as text — the input box opens with two pre-typed blank
64/// lines. Genuine pastes containing only whitespace + newlines are
65/// vanishingly rare; falling back to per-key submission of those bursts
66/// is the right trade-off.
67/// 4. **Avg ≥ 2 non-newline chars per line** when the burst is 3+ lines.
68/// Defends against the JediTerm IME commit storm reported on Windows:
69/// every Pinyin candidate selection emitted `<char> + Enter` in rapid
70/// succession (within the 2ms aggregation window), producing a burst
71/// like `[首, \n, 页, \n, 中, \n, …]`. Old heuristic accepted that as
72/// a paste, leaving the buffer with `\n` between every CJK char and
73/// the input row showing `首↵页↵中↵…`. Genuine multi-line pastes
74/// always have lines with text; IME bursts have exactly 1 text char
75/// per line. Threshold scoped to 3+ lines so a legitimate 2-line
76/// paste with two single-char lines (rare but possible) still flows
77/// through the paste path.
78fn is_paste_burst(chars: &[char]) -> bool {
79 if chars.len() < 2 {
80 return false;
81 }
82 let mut has_enter = false;
83 let mut has_text_char = false;
84 let mut newline_count = 0usize;
85 for &c in chars {
86 if c == '\n' {
87 has_enter = true;
88 newline_count += 1;
89 }
90 if !c.is_whitespace() {
91 has_text_char = true;
92 }
93 }
94 if !has_enter || !has_text_char {
95 return false;
96 }
97 let line_count = newline_count + 1;
98 let non_newline_count = chars.len() - newline_count;
99 if line_count >= 3 && non_newline_count <= line_count {
100 // Mean ≤ 1 char per line. JediTerm IME pattern, not a paste.
101 return false;
102 }
103 true
104}
105
106/// Lifecycle commands for the reader thread. Sent from the event loop
107/// whenever an external process (OAuth browser flow, `/shell`, etc.)
108/// needs stdin/stdout in cooked mode without our reader racing for bytes.
109#[derive(Debug)]
110pub enum ReaderCommand {
111 /// Stop calling `event::poll` / `event::read`. The reader blocks on
112 /// its command channel until Resume arrives. Sends a single `()` on
113 /// `ack` once it's confirmed idle, so the caller can safely take
114 /// over stdin without a race.
115 Pause,
116 /// Resume normal event dispatch. No ack — the next keystroke is
117 /// the ack.
118 Resume,
119 /// Exit the thread. Idempotent; dropping the sender also triggers exit.
120 Shutdown,
121}
122
123/// Control handle returned from `spawn`. Owns the join handle + the
124/// command channel; dropping the handle shuts the reader down cleanly.
125pub struct ReaderHandle {
126 join: Option<std::thread::JoinHandle<()>>,
127 cmd_tx: stdmpsc::Sender<(ReaderCommand, Option<stdmpsc::Sender<()>>)>,
128 focus_tracking_enabled: bool,
129}
130
131impl ReaderHandle {
132 /// Pause + wait for ack. After this returns, the reader is guaranteed
133 /// to NOT be inside `event::poll` / `event::read`, so the caller can
134 /// disable raw mode and hand stdin to a child process without the
135 /// reader stealing bytes.
136 ///
137 /// Returns early (Ok) if the reader already exited — callers should
138 /// treat that as "nothing to pause" rather than an error.
139 pub fn pause_blocking(&self) -> std::io::Result<()> {
140 let (ack_tx, ack_rx) = stdmpsc::channel();
141 if self
142 .cmd_tx
143 .send((ReaderCommand::Pause, Some(ack_tx)))
144 .is_err()
145 {
146 return Ok(()); // reader already gone
147 }
148 // Bounded wait — if the reader is stuck inside `event::poll` we
149 // still ACK within the 100ms poll timeout.
150 match ack_rx.recv_timeout(Duration::from_secs(2)) {
151 Ok(()) => Ok(()),
152 Err(_) => Err(std::io::Error::new(
153 std::io::ErrorKind::TimedOut,
154 "reader thread did not ack Pause within 2s",
155 )),
156 }
157 }
158
159 /// Resume from Pause. Fire-and-forget — the next keystroke the user
160 /// presses becomes the implicit ack.
161 pub fn resume(&self) {
162 let _ = self.cmd_tx.send((ReaderCommand::Resume, None));
163 }
164}
165
166impl Drop for ReaderHandle {
167 fn drop(&mut self) {
168 let _ = self.cmd_tx.send((ReaderCommand::Shutdown, None));
169 if self.focus_tracking_enabled {
170 let _ = execute!(std::io::stdout(), DisableFocusChange);
171 atomcode_core::notify::set_terminal_focus_state(None);
172 }
173 // Let the thread finish on its own — we don't join here because
174 // the reader may be blocked inside `event::poll` for up to 100ms
175 // and we'd rather not stall caller shutdown.
176 if let Some(join) = self.join.take() {
177 drop(join);
178 }
179 }
180}
181
182/// Spawn a blocking OS thread that reads crossterm events and forwards them
183/// over `tx`. Returns a `ReaderHandle` for lifecycle control (Pause /
184/// Resume / Shutdown). The thread exits when:
185/// - the `ReaderHandle` is dropped (Shutdown sent),
186/// - `tx` is closed (send returns Err),
187/// - or a fatal crossterm read error fires.
188pub fn spawn(tx: mpsc::UnboundedSender<InputEvent>) -> ReaderHandle {
189 let focus_tracking_enabled = terminal_supports_focus_tracking();
190 if focus_tracking_enabled {
191 let _ = execute!(std::io::stdout(), EnableFocusChange);
192 atomcode_core::notify::set_terminal_focus_state(Some(true));
193 }
194 let (cmd_tx, cmd_rx) = stdmpsc::channel::<(ReaderCommand, Option<stdmpsc::Sender<()>>)>();
195 let join = std::thread::spawn(move || run(tx, cmd_rx));
196 ReaderHandle {
197 join: Some(join),
198 cmd_tx,
199 focus_tracking_enabled,
200 }
201}
202
203fn terminal_supports_focus_tracking() -> bool {
204 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
205 let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
206 term_program == "iTerm.app"
207 || term_program.eq_ignore_ascii_case("iTerm2")
208 || lc_terminal.eq_ignore_ascii_case("iTerm2")
209}
210
211/// Decide what the reader loop should do next, given the `event::poll`
212/// result and whether the input channel is still alive. Extracted from
213/// `run` so the four-way classification can be unit-tested without
214/// spinning up a real TTY.
215#[derive(Debug, PartialEq, Eq)]
216enum PollAction {
217 /// `poll` said "event available" — proceed to `event::read`.
218 Read,
219 /// No event in this tick and channel still open — loop again.
220 Continue,
221 /// No event and the input channel was dropped — exit the thread.
222 Exit,
223 /// `poll` returned `Err` — treat as a transient glitch (Windows
224 /// crossterm has been seen to fail `poll`/`read` during terminal
225 /// resize). Sleep briefly and loop. Critically, this is NOT
226 /// `Exit` — returning here would kill the reader thread and
227 /// collapse the event loop (`input_rx` closes → `maybe = None`
228 /// → break), which is the "atomcode exits when I resize on
229 /// Windows" bug.
230 Sleep,
231}
232
233fn classify_poll(res: std::io::Result<bool>, tx_closed: bool) -> PollAction {
234 match res {
235 Ok(true) => PollAction::Read,
236 Ok(false) if tx_closed => PollAction::Exit,
237 Ok(false) => PollAction::Continue,
238 Err(_) => PollAction::Sleep,
239 }
240}
241
242/// Minimum gap between two modifier+Enter Press events to count them as
243/// distinct user actions. Anything closer is treated as OS key autorepeat
244/// leaking through as Press events (happens on terminals that advertise
245/// CSI u support but don't implement `REPORT_EVENT_TYPES`, so crossterm
246/// can't tag autorepeat as `KeyEventKind::Repeat`).
247///
248/// 40 ms sits between OS autorepeat cadence (~30 ms on macOS / Linux) and
249/// the fastest humans can actually chord Shift+Enter twice (~100+ ms).
250/// Scoped to Enter-with-modifiers only — plain-key autorepeat (Backspace,
251/// arrows) remains useful and is left untouched.
252const MODIFIER_ENTER_DEDUP: Duration = Duration::from_millis(40);
253
254fn run(
255 tx: mpsc::UnboundedSender<InputEvent>,
256 cmd_rx: stdmpsc::Receiver<(ReaderCommand, Option<stdmpsc::Sender<()>>)>,
257) {
258 let mut paused = false;
259 // Last accepted (modifiers, timestamp) for a modifier+Enter Press.
260 // Used to drop autorepeat duplicates that slip past the terminal
261 // protocol's Repeat filtering.
262 let mut last_mod_enter: Option<(KeyModifiers, std::time::Instant)> = None;
263 loop {
264 // If paused, block on the command channel — no poll, no read, so
265 // the child process owns stdin cleanly. Only Resume / Shutdown
266 // exit the paused state.
267 if paused {
268 match cmd_rx.recv() {
269 Ok((ReaderCommand::Resume, _)) => {
270 paused = false;
271 }
272 Ok((ReaderCommand::Shutdown, _)) | Err(_) => return,
273 Ok((ReaderCommand::Pause, ack)) => {
274 // Already paused — just re-ack so the caller unblocks.
275 if let Some(ack) = ack {
276 let _ = ack.send(());
277 }
278 }
279 }
280 continue;
281 }
282
283 // Non-blocking drain of any pending command before each poll.
284 // Multiple Pause requests can coalesce here.
285 match cmd_rx.try_recv() {
286 Ok((ReaderCommand::Pause, ack)) => {
287 paused = true;
288 if let Some(ack) = ack {
289 let _ = ack.send(());
290 }
291 continue;
292 }
293 Ok((ReaderCommand::Resume, _)) => {
294 // Already running — ignore.
295 }
296 Ok((ReaderCommand::Shutdown, _)) => return,
297 Err(TryRecvError::Disconnected) => return,
298 Err(TryRecvError::Empty) => {}
299 }
300
301 match classify_poll(event::poll(Duration::from_millis(100)), tx.is_closed()) {
302 PollAction::Read => {}
303 PollAction::Continue => continue,
304 PollAction::Exit => return,
305 PollAction::Sleep => {
306 std::thread::sleep(Duration::from_millis(50));
307 continue;
308 }
309 }
310 let ev = match event::read() {
311 Ok(e) => e,
312 Err(_) => {
313 std::thread::sleep(Duration::from_millis(50));
314 continue;
315 }
316 };
317
318 // Autorepeat dedup for modifier+Enter. iTerm2's current CSI u
319 // implementation (3.5+/3.6) disambiguates Shift+Enter modifiers
320 // correctly but doesn't honour `REPORT_EVENT_TYPES`, so a held
321 // Shift+Enter emits N Press events at OS autorepeat cadence and
322 // the input box inserts N newlines for one physical keystroke.
323 // Drop same-modifier repeats that arrive within the dedup window.
324 if let Event::Key(k) = &ev {
325 if k.kind == KeyEventKind::Press && k.code == KeyCode::Enter && !k.modifiers.is_empty()
326 {
327 let now = std::time::Instant::now();
328 if let Some((last_mods, last_at)) = last_mod_enter {
329 if last_mods == k.modifiers
330 && now.duration_since(last_at) < MODIFIER_ENTER_DEDUP
331 {
332 crate::tuix_trace!("RD", "dedup mod+Enter {:?}", k.modifiers);
333 last_mod_enter = Some((k.modifiers, now));
334 continue;
335 }
336 }
337 last_mod_enter = Some((k.modifiers, now));
338 }
339 }
340
341 // Paste-burst detection for terminals without bracketed paste
342 // (Windows conhost, some PowerShell setups). When a user pastes
343 // multi-line text there, crossterm emits each character as an
344 // individual `Event::Key` — including embedded Enters, which
345 // individually trigger submit and produced "many queued
346 // submits". Real bracketed paste lands here as `Event::Paste`
347 // and this block is a no-op.
348 //
349 // Heuristic: if this event is a printable char / Enter / Tab
350 // AND more events are ALREADY queued (peek with 0-timeout
351 // poll), we're almost certainly inside a paste burst — real
352 // typing has human-scale gaps so the queue is empty on peek.
353 // Aggregate consecutive paste-candidate events and emit one
354 // synthetic `InputEvent::Paste`. Only triggers when the burst
355 // contains an Enter (the unambiguous "this is multi-line
356 // pasted text, not typing" signal); burst of chars without
357 // Enter falls through to the normal per-key path — it looks
358 // the same to the user either way and keeps the heuristic
359 // conservative.
360 if let Some(c0) = paste_candidate_char(&ev) {
361 let mut chars = vec![c0];
362 let mut trailing: Option<Event> = None;
363 const BATCH_CAP: usize = 8192;
364 while chars.len() < BATCH_CAP {
365 // 2ms timeout is way under any human typing cadence
366 // (fastest typists are ~60ms/char) but bridges the
367 // transient gap Windows crossterm takes to translate
368 // each console record into an Event — without it, a
369 // paste arriving as 8 records in the console buffer
370 // can emit events with 100µs-1ms inter-event gaps that
371 // a strict `poll(0)` misses, and every line gets
372 // treated as an independent keystroke sequence.
373 match event::poll(Duration::from_millis(2)) {
374 Ok(true) => {}
375 _ => break,
376 }
377 let nxt = match event::read() {
378 Ok(e) => e,
379 Err(_) => break,
380 };
381 // Windows crossterm in raw mode emits Press + Release
382 // (and Repeat on autorepeat). Release/Repeat interleaved
383 // with the paste burst used to kill aggregation — the
384 // very next event after 'A' Press is 'A' Release, which
385 // `paste_candidate_char` rejects, so we'd break out with
386 // chars=[A] and never see the rest of the burst. Skip
387 // non-Press Key events silently so the burst detector
388 // walks through to the next printable-char Press.
389 if let Event::Key(k) = &nxt {
390 if k.kind != KeyEventKind::Press {
391 continue;
392 }
393 }
394 match paste_candidate_char(&nxt) {
395 Some(c) => {
396 chars.push(c);
397 }
398 None => {
399 trailing = Some(nxt);
400 break;
401 }
402 }
403 }
404 if is_paste_burst(&chars) {
405 let text: String = chars.into_iter().collect();
406 crate::tuix_trace!("RD", "paste-burst synth len={}", text.len());
407 if tx.send(InputEvent::Paste(text)).is_err() {
408 return;
409 }
410 } else {
411 // Not a clear paste signature — emit originals per-key.
412 // We only kept chars, so reconstruct KeyEvents. The
413 // first event we read is `ev`; subsequent ones we
414 // discarded in favour of `chars`. Rebuild from chars
415 // using a minimal KeyEvent (no modifiers) — this path
416 // fires in the rare case where events piled up but
417 // there was no Enter, i.e. fast typing or single-line
418 // paste. Both look the same on screen, so a synthetic
419 // reconstruction is faithful to user intent.
420 for c in chars {
421 let code = match c {
422 '\n' => KeyCode::Enter,
423 '\t' => KeyCode::Tab,
424 other => KeyCode::Char(other),
425 };
426 let k = KeyEvent::new(code, KeyModifiers::NONE);
427 if tx.send(InputEvent::Key(k)).is_err() {
428 return;
429 }
430 }
431 }
432 // Dispatch whatever non-paste event broke the burst.
433 if let Some(ev) = trailing {
434 let msg = match ev {
435 Event::Key(k) => {
436 crate::tuix_trace!("RD", "key {:?} {:?}", k.kind, k.code);
437 InputEvent::Key(k)
438 }
439 Event::Paste(p) => InputEvent::Paste(p),
440 Event::Resize(w, h) => InputEvent::Resize(w, h),
441 Event::Mouse(m) => match mouse_input_event(m) {
442 Some(ev) => ev,
443 None => continue,
444 },
445 Event::FocusGained => {
446 atomcode_core::notify::set_terminal_focus_state(Some(true));
447 continue;
448 }
449 Event::FocusLost => {
450 atomcode_core::notify::set_terminal_focus_state(Some(false));
451 continue;
452 }
453 };
454 if tx.send(msg).is_err() {
455 return;
456 }
457 }
458 continue;
459 }
460
461 let msg = match ev {
462 Event::Key(k) => {
463 crate::tuix_trace!("RD", "key {:?} {:?}", k.kind, k.code);
464 InputEvent::Key(k)
465 }
466 Event::Paste(p) => {
467 crate::tuix_trace!("RD", "paste len={}", p.len());
468 InputEvent::Paste(p)
469 }
470 Event::Resize(w, h) => {
471 crate::tuix_trace!("RD", "resize {}x{}", w, h);
472 InputEvent::Resize(w, h)
473 }
474 Event::Mouse(m) => match mouse_input_event(m) {
475 Some(ev) => ev,
476 None => continue,
477 },
478 Event::FocusGained => {
479 atomcode_core::notify::set_terminal_focus_state(Some(true));
480 continue;
481 }
482 Event::FocusLost => {
483 atomcode_core::notify::set_terminal_focus_state(Some(false));
484 continue;
485 }
486 };
487 if tx.send(msg).is_err() {
488 return;
489 }
490 }
491}
492
493fn mouse_input_event(m: crossterm::event::MouseEvent) -> Option<InputEvent> {
494 // Trace EVERY arrival, regardless of kind. The kind-specific arms
495 // below only log scroll/down/drag/up; on Windows conhost a wheel
496 // tick can arrive as `Moved` or another variant we silently drop,
497 // and without this top-of-function trace there's no way to tell
498 // "no mouse events arriving" from "events arriving but ignored".
499 crate::tuix_trace!("RD", "mouse kind={:?} col={} row={}", m.kind, m.column, m.row);
500 match m.kind {
501 crossterm::event::MouseEventKind::ScrollUp => {
502 crate::tuix_trace!("RD", "mouse scroll up");
503 Some(InputEvent::MouseScroll(-3))
504 }
505 crossterm::event::MouseEventKind::ScrollDown => {
506 crate::tuix_trace!("RD", "mouse scroll down");
507 Some(InputEvent::MouseScroll(3))
508 }
509 crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
510 Some(InputEvent::MouseDown {
511 col: m.column,
512 row: m.row,
513 })
514 }
515 crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
516 Some(InputEvent::MouseDrag {
517 col: m.column,
518 row: m.row,
519 })
520 }
521 crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
522 Some(InputEvent::MouseUp)
523 }
524 _ => None,
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 /// Pause/Resume round trip without touching crossterm — feeds commands
533 /// directly into the `run` worker via an in-memory channel pair. This
534 /// exercises the paused-state ACK path that the OAuth flow depends on
535 /// without needing a real TTY.
536 #[test]
537 fn pause_acks_then_resume_wakes() {
538 let (tx, _rx) = mpsc::unbounded_channel::<InputEvent>();
539 let (cmd_tx, cmd_rx) = stdmpsc::channel();
540 let worker = std::thread::spawn(move || run(tx, cmd_rx));
541
542 // Send Pause and wait for ack.
543 let (ack_tx, ack_rx) = stdmpsc::channel();
544 cmd_tx
545 .send((ReaderCommand::Pause, Some(ack_tx)))
546 .expect("send pause");
547 ack_rx
548 .recv_timeout(Duration::from_secs(2))
549 .expect("pause ACK arrives within 2s");
550
551 // Resend Pause — already paused, the worker must still ACK so
552 // callers don't deadlock on a re-entrant pause.
553 let (ack_tx2, ack_rx2) = stdmpsc::channel();
554 cmd_tx
555 .send((ReaderCommand::Pause, Some(ack_tx2)))
556 .expect("send second pause");
557 ack_rx2
558 .recv_timeout(Duration::from_secs(2))
559 .expect("re-entrant pause also ACKs");
560
561 // Resume — should unblock the worker's recv loop.
562 cmd_tx
563 .send((ReaderCommand::Resume, None))
564 .expect("send resume");
565
566 // Shutdown so the thread exits and the test doesn't leak.
567 cmd_tx
568 .send((ReaderCommand::Shutdown, None))
569 .expect("send shutdown");
570 worker.join().expect("worker thread joins cleanly");
571 }
572
573 /// `MODIFIER_ENTER_DEDUP` must sit above OS autorepeat cadence but
574 /// well below any realistic human chord rate. macOS / Linux autorepeat
575 /// ticks every ~30 ms; the next intentional Shift+Enter can't physically
576 /// happen faster than ~100 ms. 40 ms lands cleanly between the two.
577 #[test]
578 fn modifier_enter_dedup_window_brackets_autorepeat_but_not_humans() {
579 let win = MODIFIER_ENTER_DEDUP.as_millis() as u64;
580 assert!(
581 win > 30,
582 "dedup window {}ms must exceed typical OS autorepeat (30ms) \
583 so autorepeat duplicates are caught",
584 win
585 );
586 assert!(
587 win < 80,
588 "dedup window {}ms must stay below fastest realistic human \
589 chord repeat (~100ms) so intentional Shift+Enter×2 still works",
590 win
591 );
592 }
593
594 /// Shift+Enter must NOT qualify as a paste-burst char. If it did,
595 /// the single-event else-branch of the burst path reconstructs the
596 /// KeyEvent with `KeyModifiers::NONE`, stripping SHIFT, and
597 /// `key_action::classify` collapses the result to `Submit` instead
598 /// of `InsertNewline` — i.e. Shift+Enter silently sends the message.
599 #[test]
600 fn paste_candidate_rejects_shift_enter() {
601 let ev = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
602 assert_eq!(
603 paste_candidate_char(&ev),
604 None,
605 "Shift+Enter is a command (InsertNewline), not paste content"
606 );
607 }
608
609 /// Plain Enter must still flow through the paste-burst path so
610 /// multi-line pastes on terminals without bracketed paste (Windows
611 /// conhost) still aggregate into a single Paste event.
612 #[test]
613 fn paste_candidate_accepts_plain_enter() {
614 let ev = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
615 assert_eq!(paste_candidate_char(&ev), Some('\n'));
616 }
617
618 /// Regression: two Enters left in the tty input queue at startup
619 /// (e.g. user mashed Enter while waiting for `cargo build` to
620 /// finish before atomcode took over) used to aggregate into a
621 /// synthetic `Paste("\n\n")` and insert two blank lines into the
622 /// input box on launch. Pure-newline bursts must NOT count as paste.
623 #[test]
624 fn pure_newline_burst_is_not_paste() {
625 assert!(!is_paste_burst(&['\n', '\n']));
626 assert!(!is_paste_burst(&['\n', '\n', '\n']));
627 }
628
629 /// Whitespace-only bursts (newline + space, newline + tab) likewise
630 /// fail the "real content" test — same root cause as the buffered-
631 /// Enter case, just with adjacent whitespace instead.
632 #[test]
633 fn whitespace_only_burst_is_not_paste() {
634 assert!(!is_paste_burst(&[' ', '\n']));
635 assert!(!is_paste_burst(&['\t', '\n']));
636 assert!(!is_paste_burst(&['\n', ' ', '\t', '\n']));
637 }
638
639 /// Real multi-line paste (text + embedded newline) must still be
640 /// recognised — that's the entire reason the burst path exists for
641 /// terminals without bracketed paste.
642 #[test]
643 fn text_with_newline_burst_is_paste() {
644 assert!(is_paste_burst(&['h', 'i', '\n']));
645 assert!(is_paste_burst(&['\n', 'h', 'i']));
646 assert!(is_paste_burst(&['l', 'i', 'n', 'e', '1', '\n', 'l', 'i', 'n', 'e', '2']));
647 }
648
649 /// Bursts without any newline fall through to per-key handling
650 /// regardless of length — just fast typing, not a paste signal.
651 #[test]
652 fn no_newline_burst_is_not_paste() {
653 assert!(!is_paste_burst(&['a', 'b', 'c', 'd']));
654 }
655
656 /// Regression: JediTerm IME on Windows commits each Pinyin candidate
657 /// as `<char> + Enter`, producing bursts of single-char-per-line.
658 /// Old heuristic accepted these as pastes; the buffer ended up with
659 /// `\n` between every CJK char and the input row showed `首↵页↵中↵…`.
660 /// New rule: 3+ lines averaging ≤1 non-newline char per line is the
661 /// IME pattern, not a paste.
662 #[test]
663 fn ime_commit_storm_is_not_paste() {
664 // Real-world reproduction from the user screenshot: typing
665 // `首页中的` via IME emits `首 \n 页 \n 中 \n 的 \n`.
666 assert!(!is_paste_burst(&['首', '\n', '页', '\n', '中', '\n', '的', '\n']));
667 // Bare CJK without trailing newline — same shape, also rejected.
668 assert!(!is_paste_burst(&['首', '\n', '页', '\n', '中']));
669 // ASCII char-per-line bursts also caught (rare keyboard
670 // remapping but same root cause — phantom Enter between chars).
671 assert!(!is_paste_burst(&['a', '\n', 'b', '\n', 'c', '\n']));
672 }
673
674 /// 2-line pastes with two short lines must still flow through the
675 /// paste path — the IME-rejection threshold is gated on 3+ lines so
676 /// legitimate short pastes aren't caught as collateral.
677 #[test]
678 fn two_line_short_paste_still_recognised() {
679 assert!(is_paste_burst(&['a', '\n', 'b']));
680 }
681
682 /// Multi-line paste with substantial text per line stays a paste
683 /// even when CJK is involved — char-per-line check counts NON-newline
684 /// chars, so `你好世界 \n 再见` (7 non-newline + 1 newline = 2 lines,
685 /// avg 3.5/line) sails through.
686 #[test]
687 fn cjk_multi_line_paste_still_recognised() {
688 assert!(is_paste_burst(&['你', '好', '世', '界', '\n', '再', '见']));
689 }
690
691 /// Singleton "bursts" are never pastes; aggregation requires ≥ 2.
692 #[test]
693 fn singleton_burst_is_not_paste() {
694 assert!(!is_paste_burst(&['\n']));
695 assert!(!is_paste_burst(&['x']));
696 assert!(!is_paste_burst(&[]));
697 }
698
699 /// Regression for the Windows-resize crash. `crossterm::event::poll`
700 /// has been observed to return `Err` during terminal resize on
701 /// Windows; the original loop `return`'d on Err, which killed the
702 /// reader thread and collapsed the event loop ("atomcode exits
703 /// when I resize on Windows"). `classify_poll` must classify
704 /// `Err` as `Sleep` (loop again after a short delay), never `Exit`.
705 #[test]
706 fn classify_poll_err_is_sleep_not_exit() {
707 // Real error construction — ErrorKind doesn't matter, the
708 // classifier treats all Err the same.
709 let boom = std::io::Error::new(std::io::ErrorKind::Other, "resize glitch");
710 assert_eq!(classify_poll(Err(boom), false), PollAction::Sleep);
711 let boom = std::io::Error::new(std::io::ErrorKind::Other, "another glitch");
712 assert_eq!(
713 classify_poll(Err(boom), true),
714 PollAction::Sleep,
715 "Err must NOT be Exit even when tx is closed — exit path \
716 is only for clean shutdown via Ok(false) + closed tx"
717 );
718 }
719
720 /// The three `Ok` branches must classify exactly one action each,
721 /// and `Ok(false)` splits on `tx_closed` (the only place the
722 /// reader self-terminates in the happy path).
723 #[test]
724 fn classify_poll_ok_branches() {
725 assert_eq!(classify_poll(Ok(true), false), PollAction::Read);
726 assert_eq!(
727 classify_poll(Ok(true), true),
728 PollAction::Read,
729 "Ok(true) always reads — caller will notice tx closed on send"
730 );
731 assert_eq!(classify_poll(Ok(false), false), PollAction::Continue);
732 assert_eq!(classify_poll(Ok(false), true), PollAction::Exit);
733 }
734
735 /// Dropping the sender side must terminate the worker even while paused.
736 /// Without this the event-loop shutdown path would leak the thread on
737 /// any session that ever called Pause.
738 #[test]
739 fn paused_worker_exits_on_sender_drop() {
740 let (tx, _rx) = mpsc::unbounded_channel::<InputEvent>();
741 let (cmd_tx, cmd_rx) = stdmpsc::channel();
742 let worker = std::thread::spawn(move || run(tx, cmd_rx));
743
744 let (ack_tx, ack_rx) = stdmpsc::channel();
745 cmd_tx
746 .send((ReaderCommand::Pause, Some(ack_tx)))
747 .expect("send pause");
748 ack_rx
749 .recv_timeout(Duration::from_secs(2))
750 .expect("pause ACK");
751
752 drop(cmd_tx); // Err on next recv → exit
753 worker
754 .join()
755 .expect("paused worker joins after sender drop");
756 }
757}