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("clear") => SlashCmd::Clear,
128 Some("save") => SlashCmd::Save(arg),
129 Some("load") => SlashCmd::Load(arg),
130 Some("list") => SlashCmd::List,
131 Some("usage") => SlashCmd::Usage,
132 Some("context") => SlashCmd::Context,
133 Some("compact") => SlashCmd::Compact(arg),
134 Some("cloud-setup") => SlashCmd::CloudSetup,
135 Some("help") => SlashCmd::Help,
136 Some("quit") => SlashCmd::Quit,
137 _ => SlashCmd::Unknown(name),
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::domain::SlashCmd;
145
146 #[test]
147 fn translates_printable_char_key() {
148 let ev = CtEvent::Key(crossterm::event::KeyEvent {
149 code: CtKeyCode::Char('a'),
150 modifiers: CtMods::NONE,
151 kind: KeyEventKind::Press,
152 state: crossterm::event::KeyEventState::NONE,
153 });
154 let msg = event_to_msg(ev).expect("msg");
155 match msg {
156 Msg::Key(k) => {
157 assert_eq!(k.code, KeyCode::Char('a'));
158 assert!(k.modifiers.is_empty());
159 },
160 _ => panic!("wrong variant"),
161 }
162 }
163
164 #[test]
165 fn translates_ctrl_c() {
166 let ev = CtEvent::Key(crossterm::event::KeyEvent {
167 code: CtKeyCode::Char('c'),
168 modifiers: CtMods::CONTROL,
169 kind: KeyEventKind::Press,
170 state: crossterm::event::KeyEventState::NONE,
171 });
172 let msg = event_to_msg(ev).expect("msg");
173 match msg {
174 Msg::Key(k) => {
175 assert_eq!(k.code, KeyCode::Char('c'));
176 assert!(k.modifiers.ctrl);
177 assert!(!k.modifiers.alt);
178 },
179 _ => panic!("wrong variant"),
180 }
181 }
182
183 #[test]
184 fn skips_release_events() {
185 let ev = CtEvent::Key(crossterm::event::KeyEvent {
186 code: CtKeyCode::Char('a'),
187 modifiers: CtMods::NONE,
188 kind: KeyEventKind::Release,
189 state: crossterm::event::KeyEventState::NONE,
190 });
191 assert!(event_to_msg(ev).is_none());
192 }
193
194 #[test]
195 fn resize_translates_to_resize_msg() {
196 let ev = CtEvent::Resize(80, 24);
197 let msg = event_to_msg(ev).expect("msg");
198 match msg {
199 Msg::Resize { width, height } => {
200 assert_eq!(width, 80);
201 assert_eq!(height, 24);
202 },
203 _ => panic!("wrong variant"),
204 }
205 }
206
207 #[test]
208 fn empty_paste_dropped() {
209 let ev = CtEvent::Paste(String::new());
210 assert!(event_to_msg(ev).is_none());
211 }
212
213 #[test]
214 fn paste_translates_to_text_paste() {
215 let ev = CtEvent::Paste("hello".to_string());
216 let msg = event_to_msg(ev).expect("msg");
217 match msg {
218 Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
219 _ => panic!("wrong variant"),
220 }
221 }
222
223 #[test]
224 fn parse_slash_model_no_arg() {
225 assert_eq!(parse_slash_command("model"), SlashCmd::Model(None));
226 }
227
228 #[test]
229 fn parse_slash_model_with_arg() {
230 assert_eq!(
231 parse_slash_command("model anthropic/opus"),
232 SlashCmd::Model(Some("anthropic/opus".to_string())),
233 );
234 }
235
236 #[test]
237 fn parse_slash_quit_alias_q() {
238 assert_eq!(parse_slash_command("q"), SlashCmd::Quit);
239 }
240
241 #[test]
242 fn parse_slash_usage_and_context() {
243 assert_eq!(parse_slash_command("usage"), SlashCmd::Usage);
244 assert_eq!(parse_slash_command("context"), SlashCmd::Context);
245 }
246
247 #[test]
248 fn parse_slash_compact_and_aliases() {
249 assert_eq!(parse_slash_command("compact"), SlashCmd::Compact(None));
250 assert_eq!(
251 parse_slash_command("compact focus on tests"),
252 SlashCmd::Compact(Some("focus on tests".to_string()))
253 );
254 assert_eq!(parse_slash_command("compress"), SlashCmd::Compact(None));
255 assert_eq!(parse_slash_command("summarize"), SlashCmd::Compact(None));
256 }
257
258 #[test]
259 fn parse_slash_reasoning_valid_level() {
260 assert_eq!(
261 parse_slash_command("reasoning high"),
262 SlashCmd::Reasoning(Some(crate::models::ReasoningLevel::High)),
263 );
264 }
265
266 #[test]
267 fn parse_slash_reasoning_invalid_level_is_none_arg() {
268 assert_eq!(
271 parse_slash_command("reasoning bogus"),
272 SlashCmd::Reasoning(None),
273 );
274 }
275
276 #[test]
277 fn parse_slash_unknown_command() {
278 match parse_slash_command("nope") {
279 SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
280 other => panic!("expected Unknown, got {:?}", other),
281 }
282 }
283
284 #[test]
285 fn key_mods_combine_correctly() {
286 let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
287 assert!(mods.ctrl);
288 assert!(mods.shift);
289 assert!(!mods.alt);
290 }
291}