Skip to main content

teamctl_ui/
keysender.rs

1//! Key forwarding — abstracts how the UI streams keystrokes into a
2//! tmux pane so tests can stub it out. Production hits
3//! `tmux send-keys`; tests pass a `MockKeySender` recording every
4//! call. Mirrors the trait + prod + mock shape `pane.rs` uses for
5//! capture so the two surfaces evolve together.
6//!
7//! Used by `Stage::StreamKeys` (the ticket-#108 modal): once stream
8//! mode is active, every operator keystroke that isn't `Esc` gets
9//! translated to a tmux key-name and shipped over.
10
11use std::process::Command;
12use std::sync::mpsc::{self, Receiver, Sender};
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17
18/// Lookup contract: forward one tmux key-name to the named session.
19/// `key` is already encoded — see `encode_key` for the crossterm →
20/// tmux translation. The production implementation (`TmuxKeySender`)
21/// blocks on a `tmux send-keys` round-trip; callers on a latency-
22/// sensitive thread should wrap it in `AsyncKeySender` so the round-
23/// trip happens off the caller's thread (#386).
24pub trait KeySender: Send + Sync {
25    fn send(&self, session: &str, key: &EncodedKey) -> Result<()>;
26
27    /// Forward one mouse-wheel tick to the named tmux session as a
28    /// terminal-history scroll. Implementations target the pane's
29    /// copy-mode scroll commands, so the agent's history surfaces the
30    /// same way `tmux attach` + wheel does — wheel-up auto-enters
31    /// copy-mode, subsequent ticks scroll the buffer. Wheel-down on a
32    /// pane not in copy-mode is a no-op (tmux's own behaviour).
33    fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()>;
34}
35
36/// Direction of one mouse-wheel tick. Maps to tmux copy-mode commands
37/// `scroll-up` / `scroll-down`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ScrollDirection {
40    Up,
41    Down,
42}
43
44/// One encoded keystroke ready for `tmux send-keys`. Carries the
45/// argument list so the prod impl can shell out without re-doing the
46/// translation, and so tests can inspect exactly what the encoder
47/// produced.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct EncodedKey {
50    /// Args appended after `tmux send-keys -t <session>`. Either a
51    /// single key-name (`"C-c"`, `"Enter"`) or, for printable chars,
52    /// `["-l", "<char>"]` so tmux treats the byte literally and
53    /// doesn't try to parse it as a key-name.
54    pub args: Vec<String>,
55}
56
57impl EncodedKey {
58    fn named(name: impl Into<String>) -> Self {
59        Self {
60            args: vec![name.into()],
61        }
62    }
63
64    fn literal(text: impl Into<String>) -> Self {
65        Self {
66            args: vec!["-l".into(), text.into()],
67        }
68    }
69}
70
71/// Translate a crossterm `KeyEvent` to the form `tmux send-keys`
72/// expects. Returns `None` for keys we deliberately drop (release
73/// events on kitty-protocol terminals, modifier-only presses).
74///
75/// Convention:
76/// - Plain printable chars → `-l <char>` (literal, sidesteps tmux's
77///   key-name parsing on tokens like `;` or `~`).
78/// - Modifier combos and named keys → tmux key-name form
79///   (`C-c`, `M-x`, `S-Tab`, `Enter`, `BSpace`, `Up`, `F4`, …).
80/// - Shift on a printable char is already reflected in the char
81///   itself (crossterm gives `Char('A')` for shift+a), so `S-` is
82///   only emitted for named keys (`S-Tab`, `S-Up`).
83pub fn encode_key(ev: KeyEvent) -> Option<EncodedKey> {
84    let ctrl = ev.modifiers.contains(KeyModifiers::CONTROL);
85    let alt = ev.modifiers.contains(KeyModifiers::ALT);
86    let shift = ev.modifiers.contains(KeyModifiers::SHIFT);
87
88    // Modifier prefix shared by named-key and ctrl/alt-char paths.
89    let prefix = match (ctrl, alt) {
90        (true, true) => "C-M-",
91        (true, false) => "C-",
92        (false, true) => "M-",
93        (false, false) => "",
94    };
95
96    match ev.code {
97        // Printable chars. Ctrl+letter / Alt+letter take the named
98        // form (`C-c`); everything else goes through `-l` literal so
99        // tmux doesn't reinterpret tokens like `~`, `:`.
100        KeyCode::Char(c) => {
101            if ctrl || alt {
102                // tmux wants Ctrl+letter chords lowercased: `C-c`,
103                // not `C-C`. Same convention for Alt.
104                let normalised = c.to_ascii_lowercase();
105                Some(EncodedKey::named(format!("{prefix}{normalised}")))
106            } else if c == ';' {
107                // tmux's command-list parser treats a bare `;` arg
108                // as the command separator, not as data — even
109                // under `-l` the arg is tokenised first, so a
110                // literal-mode `;` keystroke is silently dropped
111                // before it ever reaches the pane. Escape it as
112                // `\;` so the parser passes `;` through to the
113                // `-l` handler as data. (Reported by qa on PR
114                // #114 with a live repro: typing "off); buy" lost
115                // the `;`.)
116                Some(EncodedKey::literal("\\;".to_string()))
117            } else {
118                Some(EncodedKey::literal(c.to_string()))
119            }
120        }
121        // Named keys — modifier-prefixed form.
122        KeyCode::Enter => Some(EncodedKey::named(format!("{prefix}Enter"))),
123        KeyCode::Tab => {
124            // Shift+Tab uses tmux's BTab name. Otherwise the prefix
125            // covers C-/M- combos.
126            if shift && !ctrl && !alt {
127                Some(EncodedKey::named("BTab"))
128            } else {
129                Some(EncodedKey::named(format!("{prefix}Tab")))
130            }
131        }
132        KeyCode::BackTab => Some(EncodedKey::named("BTab")),
133        KeyCode::Backspace => Some(EncodedKey::named(format!("{prefix}BSpace"))),
134        KeyCode::Delete => Some(EncodedKey::named(format!("{prefix}DC"))),
135        KeyCode::Up => Some(EncodedKey::named(format!("{prefix}Up"))),
136        KeyCode::Down => Some(EncodedKey::named(format!("{prefix}Down"))),
137        KeyCode::Left => Some(EncodedKey::named(format!("{prefix}Left"))),
138        KeyCode::Right => Some(EncodedKey::named(format!("{prefix}Right"))),
139        KeyCode::Home => Some(EncodedKey::named(format!("{prefix}Home"))),
140        KeyCode::End => Some(EncodedKey::named(format!("{prefix}End"))),
141        KeyCode::PageUp => Some(EncodedKey::named(format!("{prefix}PPage"))),
142        KeyCode::PageDown => Some(EncodedKey::named(format!("{prefix}NPage"))),
143        KeyCode::Insert => Some(EncodedKey::named(format!("{prefix}IC"))),
144        KeyCode::F(n) if (1..=12).contains(&n) => Some(EncodedKey::named(format!("{prefix}F{n}"))),
145        // Esc is the stream-mode exit chord — handled at the dispatch
146        // layer before encoding, so reaching this arm means the
147        // operator fired a literal Esc inside some other path.
148        // Forward it as `Escape` for completeness.
149        KeyCode::Esc => Some(EncodedKey::named("Escape")),
150        // Modifier-only presses, media keys, kitty-protocol release
151        // events — drop silently.
152        _ => None,
153    }
154}
155
156/// Production implementation — shells out to `tmux send-keys`, one
157/// subprocess per keystroke. The round-trip blocks the caller ~3ms
158/// (pure tmux client/server cost; the key delivery itself is free), so
159/// the TUI wraps this in `AsyncKeySender` to keep that block off its
160/// render/event loop (#386).
161#[derive(Debug, Default, Clone, Copy)]
162pub struct TmuxKeySender;
163
164impl KeySender for TmuxKeySender {
165    fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
166        let mut cmd = Command::new("tmux");
167        cmd.args(["send-keys", "-t", session]);
168        for arg in &key.args {
169            cmd.arg(arg);
170        }
171        let output = cmd
172            .output()
173            .with_context(|| format!("invoke tmux send-keys -t {session}"))?;
174        // Non-zero exit (e.g. session vanished mid-stream) is logged
175        // by the absence of expected output in the next refresh
176        // tick; we don't want one bad frame to kill stream-mode.
177        let _ = output;
178        Ok(())
179    }
180
181    fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
182        // ScrollUp first runs `copy-mode -e` so wheel-up on an
183        // un-scrolled pane mirrors `tmux attach` + wheel: enter
184        // copy-mode and start scrolling. `-e` auto-exits copy-mode
185        // once the user scrolls back to the bottom, so wheel-down at
186        // the end of history drops the operator cleanly back into
187        // the live pane. Already-in-copy-mode is a harmless no-op for
188        // the second invocation.
189        if matches!(direction, ScrollDirection::Up) {
190            let _ = Command::new("tmux")
191                .args(["copy-mode", "-e", "-t", session])
192                .output()
193                .with_context(|| format!("invoke tmux copy-mode -e -t {session}"))?;
194        }
195        let cmd = match direction {
196            ScrollDirection::Up => "scroll-up",
197            ScrollDirection::Down => "scroll-down",
198        };
199        let _ = Command::new("tmux")
200            .args(["send-keys", "-t", session, "-X", cmd])
201            .output()
202            .with_context(|| format!("invoke tmux send-keys -t {session} -X {cmd}"))?;
203        Ok(())
204    }
205}
206
207/// Forward through a shared `KeySender`. Lets a single sender be both
208/// moved onto `AsyncKeySender`'s worker thread and retained by the
209/// caller (tests inspect the inner mock after the worker drains).
210impl<T: KeySender + ?Sized> KeySender for std::sync::Arc<T> {
211    fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
212        (**self).send(session, key)
213    }
214
215    fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
216        (**self).scroll(session, direction)
217    }
218}
219
220/// One unit of work for the background sender thread. Mirrors the
221/// `KeySender` surface so the worker replays either a keystroke or a
222/// scroll tick against the inner (blocking) sender, in arrival order.
223enum KeyJob {
224    Send {
225        session: String,
226        key: EncodedKey,
227    },
228    Scroll {
229        session: String,
230        direction: ScrollDirection,
231    },
232}
233
234/// Non-blocking `KeySender` decorator — moves the blocking `tmux`
235/// round-trip off the caller's thread. `send`/`scroll` enqueue onto an
236/// unbounded channel and return immediately (sub-microsecond); a single
237/// background thread drains the channel in FIFO order and replays each
238/// job against the wrapped sender.
239///
240/// Why (#386): the TUI forwards each keystroke inline on its
241/// render/event loop (`app::run`). A per-key `tmux send-keys` blocks
242/// that loop ~3ms, and a keystroke that lands while the loop is mid
243/// `capture-pane` / refresh waits behind it — perceptible input lag.
244/// Handing the send to a background thread frees the loop to observe the
245/// next key and redraw. The channel is FIFO and single-consumer, so the
246/// pane still receives keys in the exact order and form the inline path
247/// produced — no batching, no reordering, so the #374 forwarding
248/// contract (Esc / Ctrl+C / arrows) is untouched.
249///
250/// Inner-sender contract: the wrapped `KeySender` must signal failures
251/// by returning `Err` (which the worker swallows and moves on, matching
252/// the inline path's best-effort `let _ = send(...)`), not by panicking.
253/// `TmuxKeySender` honours this — its `tmux` round-trip returns `Err` on
254/// any failure and never unwraps — so the worker thread is durable for
255/// the life of the app.
256pub struct AsyncKeySender {
257    tx: Option<Sender<KeyJob>>,
258    /// Worker sends `()` here once the queue is drained and closed. Drop
259    /// waits on it (bounded) so a normal quit flushes pending keys
260    /// without a stuck tmux being able to hang process exit. The `Mutex`
261    /// is only to satisfy the `Sync` bound on `KeySender` (`Receiver` is
262    /// `Send` but not `Sync`); it's touched solely from `Drop`, never on
263    /// the send hot path.
264    done: std::sync::Mutex<Receiver<()>>,
265}
266
267/// Upper bound on how long `Drop` waits for the worker to flush its
268/// queue. Comfortably covers a normal quit (the queue is near-empty —
269/// the worker drains each key in ~3ms), but caps the wait so a wedged
270/// `tmux` round-trip can't hang the terminal on exit.
271const DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
272
273impl AsyncKeySender {
274    /// Wrap a blocking sender and spawn its drain thread. `inner` is
275    /// moved onto the worker, which runs every job to completion in
276    /// arrival order. The thread lives until this `AsyncKeySender` is
277    /// dropped.
278    pub fn new<K: KeySender + 'static>(inner: K) -> Self {
279        let (tx, rx) = mpsc::channel::<KeyJob>();
280        let (done_tx, done_rx) = mpsc::channel::<()>();
281        std::thread::spawn(move || {
282            // `recv` blocks until a job arrives; the loop ends only once
283            // the `Sender` is dropped (channel closed) AND the queue is
284            // drained, so a buffered burst always flushes before exit.
285            while let Ok(job) = rx.recv() {
286                match job {
287                    KeyJob::Send { session, key } => {
288                        let _ = inner.send(&session, &key);
289                    }
290                    KeyJob::Scroll { session, direction } => {
291                        let _ = inner.scroll(&session, direction);
292                    }
293                }
294            }
295            // Drained + closed — let `Drop` stop waiting. A dropped
296            // receiver (sender already gone) makes this a harmless no-op.
297            let _ = done_tx.send(());
298        });
299        Self {
300            tx: Some(tx),
301            done: std::sync::Mutex::new(done_rx),
302        }
303    }
304}
305
306impl KeySender for AsyncKeySender {
307    fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
308        if let Some(tx) = &self.tx {
309            // A closed channel (worker gone) is silent — same best-effort
310            // contract as a dropped tmux round-trip in the inline path.
311            let _ = tx.send(KeyJob::Send {
312                session: session.to_string(),
313                key: key.clone(),
314            });
315        }
316        Ok(())
317    }
318
319    fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
320        if let Some(tx) = &self.tx {
321            let _ = tx.send(KeyJob::Scroll {
322                session: session.to_string(),
323                direction,
324            });
325        }
326        Ok(())
327    }
328}
329
330impl Drop for AsyncKeySender {
331    fn drop(&mut self) {
332        // Close the channel so the worker's `recv` loop ends once the
333        // queue drains, then wait (bounded) for it to signal completion,
334        // so a fast quit still flushes the last few keys the operator
335        // typed. The `DRAIN_TIMEOUT` cap means a wedged `tmux` round-trip
336        // can't hang the terminal on exit — on timeout we abandon the
337        // (detached) worker and let the OS reap it at process exit.
338        self.tx.take();
339        if let Ok(done) = self.done.get_mut() {
340            let _ = done.recv_timeout(DRAIN_TIMEOUT);
341        }
342    }
343}
344
345/// Test fixtures. Made `pub` (rather than `#[cfg(test)]`) so the
346/// integration tests in `tests/` can reach them — same pattern as
347/// `compose::test_support` and `mailbox::test_support`.
348pub mod test_support {
349    use super::*;
350    use std::sync::Mutex;
351
352    /// Recording stub. Captures every `(session, encoded)` pair so
353    /// tests can assert which session was targeted with which key.
354    /// Separate `scroll_calls` log for mouse-wheel forwards so
355    /// keystroke and scroll surfaces stay independently inspectable.
356    #[derive(Default)]
357    pub struct MockKeySender {
358        pub calls: Mutex<Vec<(String, EncodedKey)>>,
359        pub scroll_calls: Mutex<Vec<(String, ScrollDirection)>>,
360    }
361
362    impl KeySender for MockKeySender {
363        fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
364            self.calls
365                .lock()
366                .unwrap()
367                .push((session.to_string(), key.clone()));
368            Ok(())
369        }
370
371        fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
372            self.scroll_calls
373                .lock()
374                .unwrap()
375                .push((session.to_string(), direction));
376            Ok(())
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crossterm::event::{KeyEventKind, KeyEventState};
385
386    fn k(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
387        KeyEvent {
388            code,
389            modifiers: mods,
390            kind: KeyEventKind::Press,
391            state: KeyEventState::NONE,
392        }
393    }
394
395    #[test]
396    fn printable_char_uses_literal_form() {
397        let enc = encode_key(k(KeyCode::Char('a'), KeyModifiers::NONE)).unwrap();
398        assert_eq!(enc.args, vec!["-l".to_string(), "a".to_string()]);
399    }
400
401    #[test]
402    fn shifted_printable_char_keeps_literal_form() {
403        // crossterm pre-shifts the char; the encoder doesn't double
404        // up by also emitting `S-` for printables.
405        let enc = encode_key(k(KeyCode::Char('A'), KeyModifiers::SHIFT)).unwrap();
406        assert_eq!(enc.args, vec!["-l".to_string(), "A".to_string()]);
407    }
408
409    #[test]
410    fn punctuation_uses_literal_form() {
411        // `~` is the canonical example: tmux would read it as a
412        // key-name (`~`), literal mode forwards it as the typed
413        // character. Doesn't trigger the `;` escape path.
414        let enc = encode_key(k(KeyCode::Char('~'), KeyModifiers::NONE)).unwrap();
415        assert_eq!(enc.args, vec!["-l".to_string(), "~".to_string()]);
416    }
417
418    #[test]
419    fn semicolon_is_backslash_escaped_in_literal_form() {
420        // qa-found regression on PR #114: a bare `;` arg is consumed
421        // by tmux's own command-list parser as the command separator
422        // and never reaches the pane. Escaping it as `\;` survives
423        // the parse and lands in the pane as `;`. Pin both the
424        // exact arg shape and the path-of-arrival so a future
425        // refactor can't quietly drop the escape.
426        let enc = encode_key(k(KeyCode::Char(';'), KeyModifiers::NONE)).unwrap();
427        assert_eq!(
428            enc.args,
429            vec!["-l".to_string(), "\\;".to_string()],
430            "bare `;` must be sent as `\\;` so tmux's command parser \
431             doesn't eat it as a separator"
432        );
433    }
434
435    #[test]
436    fn ctrl_c_passes_through_as_named_chord() {
437        // Issue #108 explicitly requires Ctrl+C to forward to the
438        // agent (SIGINT), not be intercepted as a stream-mode exit.
439        let enc = encode_key(k(KeyCode::Char('c'), KeyModifiers::CONTROL)).unwrap();
440        assert_eq!(enc.args, vec!["C-c".to_string()]);
441    }
442
443    #[test]
444    fn ctrl_uppercase_normalises_to_lowercase() {
445        // Some terminals emit Ctrl+Shift+C as `Char('C')` + CONTROL;
446        // tmux wants `C-c`, not `C-C`.
447        let enc = encode_key(k(
448            KeyCode::Char('C'),
449            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
450        ))
451        .unwrap();
452        assert_eq!(enc.args, vec!["C-c".to_string()]);
453    }
454
455    #[test]
456    fn alt_char_uses_named_form() {
457        let enc = encode_key(k(KeyCode::Char('x'), KeyModifiers::ALT)).unwrap();
458        assert_eq!(enc.args, vec!["M-x".to_string()]);
459    }
460
461    #[test]
462    fn ctrl_alt_char_combines_prefixes() {
463        let enc = encode_key(k(
464            KeyCode::Char('a'),
465            KeyModifiers::CONTROL | KeyModifiers::ALT,
466        ))
467        .unwrap();
468        assert_eq!(enc.args, vec!["C-M-a".to_string()]);
469    }
470
471    #[test]
472    fn enter_named() {
473        let enc = encode_key(k(KeyCode::Enter, KeyModifiers::NONE)).unwrap();
474        assert_eq!(enc.args, vec!["Enter".to_string()]);
475    }
476
477    #[test]
478    fn backspace_named() {
479        let enc = encode_key(k(KeyCode::Backspace, KeyModifiers::NONE)).unwrap();
480        assert_eq!(enc.args, vec!["BSpace".to_string()]);
481    }
482
483    #[test]
484    fn arrows_named() {
485        for (code, name) in [
486            (KeyCode::Up, "Up"),
487            (KeyCode::Down, "Down"),
488            (KeyCode::Left, "Left"),
489            (KeyCode::Right, "Right"),
490        ] {
491            let enc = encode_key(k(code, KeyModifiers::NONE)).unwrap();
492            assert_eq!(enc.args, vec![name.to_string()], "encoding {code:?}");
493        }
494    }
495
496    #[test]
497    fn shift_tab_uses_btab() {
498        // tmux's name for Shift+Tab is `BTab`; it doesn't accept
499        // `S-Tab`. crossterm may deliver this as either Tab+SHIFT
500        // or BackTab — both routes need to reach `BTab`.
501        let from_tab = encode_key(k(KeyCode::Tab, KeyModifiers::SHIFT)).unwrap();
502        assert_eq!(from_tab.args, vec!["BTab".to_string()]);
503        let from_backtab = encode_key(k(KeyCode::BackTab, KeyModifiers::NONE)).unwrap();
504        assert_eq!(from_backtab.args, vec!["BTab".to_string()]);
505    }
506
507    #[test]
508    fn function_keys_named() {
509        let enc = encode_key(k(KeyCode::F(7), KeyModifiers::NONE)).unwrap();
510        assert_eq!(enc.args, vec!["F7".to_string()]);
511        let ctrl_f4 = encode_key(k(KeyCode::F(4), KeyModifiers::CONTROL)).unwrap();
512        assert_eq!(ctrl_f4.args, vec!["C-F4".to_string()]);
513    }
514
515    #[test]
516    fn page_keys_use_tmux_short_names() {
517        // tmux uses `PPage`/`NPage` for PageUp/PageDown.
518        assert_eq!(
519            encode_key(k(KeyCode::PageUp, KeyModifiers::NONE))
520                .unwrap()
521                .args,
522            vec!["PPage".to_string()]
523        );
524        assert_eq!(
525            encode_key(k(KeyCode::PageDown, KeyModifiers::NONE))
526                .unwrap()
527                .args,
528            vec!["NPage".to_string()]
529        );
530    }
531
532    #[test]
533    fn mock_records_session_and_key() {
534        use test_support::MockKeySender;
535        let mock = MockKeySender::default();
536        let enc = encode_key(k(KeyCode::Char('h'), KeyModifiers::NONE)).unwrap();
537        mock.send("t-p-a", &enc).unwrap();
538        let calls = mock.calls.lock().unwrap();
539        assert_eq!(calls.len(), 1);
540        assert_eq!(calls[0].0, "t-p-a");
541        assert_eq!(calls[0].1, enc);
542    }
543
544    #[test]
545    fn mock_records_scroll_session_and_direction() {
546        use test_support::MockKeySender;
547        let mock = MockKeySender::default();
548        mock.scroll("t-p-a", ScrollDirection::Up).unwrap();
549        mock.scroll("t-p-a", ScrollDirection::Down).unwrap();
550        let calls = mock.scroll_calls.lock().unwrap();
551        assert_eq!(
552            *calls,
553            vec![
554                ("t-p-a".to_string(), ScrollDirection::Up),
555                ("t-p-a".to_string(), ScrollDirection::Down),
556            ]
557        );
558    }
559
560    // `AsyncKeySender` decorator tests. The worker thread drains
561    // asynchronously, so the drop-join (`drop(acs)`) is the only
562    // synchronisation point that makes the recorded `calls` safe to
563    // read — every assertion below runs strictly AFTER the drop.
564
565    #[test]
566    fn async_preserves_send_order_and_flushes_on_drop() {
567        use test_support::MockKeySender;
568        // Shared so the worker owns one clone and the test inspects
569        // another after the worker drains.
570        let mock = std::sync::Arc::new(MockKeySender::default());
571        let session = "t-p-a";
572        let submitted = vec![
573            EncodedKey {
574                args: vec!["-l".into(), "a".into()],
575            },
576            EncodedKey {
577                args: vec!["-l".into(), "b".into()],
578            },
579            EncodedKey {
580                args: vec!["Escape".into()],
581            },
582            EncodedKey {
583                args: vec!["C-c".into()],
584            },
585        ];
586
587        let acs = AsyncKeySender::new(mock.clone());
588        for key in &submitted {
589            acs.send(session, key).unwrap();
590        }
591        // Drop joins the worker, which drains the channel before
592        // exiting — the flush-on-quit guarantee. Only now is `calls`
593        // race-free to read.
594        drop(acs);
595
596        let calls = mock.calls.lock().unwrap();
597        let expected: Vec<(String, EncodedKey)> = submitted
598            .into_iter()
599            .map(|key| (session.to_string(), key))
600            .collect();
601        assert_eq!(
602            *calls, expected,
603            "every send must land once, in submission order"
604        );
605    }
606
607    #[test]
608    fn async_drops_no_keys_under_a_burst() {
609        use test_support::MockKeySender;
610        let mock = std::sync::Arc::new(MockKeySender::default());
611        let key = EncodedKey {
612            args: vec!["-l".into(), "x".into()],
613        };
614
615        let acs = AsyncKeySender::new(mock.clone());
616        for _ in 0..100 {
617            acs.send("t-p-a", &key).unwrap();
618        }
619        drop(acs);
620
621        let calls = mock.calls.lock().unwrap();
622        assert_eq!(calls.len(), 100, "all 100 buffered sends must flush");
623        assert!(
624            calls.iter().all(|(s, k)| s == "t-p-a" && *k == key),
625            "no entry may be mangled or mis-routed"
626        );
627    }
628
629    #[test]
630    fn async_routes_scroll_through_the_same_channel_in_order() {
631        use test_support::MockKeySender;
632        let mock = std::sync::Arc::new(MockKeySender::default());
633        let session = "t-p-a";
634        let key_a = EncodedKey {
635            args: vec!["-l".into(), "a".into()],
636        };
637        let key_b = EncodedKey {
638            args: vec!["-l".into(), "b".into()],
639        };
640
641        let acs = AsyncKeySender::new(mock.clone());
642        // Interleave sends and scrolls — both ride the one FIFO queue.
643        acs.send(session, &key_a).unwrap();
644        acs.scroll(session, ScrollDirection::Up).unwrap();
645        acs.send(session, &key_b).unwrap();
646        acs.scroll(session, ScrollDirection::Down).unwrap();
647        drop(acs);
648
649        let calls = mock.calls.lock().unwrap();
650        assert_eq!(
651            *calls,
652            vec![(session.to_string(), key_a), (session.to_string(), key_b),],
653            "sends preserve relative order across interleaved scrolls"
654        );
655        let scrolls = mock.scroll_calls.lock().unwrap();
656        assert_eq!(
657            *scrolls,
658            vec![
659                (session.to_string(), ScrollDirection::Up),
660                (session.to_string(), ScrollDirection::Down),
661            ],
662            "scrolls preserve relative order across interleaved sends"
663        );
664    }
665
666    #[test]
667    fn async_round_trips_args_byte_identically() {
668        use test_support::MockKeySender;
669        // The channel must move the already-encoded `args` through
670        // untouched. A mangled round-trip would silently break the
671        // `\;` escape (#114) or the named-key forwarding contract
672        // (#374), so pin both arg vectors exactly.
673        let mock = std::sync::Arc::new(MockKeySender::default());
674        let session = "t-p-a";
675        let escaped_semicolon = EncodedKey {
676            args: vec!["-l".into(), "\\;".into()],
677        };
678        let named_escape = EncodedKey {
679            args: vec!["Escape".into()],
680        };
681
682        let acs = AsyncKeySender::new(mock.clone());
683        acs.send(session, &escaped_semicolon).unwrap();
684        acs.send(session, &named_escape).unwrap();
685        drop(acs);
686
687        let calls = mock.calls.lock().unwrap();
688        assert_eq!(calls.len(), 2);
689        assert_eq!(
690            calls[0].1.args,
691            vec!["-l".to_string(), "\\;".to_string()],
692            "the `\\;` escape must survive the channel round-trip intact"
693        );
694        assert_eq!(
695            calls[1].1.args,
696            vec!["Escape".to_string()],
697            "a named key must survive the channel round-trip intact"
698        );
699    }
700}