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 mermaid_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.).
26#[must_use]
27pub fn event_to_msg(event: CtEvent) -> Option<Msg> {
28    match event {
29        CtEvent::Key(key) => {
30            // Skip KeyEventKind::Release and ::Repeat — we only act on
31            // initial press. Release events fire twice as many Keys
32            // and bloat any recorded session.
33            if key.kind != KeyEventKind::Press {
34                return None;
35            }
36            Some(Msg::Key(Key {
37                code: translate_key_code(key.code)?,
38                modifiers: translate_mods(key.modifiers),
39            }))
40        },
41        CtEvent::Paste(text) => {
42            if text.is_empty() {
43                None
44            } else {
45                Some(Msg::Paste(Paste::Text(text)))
46            }
47        },
48        CtEvent::Mouse(mouse) => match mouse.kind {
49            // F13: wire mouse wheel scroll. `UI_MOUSE_SCROLL_LINES`
50            // sets the delta per wheel tick to match the READMEs
51            // "mouse wheel scrolls the chat" contract.
52            CtMouseKind::ScrollUp => Some(Msg::MouseScroll {
53                delta: mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16,
54            }),
55            CtMouseKind::ScrollDown => Some(Msg::MouseScroll {
56                delta: -(mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16),
57            }),
58            _ => None,
59        },
60        CtEvent::Resize(w, h) => Some(Msg::Resize {
61            width: w,
62            height: h,
63        }),
64        CtEvent::FocusGained => Some(Msg::FocusChanged(true)),
65        CtEvent::FocusLost => Some(Msg::FocusChanged(false)),
66    }
67}
68
69/// Coalesce a burst of character/Enter key presses into a single paste.
70///
71/// crossterm 0.29 does not emit `Event::Paste` on the Windows console backend
72/// (it only parses the bracketed-paste wrapper on Unix). There, a clipboard
73/// paste arrives as a flood of individual `Char`/`Enter` key events; fed
74/// one-by-one through the reducer that renders char-by-char and submits on
75/// every embedded newline. This collapses such a burst into one `Msg::Paste`
76/// so the text lands atomically with no spurious per-line submits — on every
77/// platform.
78///
79/// `first` is the event the main loop already pulled. `drain` yields each
80/// further *immediately-available* event and `None` once the input queue is
81/// momentarily empty (the burst is over). Returns the primary `Msg` plus any
82/// trailing events that were drained but aren't part of the burst and must be
83/// processed separately.
84///
85/// A lone keystroke (no burst) returns a normal `Msg::Key`, so Enter still
86/// submits and Ctrl+J still inserts a literal newline.
87pub fn coalesce_key_burst(
88    first: CtEvent,
89    mut drain: impl FnMut() -> Option<CtEvent>,
90) -> (Option<Msg>, Vec<Msg>) {
91    // Only an unmodified character/Enter press can start a paste burst.
92    // Anything else (arrows, Ctrl/Alt combos, mouse, resize) passes straight
93    // through with no draining.
94    let Some(first_char) = coalescible_char(&first) else {
95        return (event_to_msg(first), Vec::new());
96    };
97
98    let mut buf = String::new();
99    buf.push(first_char);
100    let mut trailing = Vec::new();
101
102    while let Some(evt) = drain() {
103        // Skip key release/repeat without ending the burst.
104        if let CtEvent::Key(k) = &evt
105            && k.kind != KeyEventKind::Press
106        {
107            continue;
108        }
109        match coalescible_char(&evt) {
110            Some(c) => buf.push(c),
111            None => {
112                // Not part of the paste — process it on its own next tick.
113                if let Some(m) = event_to_msg(evt) {
114                    trailing.push(m);
115                }
116                break;
117            },
118        }
119    }
120
121    if buf.chars().count() <= 1 {
122        // Single keystroke, not a paste: keep normal key semantics.
123        (event_to_msg(first), trailing)
124    } else {
125        (Some(Msg::Paste(Paste::Text(buf))), trailing)
126    }
127}
128
129/// The character a key press contributes to a coalesced paste, or `None` when
130/// the event isn't part of a paste burst. Only unmodified (no Ctrl/Alt)
131/// `Char` and `Enter` presses qualify; Enter maps to a newline.
132fn coalescible_char(event: &CtEvent) -> Option<char> {
133    let CtEvent::Key(key) = event else {
134        return None;
135    };
136    if key.kind != KeyEventKind::Press {
137        return None;
138    }
139    if key.modifiers.intersects(CtMods::CONTROL | CtMods::ALT) {
140        return None;
141    }
142    match key.code {
143        CtKeyCode::Char(c) => Some(c),
144        CtKeyCode::Enter => Some('\n'),
145        // Pasted tabs arrive as Tab key events on the Windows console; fold
146        // them into the burst so indented code survives a paste. A lone Tab
147        // (no burst) still falls through to the normal key path below.
148        CtKeyCode::Tab => Some('\t'),
149        _ => None,
150    }
151}
152
153fn translate_key_code(code: CtKeyCode) -> Option<KeyCode> {
154    Some(match code {
155        CtKeyCode::Char(c) => KeyCode::Char(c),
156        CtKeyCode::Enter => KeyCode::Enter,
157        CtKeyCode::Esc => KeyCode::Escape,
158        CtKeyCode::Backspace => KeyCode::Backspace,
159        CtKeyCode::Delete => KeyCode::Delete,
160        CtKeyCode::Tab => KeyCode::Tab,
161        CtKeyCode::BackTab => KeyCode::BackTab,
162        CtKeyCode::Left => KeyCode::Left,
163        CtKeyCode::Right => KeyCode::Right,
164        CtKeyCode::Up => KeyCode::Up,
165        CtKeyCode::Down => KeyCode::Down,
166        CtKeyCode::Home => KeyCode::Home,
167        CtKeyCode::End => KeyCode::End,
168        CtKeyCode::PageUp => KeyCode::PageUp,
169        CtKeyCode::PageDown => KeyCode::PageDown,
170        CtKeyCode::F(n) => KeyCode::F(n),
171        _ => return Some(KeyCode::Unknown),
172    })
173}
174
175fn translate_mods(mods: CtMods) -> KeyMods {
176    KeyMods {
177        ctrl: mods.contains(CtMods::CONTROL),
178        alt: mods.contains(CtMods::ALT),
179        shift: mods.contains(CtMods::SHIFT),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use mermaid_domain::SlashCmd;
187
188    #[test]
189    fn parses_theme_and_editor_commands() {
190        assert_eq!(
191            mermaid_domain::parse_slash_command("theme"),
192            SlashCmd::Theme(None)
193        );
194        assert_eq!(
195            mermaid_domain::parse_slash_command("theme light"),
196            SlashCmd::Theme(Some("light".to_string()))
197        );
198        assert_eq!(
199            mermaid_domain::parse_slash_command("editor"),
200            SlashCmd::Editor
201        );
202    }
203
204    #[test]
205    fn parses_agents_command_with_kill_tail() {
206        assert_eq!(
207            mermaid_domain::parse_slash_command("agents"),
208            SlashCmd::Agents(None)
209        );
210        assert_eq!(
211            mermaid_domain::parse_slash_command("agents kill a1"),
212            SlashCmd::Agents(Some("kill a1".to_string()))
213        );
214        assert_eq!(
215            mermaid_domain::parse_slash_command("agents kill all"),
216            SlashCmd::Agents(Some("kill all".to_string()))
217        );
218    }
219
220    #[test]
221    fn translates_printable_char_key() {
222        let ev = CtEvent::Key(crossterm::event::KeyEvent {
223            code: CtKeyCode::Char('a'),
224            modifiers: CtMods::NONE,
225            kind: KeyEventKind::Press,
226            state: crossterm::event::KeyEventState::NONE,
227        });
228        let msg = event_to_msg(ev).expect("msg");
229        match msg {
230            Msg::Key(k) => {
231                assert_eq!(k.code, KeyCode::Char('a'));
232                assert!(k.modifiers.is_empty());
233            },
234            _ => panic!("wrong variant"),
235        }
236    }
237
238    #[test]
239    fn translates_ctrl_c() {
240        let ev = CtEvent::Key(crossterm::event::KeyEvent {
241            code: CtKeyCode::Char('c'),
242            modifiers: CtMods::CONTROL,
243            kind: KeyEventKind::Press,
244            state: crossterm::event::KeyEventState::NONE,
245        });
246        let msg = event_to_msg(ev).expect("msg");
247        match msg {
248            Msg::Key(k) => {
249                assert_eq!(k.code, KeyCode::Char('c'));
250                assert!(k.modifiers.ctrl);
251                assert!(!k.modifiers.alt);
252                assert!(!k.modifiers.shift);
253            },
254            _ => panic!("wrong variant"),
255        }
256    }
257
258    /// With the kitty keyboard protocol negotiated, Ctrl+Shift+C arrives as a
259    /// distinct event; the SHIFT bit must survive translation so the reducer
260    /// can tell the copy chord apart from the quit chord.
261    #[test]
262    fn translates_ctrl_shift_c_with_shift_intact() {
263        let ev = CtEvent::Key(crossterm::event::KeyEvent {
264            code: CtKeyCode::Char('c'),
265            modifiers: CtMods::CONTROL | CtMods::SHIFT,
266            kind: KeyEventKind::Press,
267            state: crossterm::event::KeyEventState::NONE,
268        });
269        let msg = event_to_msg(ev).expect("msg");
270        match msg {
271            Msg::Key(k) => {
272                assert_eq!(k.code, KeyCode::Char('c'));
273                assert!(k.modifiers.ctrl);
274                assert!(k.modifiers.shift);
275            },
276            _ => panic!("wrong variant"),
277        }
278    }
279
280    #[test]
281    fn skips_release_events() {
282        let ev = CtEvent::Key(crossterm::event::KeyEvent {
283            code: CtKeyCode::Char('a'),
284            modifiers: CtMods::NONE,
285            kind: KeyEventKind::Release,
286            state: crossterm::event::KeyEventState::NONE,
287        });
288        assert!(event_to_msg(ev).is_none());
289    }
290
291    #[test]
292    fn resize_translates_to_resize_msg() {
293        let ev = CtEvent::Resize(80, 24);
294        let msg = event_to_msg(ev).expect("msg");
295        match msg {
296            Msg::Resize { width, height } => {
297                assert_eq!(width, 80);
298                assert_eq!(height, 24);
299            },
300            _ => panic!("wrong variant"),
301        }
302    }
303
304    #[test]
305    fn empty_paste_dropped() {
306        let ev = CtEvent::Paste(String::new());
307        assert!(event_to_msg(ev).is_none());
308    }
309
310    #[test]
311    fn paste_translates_to_text_paste() {
312        let ev = CtEvent::Paste("hello".to_string());
313        let msg = event_to_msg(ev).expect("msg");
314        match msg {
315            Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
316            _ => panic!("wrong variant"),
317        }
318    }
319
320    fn key(code: CtKeyCode) -> CtEvent {
321        CtEvent::Key(crossterm::event::KeyEvent {
322            code,
323            modifiers: CtMods::NONE,
324            kind: KeyEventKind::Press,
325            state: crossterm::event::KeyEventState::NONE,
326        })
327    }
328
329    fn key_with(code: CtKeyCode, modifiers: CtMods, kind: KeyEventKind) -> CtEvent {
330        CtEvent::Key(crossterm::event::KeyEvent {
331            code,
332            modifiers,
333            kind,
334            state: crossterm::event::KeyEventState::NONE,
335        })
336    }
337
338    #[test]
339    fn coalesce_single_char_stays_a_key() {
340        let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || None);
341        assert!(matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Char('a')));
342        assert!(trailing.is_empty());
343    }
344
345    #[test]
346    fn coalesce_lone_enter_still_submits_as_key() {
347        // A deliberate Enter (send) must NOT be turned into a paste.
348        let (primary, _) = coalesce_key_burst(key(CtKeyCode::Enter), || None);
349        assert!(
350            matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Enter),
351            "a lone Enter must remain a key, not become a paste"
352        );
353    }
354
355    #[test]
356    fn coalesce_burst_of_chars_becomes_one_paste() {
357        let mut rest = vec![key(CtKeyCode::Char('e')), key(CtKeyCode::Char('y'))].into_iter();
358        let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('h')), || rest.next());
359        match primary {
360            Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "hey"),
361            other => panic!("expected paste, got {other:?}"),
362        }
363        assert!(trailing.is_empty());
364    }
365
366    #[test]
367    fn coalesce_preserves_pasted_newlines_without_submitting() {
368        // The reported bug: each Enter in a paste submitted a line. The
369        // burst must collapse to one multi-line paste instead.
370        let mut rest = vec![key(CtKeyCode::Enter), key(CtKeyCode::Char('b'))].into_iter();
371        let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
372        match primary {
373            Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "a\nb"),
374            other => panic!("expected multi-line paste, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn coalesce_stops_at_non_char_and_enqueues_it() {
380        let mut rest = vec![key(CtKeyCode::Char('b')), key(CtKeyCode::Esc)].into_iter();
381        let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
382        assert!(matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "ab"));
383        assert_eq!(trailing.len(), 1);
384        assert!(matches!(trailing[0], Msg::Key(k) if k.code == KeyCode::Escape));
385    }
386
387    #[test]
388    fn coalesce_skips_release_events_mid_burst() {
389        let mut rest = vec![
390            key_with(CtKeyCode::Char('x'), CtMods::NONE, KeyEventKind::Release),
391            key(CtKeyCode::Char('b')),
392        ]
393        .into_iter();
394        let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
395        assert!(
396            matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "ab"),
397            "release events must be skipped, not appended or treated as burst-enders"
398        );
399    }
400
401    #[test]
402    fn coalesce_preserves_pasted_tabs() {
403        let mut rest = vec![key(CtKeyCode::Tab), key(CtKeyCode::Char('b'))].into_iter();
404        let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
405        match primary {
406            Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "a\tb"),
407            other => panic!("expected paste with tab, got {other:?}"),
408        }
409    }
410
411    #[test]
412    fn coalesce_lone_tab_stays_a_key() {
413        // A single Tab (palette completion etc.) must not become a paste.
414        let (primary, _) = coalesce_key_burst(key(CtKeyCode::Tab), || None);
415        assert!(matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Tab));
416    }
417
418    #[test]
419    fn coalesce_ctrl_combo_passes_through_without_draining() {
420        let drained = std::cell::Cell::new(false);
421        let (primary, trailing) = coalesce_key_burst(
422            key_with(CtKeyCode::Char('c'), CtMods::CONTROL, KeyEventKind::Press),
423            || {
424                drained.set(true);
425                None
426            },
427        );
428        assert!(
429            !drained.get(),
430            "a non-coalescible first event must not drain the queue"
431        );
432        assert!(
433            matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Char('c') && k.modifiers.ctrl)
434        );
435        assert!(trailing.is_empty());
436    }
437
438    #[test]
439    fn parse_slash_model_no_arg() {
440        assert_eq!(
441            mermaid_domain::parse_slash_command("model"),
442            SlashCmd::Model(None)
443        );
444    }
445
446    #[test]
447    fn parse_slash_model_with_arg() {
448        assert_eq!(
449            mermaid_domain::parse_slash_command("model anthropic/opus"),
450            SlashCmd::Model(Some("anthropic/opus".to_string())),
451        );
452    }
453
454    #[test]
455    fn parse_slash_quit_alias_q() {
456        assert_eq!(mermaid_domain::parse_slash_command("q"), SlashCmd::Quit);
457    }
458
459    #[test]
460    fn parse_slash_usage_and_context() {
461        use mermaid_domain::ContextCmd;
462        assert_eq!(
463            mermaid_domain::parse_slash_command("usage"),
464            SlashCmd::Usage
465        );
466        assert_eq!(
467            mermaid_domain::parse_slash_command("context"),
468            SlashCmd::Context(ContextCmd::Show)
469        );
470        assert_eq!(
471            mermaid_domain::parse_slash_command("context 65536"),
472            SlashCmd::Context(ContextCmd::Set(65536))
473        );
474        assert_eq!(
475            mermaid_domain::parse_slash_command("context auto"),
476            SlashCmd::Context(ContextCmd::Auto)
477        );
478        assert_eq!(
479            mermaid_domain::parse_slash_command("context max"),
480            SlashCmd::Context(ContextCmd::Max)
481        );
482        assert_eq!(
483            mermaid_domain::parse_slash_command("context offload on"),
484            SlashCmd::Context(ContextCmd::Offload(true))
485        );
486        assert_eq!(
487            mermaid_domain::parse_slash_command("context offload off"),
488            SlashCmd::Context(ContextCmd::Offload(false))
489        );
490        // Unrecognized arg falls back to the (self-documenting) report.
491        assert_eq!(
492            mermaid_domain::parse_slash_command("context wat"),
493            SlashCmd::Context(ContextCmd::Show)
494        );
495        assert_eq!(
496            mermaid_domain::parse_slash_command("doctor"),
497            SlashCmd::Doctor
498        );
499    }
500
501    #[test]
502    fn parse_slash_compact_and_aliases() {
503        assert_eq!(
504            mermaid_domain::parse_slash_command("compact"),
505            SlashCmd::Compact(None)
506        );
507        assert_eq!(
508            mermaid_domain::parse_slash_command("compact focus on tests"),
509            SlashCmd::Compact(Some("focus on tests".to_string()))
510        );
511        assert_eq!(
512            mermaid_domain::parse_slash_command("compress"),
513            SlashCmd::Compact(None)
514        );
515        assert_eq!(
516            mermaid_domain::parse_slash_command("summarize"),
517            SlashCmd::Compact(None)
518        );
519    }
520
521    #[test]
522    fn parse_memory_commands() {
523        assert_eq!(
524            mermaid_domain::parse_slash_command("memory"),
525            SlashCmd::Memory
526        );
527        assert_eq!(
528            mermaid_domain::parse_slash_command("memories"),
529            SlashCmd::Memory
530        ); // alias
531        assert_eq!(
532            mermaid_domain::parse_slash_command("remember prefer ripgrep"),
533            SlashCmd::Remember(Some("prefer ripgrep".to_string()))
534        );
535        assert_eq!(
536            mermaid_domain::parse_slash_command("remember"),
537            SlashCmd::Remember(None)
538        );
539        assert_eq!(
540            mermaid_domain::parse_slash_command("forget prefer-ripgrep"),
541            SlashCmd::Forget(Some("prefer-ripgrep".to_string()))
542        );
543        assert_eq!(
544            mermaid_domain::parse_slash_command("forget"),
545            SlashCmd::Forget(None)
546        );
547        assert_eq!(
548            mermaid_domain::parse_slash_command("consolidate-memory"),
549            SlashCmd::ConsolidateMemory
550        );
551        assert_eq!(
552            mermaid_domain::parse_slash_command("prune-memory"),
553            SlashCmd::ConsolidateMemory
554        ); // alias
555    }
556
557    #[test]
558    fn parse_runtime_task_commands() {
559        assert_eq!(
560            mermaid_domain::parse_slash_command("tasks"),
561            SlashCmd::Tasks
562        );
563        assert_eq!(
564            mermaid_domain::parse_slash_command("task task-123"),
565            SlashCmd::Task(Some("task-123".to_string()))
566        );
567        assert_eq!(
568            mermaid_domain::parse_slash_command("pause task-123"),
569            SlashCmd::Pause(Some("task-123".to_string()))
570        );
571        assert_eq!(
572            mermaid_domain::parse_slash_command("resume task-123"),
573            SlashCmd::Resume(Some("task-123".to_string()))
574        );
575        assert_eq!(
576            mermaid_domain::parse_slash_command("cancel"),
577            SlashCmd::Cancel(None)
578        );
579        assert_eq!(
580            mermaid_domain::parse_slash_command("handoff task-123"),
581            SlashCmd::Handoff(Some("task-123".to_string()))
582        );
583        assert_eq!(
584            mermaid_domain::parse_slash_command("report"),
585            SlashCmd::Report(None)
586        );
587        assert_eq!(
588            mermaid_domain::parse_slash_command("procs"),
589            SlashCmd::Processes
590        );
591        assert_eq!(
592            mermaid_domain::parse_slash_command("approvals"),
593            SlashCmd::Approvals
594        );
595        assert_eq!(
596            mermaid_domain::parse_slash_command("approve approval-1"),
597            SlashCmd::Approve(Some("approval-1".to_string()))
598        );
599        assert_eq!(
600            mermaid_domain::parse_slash_command("deny approval-1"),
601            SlashCmd::Deny(Some("approval-1".to_string()))
602        );
603        assert_eq!(
604            mermaid_domain::parse_slash_command("checkpoint src/lib.rs"),
605            SlashCmd::Checkpoint(Some("src/lib.rs".to_string()))
606        );
607        assert_eq!(
608            mermaid_domain::parse_slash_command("checkpoints"),
609            SlashCmd::Checkpoints
610        );
611        assert_eq!(
612            mermaid_domain::parse_slash_command("restore checkpoint-1"),
613            SlashCmd::Restore(Some("checkpoint-1".to_string()))
614        );
615        assert_eq!(
616            mermaid_domain::parse_slash_command("plugins"),
617            SlashCmd::Plugins
618        );
619    }
620
621    #[test]
622    fn parse_slash_reasoning_valid_level() {
623        assert_eq!(
624            mermaid_domain::parse_slash_command("reasoning high"),
625            SlashCmd::Reasoning(Some(mermaid_model::models::ReasoningLevel::High)),
626        );
627    }
628
629    #[test]
630    fn parse_slash_visible_reasoning_and_alias() {
631        assert_eq!(
632            mermaid_domain::parse_slash_command("visible-reasoning on"),
633            SlashCmd::VisibleReasoning(Some("on".to_string())),
634        );
635        assert_eq!(
636            mermaid_domain::parse_slash_command("visiblereasoning"),
637            SlashCmd::VisibleReasoning(None),
638        );
639    }
640
641    #[test]
642    fn parse_slash_reasoning_invalid_level_is_none_arg() {
643        // Argument exists but can't be parsed to a level — degrades
644        // to showing current (None arg) rather than erroring.
645        assert_eq!(
646            mermaid_domain::parse_slash_command("reasoning bogus"),
647            SlashCmd::Reasoning(None),
648        );
649    }
650
651    #[test]
652    fn parse_safety_command() {
653        assert_eq!(
654            mermaid_domain::parse_slash_command("safety auto"),
655            SlashCmd::Safety(Some(mermaid_runtime::SafetyMode::Auto)),
656        );
657        // `/permission` is an alias that routes to the same command.
658        assert_eq!(
659            mermaid_domain::parse_slash_command("permission read_only"),
660            SlashCmd::Safety(Some(mermaid_runtime::SafetyMode::ReadOnly)),
661        );
662        // No arg → show current; bogus value → None (show current + options).
663        assert_eq!(
664            mermaid_domain::parse_slash_command("safety"),
665            SlashCmd::Safety(None)
666        );
667        assert_eq!(
668            mermaid_domain::parse_slash_command("safety bogus"),
669            SlashCmd::Safety(None)
670        );
671    }
672
673    #[test]
674    fn parse_slash_unknown_command() {
675        match mermaid_domain::parse_slash_command("nope") {
676            SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
677            other => panic!("expected Unknown, got {other:?}"),
678        }
679    }
680
681    #[test]
682    fn key_mods_combine_correctly() {
683        let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
684        assert!(mods.ctrl);
685        assert!(mods.shift);
686        assert!(!mods.alt);
687    }
688}