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("visible-reasoning") => SlashCmd::VisibleReasoning(arg),
128        Some("safety") => match arg.as_deref() {
129            None => SlashCmd::Safety(None),
130            // Invalid value ⇒ `None` ⇒ the reducer shows current + options.
131            Some(mode) => SlashCmd::Safety(crate::runtime::SafetyMode::parse(&mode.to_lowercase())),
132        },
133        Some("clear") => SlashCmd::Clear,
134        Some("save") => SlashCmd::Save(arg),
135        Some("load") => SlashCmd::Load(arg),
136        Some("list") => SlashCmd::List,
137        Some("usage") => SlashCmd::Usage,
138        Some("context") => SlashCmd::Context,
139        Some("compact") => SlashCmd::Compact(arg),
140        Some("doctor") => SlashCmd::Doctor,
141        Some("tasks") => SlashCmd::Tasks,
142        Some("task") => SlashCmd::Task(arg),
143        Some("pause") => SlashCmd::Pause(arg),
144        Some("resume") => SlashCmd::Resume(arg),
145        Some("cancel") => SlashCmd::Cancel(arg),
146        Some("handoff") => SlashCmd::Handoff(arg),
147        Some("report") => SlashCmd::Report(arg),
148        Some("processes") => SlashCmd::Processes,
149        Some("logs") => SlashCmd::Logs(arg),
150        Some("stop") => SlashCmd::Stop(arg),
151        Some("restart") => SlashCmd::Restart(arg),
152        Some("open") => SlashCmd::Open(arg),
153        Some("ports") => SlashCmd::Ports,
154        Some("approvals") => SlashCmd::Approvals,
155        Some("approve") => SlashCmd::Approve(arg),
156        Some("deny") => SlashCmd::Deny(arg),
157        Some("checkpoint") => SlashCmd::Checkpoint(arg),
158        Some("checkpoints") => SlashCmd::Checkpoints,
159        Some("restore") => SlashCmd::Restore(arg),
160        Some("memory") => match arg {
161            Some(input) if input.starts_with("edit ") => {
162                SlashCmd::MemoryEdit(Some(input["edit ".len()..].trim().to_string()))
163            },
164            Some(_) | None => SlashCmd::Memory,
165        },
166        Some("remember") => SlashCmd::Remember(arg),
167        Some("forget") => SlashCmd::Forget(arg),
168        Some("plugins") => SlashCmd::Plugins,
169        Some("model-info") => SlashCmd::ModelInfo(arg),
170        Some("cloud-setup") => SlashCmd::CloudSetup,
171        Some("help") => SlashCmd::Help,
172        Some("quit") => SlashCmd::Quit,
173        _ => SlashCmd::Unknown(name),
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::domain::SlashCmd;
181
182    #[test]
183    fn translates_printable_char_key() {
184        let ev = CtEvent::Key(crossterm::event::KeyEvent {
185            code: CtKeyCode::Char('a'),
186            modifiers: CtMods::NONE,
187            kind: KeyEventKind::Press,
188            state: crossterm::event::KeyEventState::NONE,
189        });
190        let msg = event_to_msg(ev).expect("msg");
191        match msg {
192            Msg::Key(k) => {
193                assert_eq!(k.code, KeyCode::Char('a'));
194                assert!(k.modifiers.is_empty());
195            },
196            _ => panic!("wrong variant"),
197        }
198    }
199
200    #[test]
201    fn translates_ctrl_c() {
202        let ev = CtEvent::Key(crossterm::event::KeyEvent {
203            code: CtKeyCode::Char('c'),
204            modifiers: CtMods::CONTROL,
205            kind: KeyEventKind::Press,
206            state: crossterm::event::KeyEventState::NONE,
207        });
208        let msg = event_to_msg(ev).expect("msg");
209        match msg {
210            Msg::Key(k) => {
211                assert_eq!(k.code, KeyCode::Char('c'));
212                assert!(k.modifiers.ctrl);
213                assert!(!k.modifiers.alt);
214            },
215            _ => panic!("wrong variant"),
216        }
217    }
218
219    #[test]
220    fn skips_release_events() {
221        let ev = CtEvent::Key(crossterm::event::KeyEvent {
222            code: CtKeyCode::Char('a'),
223            modifiers: CtMods::NONE,
224            kind: KeyEventKind::Release,
225            state: crossterm::event::KeyEventState::NONE,
226        });
227        assert!(event_to_msg(ev).is_none());
228    }
229
230    #[test]
231    fn resize_translates_to_resize_msg() {
232        let ev = CtEvent::Resize(80, 24);
233        let msg = event_to_msg(ev).expect("msg");
234        match msg {
235            Msg::Resize { width, height } => {
236                assert_eq!(width, 80);
237                assert_eq!(height, 24);
238            },
239            _ => panic!("wrong variant"),
240        }
241    }
242
243    #[test]
244    fn empty_paste_dropped() {
245        let ev = CtEvent::Paste(String::new());
246        assert!(event_to_msg(ev).is_none());
247    }
248
249    #[test]
250    fn paste_translates_to_text_paste() {
251        let ev = CtEvent::Paste("hello".to_string());
252        let msg = event_to_msg(ev).expect("msg");
253        match msg {
254            Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
255            _ => panic!("wrong variant"),
256        }
257    }
258
259    #[test]
260    fn parse_slash_model_no_arg() {
261        assert_eq!(parse_slash_command("model"), SlashCmd::Model(None));
262    }
263
264    #[test]
265    fn parse_slash_model_with_arg() {
266        assert_eq!(
267            parse_slash_command("model anthropic/opus"),
268            SlashCmd::Model(Some("anthropic/opus".to_string())),
269        );
270    }
271
272    #[test]
273    fn parse_slash_quit_alias_q() {
274        assert_eq!(parse_slash_command("q"), SlashCmd::Quit);
275    }
276
277    #[test]
278    fn parse_slash_usage_and_context() {
279        assert_eq!(parse_slash_command("usage"), SlashCmd::Usage);
280        assert_eq!(parse_slash_command("context"), SlashCmd::Context);
281        assert_eq!(parse_slash_command("doctor"), SlashCmd::Doctor);
282    }
283
284    #[test]
285    fn parse_slash_compact_and_aliases() {
286        assert_eq!(parse_slash_command("compact"), SlashCmd::Compact(None));
287        assert_eq!(
288            parse_slash_command("compact focus on tests"),
289            SlashCmd::Compact(Some("focus on tests".to_string()))
290        );
291        assert_eq!(parse_slash_command("compress"), SlashCmd::Compact(None));
292        assert_eq!(parse_slash_command("summarize"), SlashCmd::Compact(None));
293    }
294
295    #[test]
296    fn parse_runtime_task_commands() {
297        assert_eq!(parse_slash_command("tasks"), SlashCmd::Tasks);
298        assert_eq!(
299            parse_slash_command("task task-123"),
300            SlashCmd::Task(Some("task-123".to_string()))
301        );
302        assert_eq!(
303            parse_slash_command("pause task-123"),
304            SlashCmd::Pause(Some("task-123".to_string()))
305        );
306        assert_eq!(
307            parse_slash_command("resume task-123"),
308            SlashCmd::Resume(Some("task-123".to_string()))
309        );
310        assert_eq!(parse_slash_command("cancel"), SlashCmd::Cancel(None));
311        assert_eq!(
312            parse_slash_command("handoff task-123"),
313            SlashCmd::Handoff(Some("task-123".to_string()))
314        );
315        assert_eq!(parse_slash_command("report"), SlashCmd::Report(None));
316        assert_eq!(parse_slash_command("procs"), SlashCmd::Processes);
317        assert_eq!(parse_slash_command("approvals"), SlashCmd::Approvals);
318        assert_eq!(
319            parse_slash_command("approve approval-1"),
320            SlashCmd::Approve(Some("approval-1".to_string()))
321        );
322        assert_eq!(
323            parse_slash_command("deny approval-1"),
324            SlashCmd::Deny(Some("approval-1".to_string()))
325        );
326        assert_eq!(
327            parse_slash_command("checkpoint src/lib.rs"),
328            SlashCmd::Checkpoint(Some("src/lib.rs".to_string()))
329        );
330        assert_eq!(parse_slash_command("checkpoints"), SlashCmd::Checkpoints);
331        assert_eq!(
332            parse_slash_command("restore checkpoint-1"),
333            SlashCmd::Restore(Some("checkpoint-1".to_string()))
334        );
335        assert_eq!(parse_slash_command("memory"), SlashCmd::Memory);
336        assert_eq!(
337            parse_slash_command("remember decision use sqlite"),
338            SlashCmd::Remember(Some("decision use sqlite".to_string()))
339        );
340        assert_eq!(
341            parse_slash_command("forget memory-1"),
342            SlashCmd::Forget(Some("memory-1".to_string()))
343        );
344        assert_eq!(parse_slash_command("plugins"), SlashCmd::Plugins);
345    }
346
347    #[test]
348    fn parse_slash_reasoning_valid_level() {
349        assert_eq!(
350            parse_slash_command("reasoning high"),
351            SlashCmd::Reasoning(Some(crate::models::ReasoningLevel::High)),
352        );
353    }
354
355    #[test]
356    fn parse_slash_visible_reasoning_and_alias() {
357        assert_eq!(
358            parse_slash_command("visible-reasoning on"),
359            SlashCmd::VisibleReasoning(Some("on".to_string())),
360        );
361        assert_eq!(
362            parse_slash_command("visiblereasoning"),
363            SlashCmd::VisibleReasoning(None),
364        );
365    }
366
367    #[test]
368    fn parse_slash_reasoning_invalid_level_is_none_arg() {
369        // Argument exists but can't be parsed to a level — degrades
370        // to showing current (None arg) rather than erroring.
371        assert_eq!(
372            parse_slash_command("reasoning bogus"),
373            SlashCmd::Reasoning(None),
374        );
375    }
376
377    #[test]
378    fn parse_safety_command() {
379        assert_eq!(
380            parse_slash_command("safety auto"),
381            SlashCmd::Safety(Some(crate::runtime::SafetyMode::Auto)),
382        );
383        // `/permission` is an alias that routes to the same command.
384        assert_eq!(
385            parse_slash_command("permission read_only"),
386            SlashCmd::Safety(Some(crate::runtime::SafetyMode::ReadOnly)),
387        );
388        // No arg → show current; bogus value → None (show current + options).
389        assert_eq!(parse_slash_command("safety"), SlashCmd::Safety(None));
390        assert_eq!(parse_slash_command("safety bogus"), SlashCmd::Safety(None));
391    }
392
393    #[test]
394    fn parse_slash_unknown_command() {
395        match parse_slash_command("nope") {
396            SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
397            other => panic!("expected Unknown, got {:?}", other),
398        }
399    }
400
401    #[test]
402    fn key_mods_combine_correctly() {
403        let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
404        assert!(mods.ctrl);
405        assert!(mods.shift);
406        assert!(!mods.alt);
407    }
408}