1use 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
23pub fn event_to_msg(event: CtEvent) -> Option<Msg> {
27 match event {
28 CtEvent::Key(key) => {
29 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 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
68pub fn coalesce_key_burst(
87 first: CtEvent,
88 mut drain: impl FnMut() -> Option<CtEvent>,
89) -> (Option<Msg>, Vec<Msg>) {
90 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 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 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 (event_to_msg(first), trailing)
123 } else {
124 (Some(Msg::Paste(Paste::Text(buf))), trailing)
125 }
126}
127
128fn 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 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
182pub 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 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 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 _ => ContextCmd::Show,
241 }
242 } else if let Ok(n) = s.parse::<u32>() {
243 ContextCmd::Set(n)
244 } else {
245 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 #[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 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 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 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 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); 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 ); }
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 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 assert_eq!(
704 parse_slash_command("permission read_only"),
705 SlashCmd::Safety(Some(crate::runtime::SafetyMode::ReadOnly)),
706 );
707 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}