Skip to main content

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}