Skip to main content

mermaid_cli/app/
event_source.rs

1//! Crossterm event stream → `Msg`.
2//!
3//! One of two branches in the main loop's central `select!`.
4//! Crossterm's `EventStream` yields key presses, mouse events,
5//! pastes, and resize notifications; we translate each into the
6//! typed `Msg` vocabulary the reducer understands.
7//!
8//! The event source knows nothing about state. The reducer owns
9//! the transitions; the event source just produces typed inputs.
10//!
11//! For `--replay`, a second event source (in `recorder.rs`) reads
12//! previously-recorded JSONL and yields the same Msg stream. The
13//! main loop can't tell live crossterm events apart from replayed
14//! ones — that's the point.
15
16use crossterm::event::{
17    Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind, KeyModifiers as CtMods,
18    MouseEventKind as CtMouseKind,
19};
20
21use crate::domain::{Key, KeyCode, KeyMods, Msg, Paste};
22
23/// Translate one crossterm event into `Msg`. Returns `None` for
24/// events the reducer doesn't care about (focus gained/lost, unknown
25/// media keys, key repeats, etc.).
26pub fn event_to_msg(event: CtEvent) -> Option<Msg> {
27    match event {
28        CtEvent::Key(key) => {
29            // Skip KeyEventKind::Release and ::Repeat — we only act on
30            // initial press. Release events fire twice as many Keys
31            // and bloat any recorded session.
32            if key.kind != KeyEventKind::Press {
33                return None;
34            }
35            Some(Msg::Key(Key {
36                code: translate_key_code(key.code)?,
37                modifiers: translate_mods(key.modifiers),
38            }))
39        },
40        CtEvent::Paste(text) => {
41            if text.is_empty() {
42                None
43            } else {
44                Some(Msg::Paste(Paste::Text(text)))
45            }
46        },
47        CtEvent::Mouse(mouse) => match mouse.kind {
48            // F13: wire mouse wheel scroll. `UI_MOUSE_SCROLL_LINES`
49            // sets the delta per wheel tick to match the READMEs
50            // "mouse wheel scrolls the chat" contract.
51            CtMouseKind::ScrollUp => Some(Msg::MouseScroll {
52                delta: crate::constants::UI_MOUSE_SCROLL_LINES as i16,
53            }),
54            CtMouseKind::ScrollDown => Some(Msg::MouseScroll {
55                delta: -(crate::constants::UI_MOUSE_SCROLL_LINES as i16),
56            }),
57            _ => None,
58        },
59        CtEvent::Resize(w, h) => Some(Msg::Resize {
60            width: w,
61            height: h,
62        }),
63        CtEvent::FocusGained | CtEvent::FocusLost => None,
64    }
65}
66
67fn translate_key_code(code: CtKeyCode) -> Option<KeyCode> {
68    Some(match code {
69        CtKeyCode::Char(c) => KeyCode::Char(c),
70        CtKeyCode::Enter => KeyCode::Enter,
71        CtKeyCode::Esc => KeyCode::Escape,
72        CtKeyCode::Backspace => KeyCode::Backspace,
73        CtKeyCode::Delete => KeyCode::Delete,
74        CtKeyCode::Tab => KeyCode::Tab,
75        CtKeyCode::BackTab => KeyCode::BackTab,
76        CtKeyCode::Left => KeyCode::Left,
77        CtKeyCode::Right => KeyCode::Right,
78        CtKeyCode::Up => KeyCode::Up,
79        CtKeyCode::Down => KeyCode::Down,
80        CtKeyCode::Home => KeyCode::Home,
81        CtKeyCode::End => KeyCode::End,
82        CtKeyCode::PageUp => KeyCode::PageUp,
83        CtKeyCode::PageDown => KeyCode::PageDown,
84        CtKeyCode::F(n) => KeyCode::F(n),
85        _ => return Some(KeyCode::Unknown),
86    })
87}
88
89fn translate_mods(mods: CtMods) -> KeyMods {
90    KeyMods {
91        ctrl: mods.contains(CtMods::CONTROL),
92        alt: mods.contains(CtMods::ALT),
93        shift: mods.contains(CtMods::SHIFT),
94    }
95}
96
97/// Parse a slash-command input line (without the leading `/`) into a
98/// `SlashCmd`. Returns `SlashCmd::Unknown` if the command isn't in
99/// the registry. Shared between the TUI dispatcher (C8) and any
100/// non-interactive command dispatch.
101pub fn parse_slash_command(raw: &str) -> crate::domain::SlashCmd {
102    use crate::domain::SlashCmd;
103    let trimmed = raw.trim();
104    let (name, arg) = match trimmed.split_once(' ') {
105        Some((n, a)) => (n.to_lowercase(), Some(a.trim().to_string())),
106        None => (trimmed.to_lowercase(), None),
107    };
108
109    // Route through the registry so command aliases (/q → /quit) work.
110    use crate::domain::slash_commands::COMMAND_REGISTRY;
111    let canonical = COMMAND_REGISTRY
112        .iter()
113        .find(|c| c.name == name.as_str() || c.aliases.contains(&name.as_str()))
114        .map(|c| c.name);
115
116    match canonical {
117        Some("model") => SlashCmd::Model(arg),
118        Some("reasoning") => match arg.as_deref() {
119            None => SlashCmd::Reasoning(None),
120            Some(level) => {
121                use clap::ValueEnum;
122                SlashCmd::Reasoning(
123                    crate::models::ReasoningLevel::from_str(&level.to_lowercase(), true).ok(),
124                )
125            },
126        },
127        Some("clear") => SlashCmd::Clear,
128        Some("save") => SlashCmd::Save(arg),
129        Some("load") => SlashCmd::Load(arg),
130        Some("list") => SlashCmd::List,
131        Some("usage") => SlashCmd::Usage,
132        Some("context") => SlashCmd::Context,
133        Some("compact") => SlashCmd::Compact(arg),
134        Some("cloud-setup") => SlashCmd::CloudSetup,
135        Some("help") => SlashCmd::Help,
136        Some("quit") => SlashCmd::Quit,
137        _ => SlashCmd::Unknown(name),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::domain::SlashCmd;
145
146    #[test]
147    fn translates_printable_char_key() {
148        let ev = CtEvent::Key(crossterm::event::KeyEvent {
149            code: CtKeyCode::Char('a'),
150            modifiers: CtMods::NONE,
151            kind: KeyEventKind::Press,
152            state: crossterm::event::KeyEventState::NONE,
153        });
154        let msg = event_to_msg(ev).expect("msg");
155        match msg {
156            Msg::Key(k) => {
157                assert_eq!(k.code, KeyCode::Char('a'));
158                assert!(k.modifiers.is_empty());
159            },
160            _ => panic!("wrong variant"),
161        }
162    }
163
164    #[test]
165    fn translates_ctrl_c() {
166        let ev = CtEvent::Key(crossterm::event::KeyEvent {
167            code: CtKeyCode::Char('c'),
168            modifiers: CtMods::CONTROL,
169            kind: KeyEventKind::Press,
170            state: crossterm::event::KeyEventState::NONE,
171        });
172        let msg = event_to_msg(ev).expect("msg");
173        match msg {
174            Msg::Key(k) => {
175                assert_eq!(k.code, KeyCode::Char('c'));
176                assert!(k.modifiers.ctrl);
177                assert!(!k.modifiers.alt);
178            },
179            _ => panic!("wrong variant"),
180        }
181    }
182
183    #[test]
184    fn skips_release_events() {
185        let ev = CtEvent::Key(crossterm::event::KeyEvent {
186            code: CtKeyCode::Char('a'),
187            modifiers: CtMods::NONE,
188            kind: KeyEventKind::Release,
189            state: crossterm::event::KeyEventState::NONE,
190        });
191        assert!(event_to_msg(ev).is_none());
192    }
193
194    #[test]
195    fn resize_translates_to_resize_msg() {
196        let ev = CtEvent::Resize(80, 24);
197        let msg = event_to_msg(ev).expect("msg");
198        match msg {
199            Msg::Resize { width, height } => {
200                assert_eq!(width, 80);
201                assert_eq!(height, 24);
202            },
203            _ => panic!("wrong variant"),
204        }
205    }
206
207    #[test]
208    fn empty_paste_dropped() {
209        let ev = CtEvent::Paste(String::new());
210        assert!(event_to_msg(ev).is_none());
211    }
212
213    #[test]
214    fn paste_translates_to_text_paste() {
215        let ev = CtEvent::Paste("hello".to_string());
216        let msg = event_to_msg(ev).expect("msg");
217        match msg {
218            Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
219            _ => panic!("wrong variant"),
220        }
221    }
222
223    #[test]
224    fn parse_slash_model_no_arg() {
225        assert_eq!(parse_slash_command("model"), SlashCmd::Model(None));
226    }
227
228    #[test]
229    fn parse_slash_model_with_arg() {
230        assert_eq!(
231            parse_slash_command("model anthropic/opus"),
232            SlashCmd::Model(Some("anthropic/opus".to_string())),
233        );
234    }
235
236    #[test]
237    fn parse_slash_quit_alias_q() {
238        assert_eq!(parse_slash_command("q"), SlashCmd::Quit);
239    }
240
241    #[test]
242    fn parse_slash_usage_and_context() {
243        assert_eq!(parse_slash_command("usage"), SlashCmd::Usage);
244        assert_eq!(parse_slash_command("context"), SlashCmd::Context);
245    }
246
247    #[test]
248    fn parse_slash_compact_and_aliases() {
249        assert_eq!(parse_slash_command("compact"), SlashCmd::Compact(None));
250        assert_eq!(
251            parse_slash_command("compact focus on tests"),
252            SlashCmd::Compact(Some("focus on tests".to_string()))
253        );
254        assert_eq!(parse_slash_command("compress"), SlashCmd::Compact(None));
255        assert_eq!(parse_slash_command("summarize"), SlashCmd::Compact(None));
256    }
257
258    #[test]
259    fn parse_slash_reasoning_valid_level() {
260        assert_eq!(
261            parse_slash_command("reasoning high"),
262            SlashCmd::Reasoning(Some(crate::models::ReasoningLevel::High)),
263        );
264    }
265
266    #[test]
267    fn parse_slash_reasoning_invalid_level_is_none_arg() {
268        // Argument exists but can't be parsed to a level — degrades
269        // to showing current (None arg) rather than erroring.
270        assert_eq!(
271            parse_slash_command("reasoning bogus"),
272            SlashCmd::Reasoning(None),
273        );
274    }
275
276    #[test]
277    fn parse_slash_unknown_command() {
278        match parse_slash_command("nope") {
279            SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
280            other => panic!("expected Unknown, got {:?}", other),
281        }
282    }
283
284    #[test]
285    fn key_mods_combine_correctly() {
286        let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
287        assert!(mods.ctrl);
288        assert!(mods.shift);
289        assert!(!mods.alt);
290    }
291}