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}