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
67fn translate_key_code(code: CtKeyCode) -> Option<KeyCode> {
68 Some(match code {
69 CtKeyCode::Char(c) => KeyCode::Char(c),
70 CtKeyCode::Enter => KeyCode::Enter,
71 CtKeyCode::Esc => KeyCode::Escape,
72 CtKeyCode::Backspace => KeyCode::Backspace,
73 CtKeyCode::Delete => KeyCode::Delete,
74 CtKeyCode::Tab => KeyCode::Tab,
75 CtKeyCode::BackTab => KeyCode::BackTab,
76 CtKeyCode::Left => KeyCode::Left,
77 CtKeyCode::Right => KeyCode::Right,
78 CtKeyCode::Up => KeyCode::Up,
79 CtKeyCode::Down => KeyCode::Down,
80 CtKeyCode::Home => KeyCode::Home,
81 CtKeyCode::End => KeyCode::End,
82 CtKeyCode::PageUp => KeyCode::PageUp,
83 CtKeyCode::PageDown => KeyCode::PageDown,
84 CtKeyCode::F(n) => KeyCode::F(n),
85 _ => return Some(KeyCode::Unknown),
86 })
87}
88
89fn translate_mods(mods: CtMods) -> KeyMods {
90 KeyMods {
91 ctrl: mods.contains(CtMods::CONTROL),
92 alt: mods.contains(CtMods::ALT),
93 shift: mods.contains(CtMods::SHIFT),
94 }
95}
96
97pub fn parse_slash_command(raw: &str) -> crate::domain::SlashCmd {
102 use crate::domain::SlashCmd;
103 let trimmed = raw.trim();
104 let (name, arg) = match trimmed.split_once(' ') {
105 Some((n, a)) => (n.to_lowercase(), Some(a.trim().to_string())),
106 None => (trimmed.to_lowercase(), None),
107 };
108
109 use crate::domain::slash_commands::COMMAND_REGISTRY;
111 let canonical = COMMAND_REGISTRY
112 .iter()
113 .find(|c| c.name == name.as_str() || c.aliases.contains(&name.as_str()))
114 .map(|c| c.name);
115
116 match canonical {
117 Some("model") => SlashCmd::Model(arg),
118 Some("reasoning") => match arg.as_deref() {
119 None => SlashCmd::Reasoning(None),
120 Some(level) => {
121 use clap::ValueEnum;
122 SlashCmd::Reasoning(
123 crate::models::ReasoningLevel::from_str(&level.to_lowercase(), true).ok(),
124 )
125 },
126 },
127 Some("visible-reasoning") => SlashCmd::VisibleReasoning(arg),
128 Some("safety") => match arg.as_deref() {
129 None => SlashCmd::Safety(None),
130 Some(mode) => SlashCmd::Safety(crate::runtime::SafetyMode::parse(&mode.to_lowercase())),
132 },
133 Some("clear") => SlashCmd::Clear,
134 Some("save") => SlashCmd::Save(arg),
135 Some("load") => SlashCmd::Load(arg),
136 Some("list") => SlashCmd::List,
137 Some("usage") => SlashCmd::Usage,
138 Some("context") => SlashCmd::Context,
139 Some("compact") => SlashCmd::Compact(arg),
140 Some("doctor") => SlashCmd::Doctor,
141 Some("tasks") => SlashCmd::Tasks,
142 Some("task") => SlashCmd::Task(arg),
143 Some("pause") => SlashCmd::Pause(arg),
144 Some("resume") => SlashCmd::Resume(arg),
145 Some("cancel") => SlashCmd::Cancel(arg),
146 Some("handoff") => SlashCmd::Handoff(arg),
147 Some("report") => SlashCmd::Report(arg),
148 Some("processes") => SlashCmd::Processes,
149 Some("logs") => SlashCmd::Logs(arg),
150 Some("stop") => SlashCmd::Stop(arg),
151 Some("restart") => SlashCmd::Restart(arg),
152 Some("open") => SlashCmd::Open(arg),
153 Some("ports") => SlashCmd::Ports,
154 Some("approvals") => SlashCmd::Approvals,
155 Some("approve") => SlashCmd::Approve(arg),
156 Some("deny") => SlashCmd::Deny(arg),
157 Some("checkpoint") => SlashCmd::Checkpoint(arg),
158 Some("checkpoints") => SlashCmd::Checkpoints,
159 Some("restore") => SlashCmd::Restore(arg),
160 Some("memory") => match arg {
161 Some(input) if input.starts_with("edit ") => {
162 SlashCmd::MemoryEdit(Some(input["edit ".len()..].trim().to_string()))
163 },
164 Some(_) | None => SlashCmd::Memory,
165 },
166 Some("remember") => SlashCmd::Remember(arg),
167 Some("forget") => SlashCmd::Forget(arg),
168 Some("plugins") => SlashCmd::Plugins,
169 Some("model-info") => SlashCmd::ModelInfo(arg),
170 Some("cloud-setup") => SlashCmd::CloudSetup,
171 Some("help") => SlashCmd::Help,
172 Some("quit") => SlashCmd::Quit,
173 _ => SlashCmd::Unknown(name),
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::domain::SlashCmd;
181
182 #[test]
183 fn translates_printable_char_key() {
184 let ev = CtEvent::Key(crossterm::event::KeyEvent {
185 code: CtKeyCode::Char('a'),
186 modifiers: CtMods::NONE,
187 kind: KeyEventKind::Press,
188 state: crossterm::event::KeyEventState::NONE,
189 });
190 let msg = event_to_msg(ev).expect("msg");
191 match msg {
192 Msg::Key(k) => {
193 assert_eq!(k.code, KeyCode::Char('a'));
194 assert!(k.modifiers.is_empty());
195 },
196 _ => panic!("wrong variant"),
197 }
198 }
199
200 #[test]
201 fn translates_ctrl_c() {
202 let ev = CtEvent::Key(crossterm::event::KeyEvent {
203 code: CtKeyCode::Char('c'),
204 modifiers: CtMods::CONTROL,
205 kind: KeyEventKind::Press,
206 state: crossterm::event::KeyEventState::NONE,
207 });
208 let msg = event_to_msg(ev).expect("msg");
209 match msg {
210 Msg::Key(k) => {
211 assert_eq!(k.code, KeyCode::Char('c'));
212 assert!(k.modifiers.ctrl);
213 assert!(!k.modifiers.alt);
214 },
215 _ => panic!("wrong variant"),
216 }
217 }
218
219 #[test]
220 fn skips_release_events() {
221 let ev = CtEvent::Key(crossterm::event::KeyEvent {
222 code: CtKeyCode::Char('a'),
223 modifiers: CtMods::NONE,
224 kind: KeyEventKind::Release,
225 state: crossterm::event::KeyEventState::NONE,
226 });
227 assert!(event_to_msg(ev).is_none());
228 }
229
230 #[test]
231 fn resize_translates_to_resize_msg() {
232 let ev = CtEvent::Resize(80, 24);
233 let msg = event_to_msg(ev).expect("msg");
234 match msg {
235 Msg::Resize { width, height } => {
236 assert_eq!(width, 80);
237 assert_eq!(height, 24);
238 },
239 _ => panic!("wrong variant"),
240 }
241 }
242
243 #[test]
244 fn empty_paste_dropped() {
245 let ev = CtEvent::Paste(String::new());
246 assert!(event_to_msg(ev).is_none());
247 }
248
249 #[test]
250 fn paste_translates_to_text_paste() {
251 let ev = CtEvent::Paste("hello".to_string());
252 let msg = event_to_msg(ev).expect("msg");
253 match msg {
254 Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
255 _ => panic!("wrong variant"),
256 }
257 }
258
259 #[test]
260 fn parse_slash_model_no_arg() {
261 assert_eq!(parse_slash_command("model"), SlashCmd::Model(None));
262 }
263
264 #[test]
265 fn parse_slash_model_with_arg() {
266 assert_eq!(
267 parse_slash_command("model anthropic/opus"),
268 SlashCmd::Model(Some("anthropic/opus".to_string())),
269 );
270 }
271
272 #[test]
273 fn parse_slash_quit_alias_q() {
274 assert_eq!(parse_slash_command("q"), SlashCmd::Quit);
275 }
276
277 #[test]
278 fn parse_slash_usage_and_context() {
279 assert_eq!(parse_slash_command("usage"), SlashCmd::Usage);
280 assert_eq!(parse_slash_command("context"), SlashCmd::Context);
281 assert_eq!(parse_slash_command("doctor"), SlashCmd::Doctor);
282 }
283
284 #[test]
285 fn parse_slash_compact_and_aliases() {
286 assert_eq!(parse_slash_command("compact"), SlashCmd::Compact(None));
287 assert_eq!(
288 parse_slash_command("compact focus on tests"),
289 SlashCmd::Compact(Some("focus on tests".to_string()))
290 );
291 assert_eq!(parse_slash_command("compress"), SlashCmd::Compact(None));
292 assert_eq!(parse_slash_command("summarize"), SlashCmd::Compact(None));
293 }
294
295 #[test]
296 fn parse_runtime_task_commands() {
297 assert_eq!(parse_slash_command("tasks"), SlashCmd::Tasks);
298 assert_eq!(
299 parse_slash_command("task task-123"),
300 SlashCmd::Task(Some("task-123".to_string()))
301 );
302 assert_eq!(
303 parse_slash_command("pause task-123"),
304 SlashCmd::Pause(Some("task-123".to_string()))
305 );
306 assert_eq!(
307 parse_slash_command("resume task-123"),
308 SlashCmd::Resume(Some("task-123".to_string()))
309 );
310 assert_eq!(parse_slash_command("cancel"), SlashCmd::Cancel(None));
311 assert_eq!(
312 parse_slash_command("handoff task-123"),
313 SlashCmd::Handoff(Some("task-123".to_string()))
314 );
315 assert_eq!(parse_slash_command("report"), SlashCmd::Report(None));
316 assert_eq!(parse_slash_command("procs"), SlashCmd::Processes);
317 assert_eq!(parse_slash_command("approvals"), SlashCmd::Approvals);
318 assert_eq!(
319 parse_slash_command("approve approval-1"),
320 SlashCmd::Approve(Some("approval-1".to_string()))
321 );
322 assert_eq!(
323 parse_slash_command("deny approval-1"),
324 SlashCmd::Deny(Some("approval-1".to_string()))
325 );
326 assert_eq!(
327 parse_slash_command("checkpoint src/lib.rs"),
328 SlashCmd::Checkpoint(Some("src/lib.rs".to_string()))
329 );
330 assert_eq!(parse_slash_command("checkpoints"), SlashCmd::Checkpoints);
331 assert_eq!(
332 parse_slash_command("restore checkpoint-1"),
333 SlashCmd::Restore(Some("checkpoint-1".to_string()))
334 );
335 assert_eq!(parse_slash_command("memory"), SlashCmd::Memory);
336 assert_eq!(
337 parse_slash_command("remember decision use sqlite"),
338 SlashCmd::Remember(Some("decision use sqlite".to_string()))
339 );
340 assert_eq!(
341 parse_slash_command("forget memory-1"),
342 SlashCmd::Forget(Some("memory-1".to_string()))
343 );
344 assert_eq!(parse_slash_command("plugins"), SlashCmd::Plugins);
345 }
346
347 #[test]
348 fn parse_slash_reasoning_valid_level() {
349 assert_eq!(
350 parse_slash_command("reasoning high"),
351 SlashCmd::Reasoning(Some(crate::models::ReasoningLevel::High)),
352 );
353 }
354
355 #[test]
356 fn parse_slash_visible_reasoning_and_alias() {
357 assert_eq!(
358 parse_slash_command("visible-reasoning on"),
359 SlashCmd::VisibleReasoning(Some("on".to_string())),
360 );
361 assert_eq!(
362 parse_slash_command("visiblereasoning"),
363 SlashCmd::VisibleReasoning(None),
364 );
365 }
366
367 #[test]
368 fn parse_slash_reasoning_invalid_level_is_none_arg() {
369 assert_eq!(
372 parse_slash_command("reasoning bogus"),
373 SlashCmd::Reasoning(None),
374 );
375 }
376
377 #[test]
378 fn parse_safety_command() {
379 assert_eq!(
380 parse_slash_command("safety auto"),
381 SlashCmd::Safety(Some(crate::runtime::SafetyMode::Auto)),
382 );
383 assert_eq!(
385 parse_slash_command("permission read_only"),
386 SlashCmd::Safety(Some(crate::runtime::SafetyMode::ReadOnly)),
387 );
388 assert_eq!(parse_slash_command("safety"), SlashCmd::Safety(None));
390 assert_eq!(parse_slash_command("safety bogus"), SlashCmd::Safety(None));
391 }
392
393 #[test]
394 fn parse_slash_unknown_command() {
395 match parse_slash_command("nope") {
396 SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
397 other => panic!("expected Unknown, got {:?}", other),
398 }
399 }
400
401 #[test]
402 fn key_mods_combine_correctly() {
403 let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
404 assert!(mods.ctrl);
405 assert!(mods.shift);
406 assert!(!mods.alt);
407 }
408}