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;
12
13use anyhow::{Context, Result};
14use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15
16/// Lookup contract: forward one tmux key-name to the named session.
17/// `key` is already encoded — see `encode_key` for the crossterm →
18/// tmux translation. Implementations must treat the call as
19/// fire-and-forget at the operator's typing rate; a per-call
20/// `tmux send-keys` round-trip is acceptable for v1 (the 50ms event
21/// poll already gates throughput).
22pub trait KeySender: Send + Sync {
23    fn send(&self, session: &str, key: &EncodedKey) -> Result<()>;
24
25    /// Forward one mouse-wheel tick to the named tmux session as a
26    /// terminal-history scroll. Implementations target the pane's
27    /// copy-mode scroll commands, so the agent's history surfaces the
28    /// same way `tmux attach` + wheel does — wheel-up auto-enters
29    /// copy-mode, subsequent ticks scroll the buffer. Wheel-down on a
30    /// pane not in copy-mode is a no-op (tmux's own behaviour).
31    fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()>;
32}
33
34/// Direction of one mouse-wheel tick. Maps to tmux copy-mode commands
35/// `scroll-up` / `scroll-down`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ScrollDirection {
38    Up,
39    Down,
40}
41
42/// One encoded keystroke ready for `tmux send-keys`. Carries the
43/// argument list so the prod impl can shell out without re-doing the
44/// translation, and so tests can inspect exactly what the encoder
45/// produced.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct EncodedKey {
48    /// Args appended after `tmux send-keys -t <session>`. Either a
49    /// single key-name (`"C-c"`, `"Enter"`) or, for printable chars,
50    /// `["-l", "<char>"]` so tmux treats the byte literally and
51    /// doesn't try to parse it as a key-name.
52    pub args: Vec<String>,
53}
54
55impl EncodedKey {
56    fn named(name: impl Into<String>) -> Self {
57        Self {
58            args: vec![name.into()],
59        }
60    }
61
62    fn literal(text: impl Into<String>) -> Self {
63        Self {
64            args: vec!["-l".into(), text.into()],
65        }
66    }
67}
68
69/// Translate a crossterm `KeyEvent` to the form `tmux send-keys`
70/// expects. Returns `None` for keys we deliberately drop (release
71/// events on kitty-protocol terminals, modifier-only presses).
72///
73/// Convention:
74/// - Plain printable chars → `-l <char>` (literal, sidesteps tmux's
75///   key-name parsing on tokens like `;` or `~`).
76/// - Modifier combos and named keys → tmux key-name form
77///   (`C-c`, `M-x`, `S-Tab`, `Enter`, `BSpace`, `Up`, `F4`, …).
78/// - Shift on a printable char is already reflected in the char
79///   itself (crossterm gives `Char('A')` for shift+a), so `S-` is
80///   only emitted for named keys (`S-Tab`, `S-Up`).
81pub fn encode_key(ev: KeyEvent) -> Option<EncodedKey> {
82    let ctrl = ev.modifiers.contains(KeyModifiers::CONTROL);
83    let alt = ev.modifiers.contains(KeyModifiers::ALT);
84    let shift = ev.modifiers.contains(KeyModifiers::SHIFT);
85
86    // Modifier prefix shared by named-key and ctrl/alt-char paths.
87    let prefix = match (ctrl, alt) {
88        (true, true) => "C-M-",
89        (true, false) => "C-",
90        (false, true) => "M-",
91        (false, false) => "",
92    };
93
94    match ev.code {
95        // Printable chars. Ctrl+letter / Alt+letter take the named
96        // form (`C-c`); everything else goes through `-l` literal so
97        // tmux doesn't reinterpret tokens like `~`, `:`.
98        KeyCode::Char(c) => {
99            if ctrl || alt {
100                // tmux wants Ctrl+letter chords lowercased: `C-c`,
101                // not `C-C`. Same convention for Alt.
102                let normalised = c.to_ascii_lowercase();
103                Some(EncodedKey::named(format!("{prefix}{normalised}")))
104            } else if c == ';' {
105                // tmux's command-list parser treats a bare `;` arg
106                // as the command separator, not as data — even
107                // under `-l` the arg is tokenised first, so a
108                // literal-mode `;` keystroke is silently dropped
109                // before it ever reaches the pane. Escape it as
110                // `\;` so the parser passes `;` through to the
111                // `-l` handler as data. (Reported by qa on PR
112                // #114 with a live repro: typing "off); buy" lost
113                // the `;`.)
114                Some(EncodedKey::literal("\\;".to_string()))
115            } else {
116                Some(EncodedKey::literal(c.to_string()))
117            }
118        }
119        // Named keys — modifier-prefixed form.
120        KeyCode::Enter => Some(EncodedKey::named(format!("{prefix}Enter"))),
121        KeyCode::Tab => {
122            // Shift+Tab uses tmux's BTab name. Otherwise the prefix
123            // covers C-/M- combos.
124            if shift && !ctrl && !alt {
125                Some(EncodedKey::named("BTab"))
126            } else {
127                Some(EncodedKey::named(format!("{prefix}Tab")))
128            }
129        }
130        KeyCode::BackTab => Some(EncodedKey::named("BTab")),
131        KeyCode::Backspace => Some(EncodedKey::named(format!("{prefix}BSpace"))),
132        KeyCode::Delete => Some(EncodedKey::named(format!("{prefix}DC"))),
133        KeyCode::Up => Some(EncodedKey::named(format!("{prefix}Up"))),
134        KeyCode::Down => Some(EncodedKey::named(format!("{prefix}Down"))),
135        KeyCode::Left => Some(EncodedKey::named(format!("{prefix}Left"))),
136        KeyCode::Right => Some(EncodedKey::named(format!("{prefix}Right"))),
137        KeyCode::Home => Some(EncodedKey::named(format!("{prefix}Home"))),
138        KeyCode::End => Some(EncodedKey::named(format!("{prefix}End"))),
139        KeyCode::PageUp => Some(EncodedKey::named(format!("{prefix}PPage"))),
140        KeyCode::PageDown => Some(EncodedKey::named(format!("{prefix}NPage"))),
141        KeyCode::Insert => Some(EncodedKey::named(format!("{prefix}IC"))),
142        KeyCode::F(n) if (1..=12).contains(&n) => Some(EncodedKey::named(format!("{prefix}F{n}"))),
143        // Esc is the stream-mode exit chord — handled at the dispatch
144        // layer before encoding, so reaching this arm means the
145        // operator fired a literal Esc inside some other path.
146        // Forward it as `Escape` for completeness.
147        KeyCode::Esc => Some(EncodedKey::named("Escape")),
148        // Modifier-only presses, media keys, kitty-protocol release
149        // events — drop silently.
150        _ => None,
151    }
152}
153
154/// Production implementation — shells out to `tmux send-keys`. Per-
155/// keystroke; v1 doesn't batch (the 50ms event poll already gates
156/// throughput, and per-call latency stays below typing speed).
157#[derive(Debug, Default, Clone, Copy)]
158pub struct TmuxKeySender;
159
160impl KeySender for TmuxKeySender {
161    fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
162        let mut cmd = Command::new("tmux");
163        cmd.args(["send-keys", "-t", session]);
164        for arg in &key.args {
165            cmd.arg(arg);
166        }
167        let output = cmd
168            .output()
169            .with_context(|| format!("invoke tmux send-keys -t {session}"))?;
170        // Non-zero exit (e.g. session vanished mid-stream) is logged
171        // by the absence of expected output in the next refresh
172        // tick; we don't want one bad frame to kill stream-mode.
173        let _ = output;
174        Ok(())
175    }
176
177    fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
178        // ScrollUp first runs `copy-mode -e` so wheel-up on an
179        // un-scrolled pane mirrors `tmux attach` + wheel: enter
180        // copy-mode and start scrolling. `-e` auto-exits copy-mode
181        // once the user scrolls back to the bottom, so wheel-down at
182        // the end of history drops the operator cleanly back into
183        // the live pane. Already-in-copy-mode is a harmless no-op for
184        // the second invocation.
185        if matches!(direction, ScrollDirection::Up) {
186            let _ = Command::new("tmux")
187                .args(["copy-mode", "-e", "-t", session])
188                .output()
189                .with_context(|| format!("invoke tmux copy-mode -e -t {session}"))?;
190        }
191        let cmd = match direction {
192            ScrollDirection::Up => "scroll-up",
193            ScrollDirection::Down => "scroll-down",
194        };
195        let _ = Command::new("tmux")
196            .args(["send-keys", "-t", session, "-X", cmd])
197            .output()
198            .with_context(|| format!("invoke tmux send-keys -t {session} -X {cmd}"))?;
199        Ok(())
200    }
201}
202
203/// Test fixtures. Made `pub` (rather than `#[cfg(test)]`) so the
204/// integration tests in `tests/` can reach them — same pattern as
205/// `compose::test_support` and `mailbox::test_support`.
206pub mod test_support {
207    use super::*;
208    use std::sync::Mutex;
209
210    /// Recording stub. Captures every `(session, encoded)` pair so
211    /// tests can assert which session was targeted with which key.
212    /// Separate `scroll_calls` log for mouse-wheel forwards so
213    /// keystroke and scroll surfaces stay independently inspectable.
214    #[derive(Default)]
215    pub struct MockKeySender {
216        pub calls: Mutex<Vec<(String, EncodedKey)>>,
217        pub scroll_calls: Mutex<Vec<(String, ScrollDirection)>>,
218    }
219
220    impl KeySender for MockKeySender {
221        fn send(&self, session: &str, key: &EncodedKey) -> Result<()> {
222            self.calls
223                .lock()
224                .unwrap()
225                .push((session.to_string(), key.clone()));
226            Ok(())
227        }
228
229        fn scroll(&self, session: &str, direction: ScrollDirection) -> Result<()> {
230            self.scroll_calls
231                .lock()
232                .unwrap()
233                .push((session.to_string(), direction));
234            Ok(())
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crossterm::event::{KeyEventKind, KeyEventState};
243
244    fn k(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
245        KeyEvent {
246            code,
247            modifiers: mods,
248            kind: KeyEventKind::Press,
249            state: KeyEventState::NONE,
250        }
251    }
252
253    #[test]
254    fn printable_char_uses_literal_form() {
255        let enc = encode_key(k(KeyCode::Char('a'), KeyModifiers::NONE)).unwrap();
256        assert_eq!(enc.args, vec!["-l".to_string(), "a".to_string()]);
257    }
258
259    #[test]
260    fn shifted_printable_char_keeps_literal_form() {
261        // crossterm pre-shifts the char; the encoder doesn't double
262        // up by also emitting `S-` for printables.
263        let enc = encode_key(k(KeyCode::Char('A'), KeyModifiers::SHIFT)).unwrap();
264        assert_eq!(enc.args, vec!["-l".to_string(), "A".to_string()]);
265    }
266
267    #[test]
268    fn punctuation_uses_literal_form() {
269        // `~` is the canonical example: tmux would read it as a
270        // key-name (`~`), literal mode forwards it as the typed
271        // character. Doesn't trigger the `;` escape path.
272        let enc = encode_key(k(KeyCode::Char('~'), KeyModifiers::NONE)).unwrap();
273        assert_eq!(enc.args, vec!["-l".to_string(), "~".to_string()]);
274    }
275
276    #[test]
277    fn semicolon_is_backslash_escaped_in_literal_form() {
278        // qa-found regression on PR #114: a bare `;` arg is consumed
279        // by tmux's own command-list parser as the command separator
280        // and never reaches the pane. Escaping it as `\;` survives
281        // the parse and lands in the pane as `;`. Pin both the
282        // exact arg shape and the path-of-arrival so a future
283        // refactor can't quietly drop the escape.
284        let enc = encode_key(k(KeyCode::Char(';'), KeyModifiers::NONE)).unwrap();
285        assert_eq!(
286            enc.args,
287            vec!["-l".to_string(), "\\;".to_string()],
288            "bare `;` must be sent as `\\;` so tmux's command parser \
289             doesn't eat it as a separator"
290        );
291    }
292
293    #[test]
294    fn ctrl_c_passes_through_as_named_chord() {
295        // Issue #108 explicitly requires Ctrl+C to forward to the
296        // agent (SIGINT), not be intercepted as a stream-mode exit.
297        let enc = encode_key(k(KeyCode::Char('c'), KeyModifiers::CONTROL)).unwrap();
298        assert_eq!(enc.args, vec!["C-c".to_string()]);
299    }
300
301    #[test]
302    fn ctrl_uppercase_normalises_to_lowercase() {
303        // Some terminals emit Ctrl+Shift+C as `Char('C')` + CONTROL;
304        // tmux wants `C-c`, not `C-C`.
305        let enc = encode_key(k(
306            KeyCode::Char('C'),
307            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
308        ))
309        .unwrap();
310        assert_eq!(enc.args, vec!["C-c".to_string()]);
311    }
312
313    #[test]
314    fn alt_char_uses_named_form() {
315        let enc = encode_key(k(KeyCode::Char('x'), KeyModifiers::ALT)).unwrap();
316        assert_eq!(enc.args, vec!["M-x".to_string()]);
317    }
318
319    #[test]
320    fn ctrl_alt_char_combines_prefixes() {
321        let enc = encode_key(k(
322            KeyCode::Char('a'),
323            KeyModifiers::CONTROL | KeyModifiers::ALT,
324        ))
325        .unwrap();
326        assert_eq!(enc.args, vec!["C-M-a".to_string()]);
327    }
328
329    #[test]
330    fn enter_named() {
331        let enc = encode_key(k(KeyCode::Enter, KeyModifiers::NONE)).unwrap();
332        assert_eq!(enc.args, vec!["Enter".to_string()]);
333    }
334
335    #[test]
336    fn backspace_named() {
337        let enc = encode_key(k(KeyCode::Backspace, KeyModifiers::NONE)).unwrap();
338        assert_eq!(enc.args, vec!["BSpace".to_string()]);
339    }
340
341    #[test]
342    fn arrows_named() {
343        for (code, name) in [
344            (KeyCode::Up, "Up"),
345            (KeyCode::Down, "Down"),
346            (KeyCode::Left, "Left"),
347            (KeyCode::Right, "Right"),
348        ] {
349            let enc = encode_key(k(code, KeyModifiers::NONE)).unwrap();
350            assert_eq!(enc.args, vec![name.to_string()], "encoding {code:?}");
351        }
352    }
353
354    #[test]
355    fn shift_tab_uses_btab() {
356        // tmux's name for Shift+Tab is `BTab`; it doesn't accept
357        // `S-Tab`. crossterm may deliver this as either Tab+SHIFT
358        // or BackTab — both routes need to reach `BTab`.
359        let from_tab = encode_key(k(KeyCode::Tab, KeyModifiers::SHIFT)).unwrap();
360        assert_eq!(from_tab.args, vec!["BTab".to_string()]);
361        let from_backtab = encode_key(k(KeyCode::BackTab, KeyModifiers::NONE)).unwrap();
362        assert_eq!(from_backtab.args, vec!["BTab".to_string()]);
363    }
364
365    #[test]
366    fn function_keys_named() {
367        let enc = encode_key(k(KeyCode::F(7), KeyModifiers::NONE)).unwrap();
368        assert_eq!(enc.args, vec!["F7".to_string()]);
369        let ctrl_f4 = encode_key(k(KeyCode::F(4), KeyModifiers::CONTROL)).unwrap();
370        assert_eq!(ctrl_f4.args, vec!["C-F4".to_string()]);
371    }
372
373    #[test]
374    fn page_keys_use_tmux_short_names() {
375        // tmux uses `PPage`/`NPage` for PageUp/PageDown.
376        assert_eq!(
377            encode_key(k(KeyCode::PageUp, KeyModifiers::NONE))
378                .unwrap()
379                .args,
380            vec!["PPage".to_string()]
381        );
382        assert_eq!(
383            encode_key(k(KeyCode::PageDown, KeyModifiers::NONE))
384                .unwrap()
385                .args,
386            vec!["NPage".to_string()]
387        );
388    }
389
390    #[test]
391    fn mock_records_session_and_key() {
392        use test_support::MockKeySender;
393        let mock = MockKeySender::default();
394        let enc = encode_key(k(KeyCode::Char('h'), KeyModifiers::NONE)).unwrap();
395        mock.send("t-p-a", &enc).unwrap();
396        let calls = mock.calls.lock().unwrap();
397        assert_eq!(calls.len(), 1);
398        assert_eq!(calls[0].0, "t-p-a");
399        assert_eq!(calls[0].1, enc);
400    }
401
402    #[test]
403    fn mock_records_scroll_session_and_direction() {
404        use test_support::MockKeySender;
405        let mock = MockKeySender::default();
406        mock.scroll("t-p-a", ScrollDirection::Up).unwrap();
407        mock.scroll("t-p-a", ScrollDirection::Down).unwrap();
408        let calls = mock.scroll_calls.lock().unwrap();
409        assert_eq!(
410            *calls,
411            vec![
412                ("t-p-a".to_string(), ScrollDirection::Up),
413                ("t-p-a".to_string(), ScrollDirection::Down),
414            ]
415        );
416    }
417}