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