1use 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#[must_use]
27pub fn event_to_msg(event: CtEvent) -> Option<Msg> {
28 match event {
29 CtEvent::Key(key) => {
30 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 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
69pub fn coalesce_key_burst(
88 first: CtEvent,
89 mut drain: impl FnMut() -> Option<CtEvent>,
90) -> (Option<Msg>, Vec<Msg>) {
91 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 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 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 (event_to_msg(first), trailing)
124 } else {
125 (Some(Msg::Paste(Paste::Text(buf))), trailing)
126 }
127}
128
129fn 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 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 #[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 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 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 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 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 ); 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 ); }
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 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 assert_eq!(
659 mermaid_domain::parse_slash_command("permission read_only"),
660 SlashCmd::Safety(Some(mermaid_runtime::SafetyMode::ReadOnly)),
661 );
662 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}