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") => SlashCmd::Context,
223 Some("compact") => SlashCmd::Compact(arg),
224 Some("memory") => SlashCmd::Memory,
225 Some("remember") => SlashCmd::Remember(arg),
226 Some("forget") => SlashCmd::Forget(arg),
227 Some("consolidate-memory") => SlashCmd::ConsolidateMemory,
228 Some("doctor") => SlashCmd::Doctor,
229 Some("tasks") => SlashCmd::Tasks,
230 Some("task") => SlashCmd::Task(arg),
231 Some("pause") => SlashCmd::Pause(arg),
232 Some("resume") => SlashCmd::Resume(arg),
233 Some("cancel") => SlashCmd::Cancel(arg),
234 Some("handoff") => SlashCmd::Handoff(arg),
235 Some("report") => SlashCmd::Report(arg),
236 Some("processes") => SlashCmd::Processes,
237 Some("logs") => SlashCmd::Logs(arg),
238 Some("stop") => SlashCmd::Stop(arg),
239 Some("restart") => SlashCmd::Restart(arg),
240 Some("open") => SlashCmd::Open(arg),
241 Some("ports") => SlashCmd::Ports,
242 Some("approvals") => SlashCmd::Approvals,
243 Some("approve") => SlashCmd::Approve(arg),
244 Some("deny") => SlashCmd::Deny(arg),
245 Some("checkpoint") => SlashCmd::Checkpoint(arg),
246 Some("checkpoints") => SlashCmd::Checkpoints,
247 Some("restore") => SlashCmd::Restore(arg),
248 Some("plugins") => SlashCmd::Plugins,
249 Some("model-info") => SlashCmd::ModelInfo(arg),
250 Some("cloud-setup") => SlashCmd::CloudSetup,
251 Some("help") => SlashCmd::Help,
252 Some("quit") => SlashCmd::Quit,
253 _ => SlashCmd::Unknown(name),
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::domain::SlashCmd;
261
262 #[test]
263 fn translates_printable_char_key() {
264 let ev = CtEvent::Key(crossterm::event::KeyEvent {
265 code: CtKeyCode::Char('a'),
266 modifiers: CtMods::NONE,
267 kind: KeyEventKind::Press,
268 state: crossterm::event::KeyEventState::NONE,
269 });
270 let msg = event_to_msg(ev).expect("msg");
271 match msg {
272 Msg::Key(k) => {
273 assert_eq!(k.code, KeyCode::Char('a'));
274 assert!(k.modifiers.is_empty());
275 },
276 _ => panic!("wrong variant"),
277 }
278 }
279
280 #[test]
281 fn translates_ctrl_c() {
282 let ev = CtEvent::Key(crossterm::event::KeyEvent {
283 code: CtKeyCode::Char('c'),
284 modifiers: CtMods::CONTROL,
285 kind: KeyEventKind::Press,
286 state: crossterm::event::KeyEventState::NONE,
287 });
288 let msg = event_to_msg(ev).expect("msg");
289 match msg {
290 Msg::Key(k) => {
291 assert_eq!(k.code, KeyCode::Char('c'));
292 assert!(k.modifiers.ctrl);
293 assert!(!k.modifiers.alt);
294 },
295 _ => panic!("wrong variant"),
296 }
297 }
298
299 #[test]
300 fn skips_release_events() {
301 let ev = CtEvent::Key(crossterm::event::KeyEvent {
302 code: CtKeyCode::Char('a'),
303 modifiers: CtMods::NONE,
304 kind: KeyEventKind::Release,
305 state: crossterm::event::KeyEventState::NONE,
306 });
307 assert!(event_to_msg(ev).is_none());
308 }
309
310 #[test]
311 fn resize_translates_to_resize_msg() {
312 let ev = CtEvent::Resize(80, 24);
313 let msg = event_to_msg(ev).expect("msg");
314 match msg {
315 Msg::Resize { width, height } => {
316 assert_eq!(width, 80);
317 assert_eq!(height, 24);
318 },
319 _ => panic!("wrong variant"),
320 }
321 }
322
323 #[test]
324 fn empty_paste_dropped() {
325 let ev = CtEvent::Paste(String::new());
326 assert!(event_to_msg(ev).is_none());
327 }
328
329 #[test]
330 fn paste_translates_to_text_paste() {
331 let ev = CtEvent::Paste("hello".to_string());
332 let msg = event_to_msg(ev).expect("msg");
333 match msg {
334 Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
335 _ => panic!("wrong variant"),
336 }
337 }
338
339 fn key(code: CtKeyCode) -> CtEvent {
340 CtEvent::Key(crossterm::event::KeyEvent {
341 code,
342 modifiers: CtMods::NONE,
343 kind: KeyEventKind::Press,
344 state: crossterm::event::KeyEventState::NONE,
345 })
346 }
347
348 fn key_with(code: CtKeyCode, modifiers: CtMods, kind: KeyEventKind) -> CtEvent {
349 CtEvent::Key(crossterm::event::KeyEvent {
350 code,
351 modifiers,
352 kind,
353 state: crossterm::event::KeyEventState::NONE,
354 })
355 }
356
357 #[test]
358 fn coalesce_single_char_stays_a_key() {
359 let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || None);
360 assert!(matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Char('a')));
361 assert!(trailing.is_empty());
362 }
363
364 #[test]
365 fn coalesce_lone_enter_still_submits_as_key() {
366 let (primary, _) = coalesce_key_burst(key(CtKeyCode::Enter), || None);
368 assert!(
369 matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Enter),
370 "a lone Enter must remain a key, not become a paste"
371 );
372 }
373
374 #[test]
375 fn coalesce_burst_of_chars_becomes_one_paste() {
376 let mut rest = vec![key(CtKeyCode::Char('e')), key(CtKeyCode::Char('y'))].into_iter();
377 let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('h')), || rest.next());
378 match primary {
379 Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "hey"),
380 other => panic!("expected paste, got {other:?}"),
381 }
382 assert!(trailing.is_empty());
383 }
384
385 #[test]
386 fn coalesce_preserves_pasted_newlines_without_submitting() {
387 let mut rest = vec![key(CtKeyCode::Enter), key(CtKeyCode::Char('b'))].into_iter();
390 let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
391 match primary {
392 Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "a\nb"),
393 other => panic!("expected multi-line paste, got {other:?}"),
394 }
395 }
396
397 #[test]
398 fn coalesce_stops_at_non_char_and_enqueues_it() {
399 let mut rest = vec![key(CtKeyCode::Char('b')), key(CtKeyCode::Esc)].into_iter();
400 let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
401 assert!(matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "ab"));
402 assert_eq!(trailing.len(), 1);
403 assert!(matches!(trailing[0], Msg::Key(k) if k.code == KeyCode::Escape));
404 }
405
406 #[test]
407 fn coalesce_skips_release_events_mid_burst() {
408 let mut rest = vec![
409 key_with(CtKeyCode::Char('x'), CtMods::NONE, KeyEventKind::Release),
410 key(CtKeyCode::Char('b')),
411 ]
412 .into_iter();
413 let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
414 assert!(
415 matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "ab"),
416 "release events must be skipped, not appended or treated as burst-enders"
417 );
418 }
419
420 #[test]
421 fn coalesce_preserves_pasted_tabs() {
422 let mut rest = vec![key(CtKeyCode::Tab), key(CtKeyCode::Char('b'))].into_iter();
423 let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
424 match primary {
425 Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "a\tb"),
426 other => panic!("expected paste with tab, got {other:?}"),
427 }
428 }
429
430 #[test]
431 fn coalesce_lone_tab_stays_a_key() {
432 let (primary, _) = coalesce_key_burst(key(CtKeyCode::Tab), || None);
434 assert!(matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Tab));
435 }
436
437 #[test]
438 fn coalesce_ctrl_combo_passes_through_without_draining() {
439 let drained = std::cell::Cell::new(false);
440 let (primary, trailing) = coalesce_key_burst(
441 key_with(CtKeyCode::Char('c'), CtMods::CONTROL, KeyEventKind::Press),
442 || {
443 drained.set(true);
444 None
445 },
446 );
447 assert!(
448 !drained.get(),
449 "a non-coalescible first event must not drain the queue"
450 );
451 assert!(
452 matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Char('c') && k.modifiers.ctrl)
453 );
454 assert!(trailing.is_empty());
455 }
456
457 #[test]
458 fn parse_slash_model_no_arg() {
459 assert_eq!(parse_slash_command("model"), SlashCmd::Model(None));
460 }
461
462 #[test]
463 fn parse_slash_model_with_arg() {
464 assert_eq!(
465 parse_slash_command("model anthropic/opus"),
466 SlashCmd::Model(Some("anthropic/opus".to_string())),
467 );
468 }
469
470 #[test]
471 fn parse_slash_quit_alias_q() {
472 assert_eq!(parse_slash_command("q"), SlashCmd::Quit);
473 }
474
475 #[test]
476 fn parse_slash_usage_and_context() {
477 assert_eq!(parse_slash_command("usage"), SlashCmd::Usage);
478 assert_eq!(parse_slash_command("context"), SlashCmd::Context);
479 assert_eq!(parse_slash_command("doctor"), SlashCmd::Doctor);
480 }
481
482 #[test]
483 fn parse_slash_compact_and_aliases() {
484 assert_eq!(parse_slash_command("compact"), SlashCmd::Compact(None));
485 assert_eq!(
486 parse_slash_command("compact focus on tests"),
487 SlashCmd::Compact(Some("focus on tests".to_string()))
488 );
489 assert_eq!(parse_slash_command("compress"), SlashCmd::Compact(None));
490 assert_eq!(parse_slash_command("summarize"), SlashCmd::Compact(None));
491 }
492
493 #[test]
494 fn parse_memory_commands() {
495 assert_eq!(parse_slash_command("memory"), SlashCmd::Memory);
496 assert_eq!(parse_slash_command("memories"), SlashCmd::Memory); assert_eq!(
498 parse_slash_command("remember prefer ripgrep"),
499 SlashCmd::Remember(Some("prefer ripgrep".to_string()))
500 );
501 assert_eq!(parse_slash_command("remember"), SlashCmd::Remember(None));
502 assert_eq!(
503 parse_slash_command("forget prefer-ripgrep"),
504 SlashCmd::Forget(Some("prefer-ripgrep".to_string()))
505 );
506 assert_eq!(parse_slash_command("forget"), SlashCmd::Forget(None));
507 assert_eq!(
508 parse_slash_command("consolidate-memory"),
509 SlashCmd::ConsolidateMemory
510 );
511 assert_eq!(
512 parse_slash_command("prune-memory"),
513 SlashCmd::ConsolidateMemory
514 ); }
516
517 #[test]
518 fn parse_runtime_task_commands() {
519 assert_eq!(parse_slash_command("tasks"), SlashCmd::Tasks);
520 assert_eq!(
521 parse_slash_command("task task-123"),
522 SlashCmd::Task(Some("task-123".to_string()))
523 );
524 assert_eq!(
525 parse_slash_command("pause task-123"),
526 SlashCmd::Pause(Some("task-123".to_string()))
527 );
528 assert_eq!(
529 parse_slash_command("resume task-123"),
530 SlashCmd::Resume(Some("task-123".to_string()))
531 );
532 assert_eq!(parse_slash_command("cancel"), SlashCmd::Cancel(None));
533 assert_eq!(
534 parse_slash_command("handoff task-123"),
535 SlashCmd::Handoff(Some("task-123".to_string()))
536 );
537 assert_eq!(parse_slash_command("report"), SlashCmd::Report(None));
538 assert_eq!(parse_slash_command("procs"), SlashCmd::Processes);
539 assert_eq!(parse_slash_command("approvals"), SlashCmd::Approvals);
540 assert_eq!(
541 parse_slash_command("approve approval-1"),
542 SlashCmd::Approve(Some("approval-1".to_string()))
543 );
544 assert_eq!(
545 parse_slash_command("deny approval-1"),
546 SlashCmd::Deny(Some("approval-1".to_string()))
547 );
548 assert_eq!(
549 parse_slash_command("checkpoint src/lib.rs"),
550 SlashCmd::Checkpoint(Some("src/lib.rs".to_string()))
551 );
552 assert_eq!(parse_slash_command("checkpoints"), SlashCmd::Checkpoints);
553 assert_eq!(
554 parse_slash_command("restore checkpoint-1"),
555 SlashCmd::Restore(Some("checkpoint-1".to_string()))
556 );
557 assert_eq!(parse_slash_command("plugins"), SlashCmd::Plugins);
558 }
559
560 #[test]
561 fn parse_slash_reasoning_valid_level() {
562 assert_eq!(
563 parse_slash_command("reasoning high"),
564 SlashCmd::Reasoning(Some(crate::models::ReasoningLevel::High)),
565 );
566 }
567
568 #[test]
569 fn parse_slash_visible_reasoning_and_alias() {
570 assert_eq!(
571 parse_slash_command("visible-reasoning on"),
572 SlashCmd::VisibleReasoning(Some("on".to_string())),
573 );
574 assert_eq!(
575 parse_slash_command("visiblereasoning"),
576 SlashCmd::VisibleReasoning(None),
577 );
578 }
579
580 #[test]
581 fn parse_slash_reasoning_invalid_level_is_none_arg() {
582 assert_eq!(
585 parse_slash_command("reasoning bogus"),
586 SlashCmd::Reasoning(None),
587 );
588 }
589
590 #[test]
591 fn parse_safety_command() {
592 assert_eq!(
593 parse_slash_command("safety auto"),
594 SlashCmd::Safety(Some(crate::runtime::SafetyMode::Auto)),
595 );
596 assert_eq!(
598 parse_slash_command("permission read_only"),
599 SlashCmd::Safety(Some(crate::runtime::SafetyMode::ReadOnly)),
600 );
601 assert_eq!(parse_slash_command("safety"), SlashCmd::Safety(None));
603 assert_eq!(parse_slash_command("safety bogus"), SlashCmd::Safety(None));
604 }
605
606 #[test]
607 fn parse_slash_unknown_command() {
608 match parse_slash_command("nope") {
609 SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
610 other => panic!("expected Unknown, got {:?}", other),
611 }
612 }
613
614 #[test]
615 fn key_mods_combine_correctly() {
616 let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
617 assert!(mods.ctrl);
618 assert!(mods.shift);
619 assert!(!mods.alt);
620 }
621}