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 | CtEvent::FocusLost => None,
64 }
65}
66
67pub fn coalesce_key_burst(
86 first: CtEvent,
87 mut drain: impl FnMut() -> Option<CtEvent>,
88) -> (Option<Msg>, Vec<Msg>) {
89 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 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 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 (event_to_msg(first), trailing)
122 } else {
123 (Some(Msg::Paste(Paste::Text(buf))), trailing)
124 }
125}
126
127fn 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 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
181pub 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 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 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 _ => ContextCmd::Show,
236 }
237 } else if let Ok(n) = s.parse::<u32>() {
238 ContextCmd::Set(n)
239 } else {
240 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 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 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 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 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); 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 ); }
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 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 assert_eq!(
650 parse_slash_command("permission read_only"),
651 SlashCmd::Safety(Some(crate::runtime::SafetyMode::ReadOnly)),
652 );
653 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}