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