1use std::collections::HashMap;
34
35use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
36
37#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
44pub enum GlobalAction {
45 Interrupt,
46 Submit,
47 SendNow,
48 ToggleMultiline,
49 OpenCommandPalette,
50 ToggleQueuePanel,
51 FoldAll,
52 ScrollUp,
53 ScrollDown,
54 Clear,
55 Help,
56 ModelPicker,
57 ToggleThinking,
58}
59
60pub const DEFAULT_KEYBINDINGS: &[(GlobalAction, &str)] = &[
68 (GlobalAction::Interrupt, "Ctrl+c"),
69 (GlobalAction::Submit, "Enter"),
70 (GlobalAction::Submit, "Shift+Enter"),
74 (GlobalAction::SendNow, "Ctrl+Enter"),
75 (GlobalAction::ToggleMultiline, "Ctrl+m"),
76 (GlobalAction::OpenCommandPalette, "Ctrl+p"),
77 (GlobalAction::ToggleQueuePanel, "Ctrl+;"),
78 (GlobalAction::FoldAll, "Ctrl+e"),
79 (GlobalAction::ScrollUp, "PageUp"),
80 (GlobalAction::ScrollDown, "PageDown"),
81 (GlobalAction::Clear, "Ctrl+l"),
82 (GlobalAction::Help, "?"),
83 (GlobalAction::ModelPicker, "Ctrl+g"),
84 (GlobalAction::ToggleThinking, "Ctrl+t"),
85];
86
87impl GlobalAction {
88 pub fn name(self) -> &'static str {
89 match self {
90 GlobalAction::Interrupt => "Interrupt",
91 GlobalAction::Submit => "Submit",
92 GlobalAction::SendNow => "SendNow",
93 GlobalAction::ToggleMultiline => "ToggleMultiline",
94 GlobalAction::OpenCommandPalette => "OpenCommandPalette",
95 GlobalAction::ToggleQueuePanel => "ToggleQueuePanel",
96 GlobalAction::FoldAll => "FoldAll",
97 GlobalAction::ScrollUp => "ScrollUp",
98 GlobalAction::ScrollDown => "ScrollDown",
99 GlobalAction::Clear => "Clear",
100 GlobalAction::Help => "Help",
101 GlobalAction::ModelPicker => "ModelPicker",
102 GlobalAction::ToggleThinking => "ToggleThinking",
103 }
104 }
105
106 pub fn from_name(s: &str) -> Option<Self> {
107 DEFAULT_KEYBINDINGS
108 .iter()
109 .map(|(a, _)| *a)
110 .find(|a| a.name() == s)
111 }
112
113 pub fn all() -> impl Iterator<Item = GlobalAction> {
114 DEFAULT_KEYBINDINGS.iter().map(|(a, _)| *a)
115 }
116}
117
118#[derive(Clone, PartialEq, Eq, Hash, Debug)]
119pub struct KeyCombo {
120 pub code: KeyCode,
121 pub modifiers: KeyModifiers,
122}
123
124impl KeyCombo {
125 pub fn parse(s: &str) -> Option<Self> {
130 let mut mods = KeyModifiers::NONE;
131 let mut code: Option<KeyCode> = None;
132 for part in s.split('+') {
133 let part = part.trim();
134 if part.is_empty() {
135 return None;
136 }
137 match part {
138 "Ctrl" | "Control" => mods |= KeyModifiers::CONTROL,
139 "Shift" => mods |= KeyModifiers::SHIFT,
140 "Alt" => mods |= KeyModifiers::ALT,
141 other => {
142 if code.is_some() {
143 return None;
147 }
148 code = Some(parse_key_name(other, mods)?);
149 }
150 }
151 }
152 Some(KeyCombo {
153 code: code?,
154 modifiers: mods,
155 })
156 }
157
158 #[allow(clippy::inherent_to_string)] pub fn to_string(&self) -> String {
163 let mut out = String::new();
164 if self.modifiers.contains(KeyModifiers::CONTROL) {
165 out.push_str("Ctrl+");
166 }
167 if self.modifiers.contains(KeyModifiers::ALT) {
168 out.push_str("Alt+");
169 }
170 if self.modifiers.contains(KeyModifiers::SHIFT) {
171 out.push_str("Shift+");
172 }
173 match self.code {
174 KeyCode::Enter => out.push_str("Enter"),
175 KeyCode::Esc => out.push_str("Esc"),
176 KeyCode::Tab => out.push_str("Tab"),
177 KeyCode::BackTab => out.push_str("BackTab"),
178 KeyCode::Backspace => out.push_str("Backspace"),
179 KeyCode::Delete => out.push_str("Delete"),
180 KeyCode::Home => out.push_str("Home"),
181 KeyCode::End => out.push_str("End"),
182 KeyCode::PageUp => out.push_str("PageUp"),
183 KeyCode::PageDown => out.push_str("PageDown"),
184 KeyCode::Up => out.push_str("Up"),
185 KeyCode::Down => out.push_str("Down"),
186 KeyCode::Left => out.push_str("Left"),
187 KeyCode::Right => out.push_str("Right"),
188 KeyCode::Char(c) => {
189 out.push(c);
193 }
194 _ => return self.code.to_string(),
197 }
198 out
199 }
200}
201
202fn parse_key_name(token: &str, mods: KeyModifiers) -> Option<KeyCode> {
208 let lower = token.to_ascii_lowercase();
212 if let Some(code) = match lower.as_str() {
213 "enter" | "return" | "cr" => Some(KeyCode::Enter),
214 "esc" | "escape" => Some(KeyCode::Esc),
215 "tab" => Some(KeyCode::Tab),
216 "backtab" | "shift-tab" | "shift+tab" => Some(KeyCode::BackTab),
217 "up" => Some(KeyCode::Up),
218 "down" => Some(KeyCode::Down),
219 "left" => Some(KeyCode::Left),
220 "right" => Some(KeyCode::Right),
221 "home" => Some(KeyCode::Home),
222 "end" => Some(KeyCode::End),
223 "pageup" | "page_up" | "pgup" => Some(KeyCode::PageUp),
224 "pagedown" | "page_down" | "pgdn" => Some(KeyCode::PageDown),
225 "backspace" | "bs" => Some(KeyCode::Backspace),
226 "delete" | "del" => Some(KeyCode::Delete),
227 _ => None,
228 } {
229 return Some(code);
230 }
231 if token.chars().count() == 1 {
234 let ch = token.chars().next()?;
235 let ch = if mods.contains(KeyModifiers::SHIFT) {
236 ch.to_ascii_uppercase()
237 } else {
238 ch
239 };
240 return Some(KeyCode::Char(ch));
241 }
242 None
243}
244
245#[derive(Clone)]
246pub struct Keymap {
247 bindings: HashMap<GlobalAction, Vec<KeyCombo>>,
248}
249
250impl Keymap {
251 pub fn from_settings(overrides: &HashMap<String, Vec<String>>) -> Self {
257 let mut bindings: HashMap<GlobalAction, Vec<KeyCombo>> = HashMap::new();
258 for (action, combo) in DEFAULT_KEYBINDINGS {
259 bindings
260 .entry(*action)
261 .or_default()
262 .push(KeyCombo::parse(combo).expect("DEFAULT_KEYBINDINGS parses"));
263 }
264 for (name, combos) in overrides {
265 let Some(action) = GlobalAction::from_name(name) else {
266 continue;
267 };
268 let parsed: Vec<KeyCombo> = combos
269 .iter()
270 .map(String::as_str)
271 .filter_map(KeyCombo::parse)
272 .collect();
273 if !parsed.is_empty() {
274 bindings.insert(action, parsed);
275 }
276 }
277 Keymap { bindings }
278 }
279
280 pub fn resolve(&self, key: KeyEvent) -> Option<GlobalAction> {
285 GlobalAction::all().find(|action| {
286 self.bindings.get(action).is_some_and(|combos| {
287 combos
288 .iter()
289 .any(|c| c.code == key.code && c.modifiers == key.modifiers)
290 })
291 })
292 }
293
294 pub fn matches(&self, action: GlobalAction, event: &KeyEvent) -> bool {
300 self.bindings.get(&action).is_some_and(|combos| {
301 combos
302 .iter()
303 .any(|c| c.code == event.code && c.modifiers == event.modifiers)
304 })
305 }
306
307 pub fn set_action(&mut self, action: GlobalAction, combos: Vec<KeyCombo>) {
310 self.bindings.insert(action, combos);
311 }
312
313 pub fn action_combos(&self, action: GlobalAction) -> &[KeyCombo] {
317 self.bindings.get(&action).map(Vec::as_slice).unwrap_or(&[])
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
325
326 fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
327 KeyEvent::new(code, mods)
328 }
329 fn ctrl(code: KeyCode) -> KeyEvent {
330 press(code, KeyModifiers::CONTROL)
331 }
332
333 #[test]
334 fn default_resolve_maps_ctrl_p_to_command_palette() {
335 let km = Keymap::from_settings(&HashMap::new());
336 let ev = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
337 assert_eq!(km.resolve(ev), Some(GlobalAction::OpenCommandPalette));
338 }
339
340 #[test]
341 fn user_override_adds_instead_of_replacing() {
342 let mut o: HashMap<String, Vec<String>> = HashMap::new();
343 o.insert("OpenCommandPalette".into(), vec!["Alt+p".into()]);
344 let km = Keymap::from_settings(&o);
345 assert_eq!(
349 km.resolve(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT)),
350 Some(GlobalAction::OpenCommandPalette),
351 "override combo Alt+P must resolve",
352 );
353 assert_eq!(
354 km.resolve(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
355 Some(GlobalAction::Interrupt),
356 "default Ctrl+C for unrelated action must still resolve",
357 );
358 assert_eq!(
359 km.resolve(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)),
360 None,
361 "original Ctrl+P is gone after override",
362 );
363 }
364
365 #[test]
366 fn override_keeps_other_actions_default() {
367 let mut o: HashMap<String, Vec<String>> = HashMap::new();
368 o.insert("OpenCommandPalette".into(), vec!["Alt+p".into()]);
369 let km = Keymap::from_settings(&o);
370 assert_eq!(
371 km.resolve(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)),
372 Some(GlobalAction::SendNow),
373 );
374 assert_eq!(
375 km.resolve(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)),
376 Some(GlobalAction::FoldAll),
377 );
378 }
379
380 #[test]
381 fn keycombo_roundtrips() {
382 for s in [
387 "Ctrl+c",
388 "Ctrl+m",
389 "Ctrl+Shift+E",
390 "Ctrl+Enter",
391 "Alt+p",
392 "PageUp",
393 "PageDown",
394 "?",
395 "Ctrl+l",
396 "Ctrl+g",
397 "Ctrl+t",
398 ] {
399 let parsed = KeyCombo::parse(s).unwrap_or_else(|| panic!("parse failed: {s}"));
400 assert_eq!(parsed.to_string(), s, "round-trip mismatch for {s}");
401 }
402 let lower = KeyCombo::parse("Ctrl+Shift+e").unwrap();
404 let upper = KeyCombo::parse("Ctrl+Shift+E").unwrap();
405 assert_eq!(lower, upper, "lowercase Shift+E normalizes to uppercase",);
406 assert_eq!(lower.to_string(), "Ctrl+Shift+E");
407 }
408
409 #[test]
410 fn shifted_letter_serializes_uppercase() {
411 let combo = KeyCombo {
412 code: KeyCode::Char('E'),
413 modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
414 };
415 assert_eq!(combo.to_string(), "Ctrl+Shift+E");
416 assert_eq!(KeyCombo::parse("Ctrl+Shift+E").unwrap(), combo);
417 }
418
419 #[test]
420 fn plain_char_is_not_a_global_action() {
421 let km = Keymap::from_settings(&HashMap::new());
422 assert_eq!(
423 km.resolve(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
424 None,
425 );
426 }
427
428 #[test]
429 fn from_name_and_all_are_inverse() {
430 for action in GlobalAction::all() {
431 assert_eq!(GlobalAction::from_name(action.name()), Some(action));
432 }
433 assert_eq!(GlobalAction::from_name("NotAnAction"), None);
434 }
435
436 #[test]
437 fn set_action_replaces_combos() {
438 let mut km = Keymap::from_settings(&HashMap::new());
439 let new_combos = vec![KeyCombo::parse("Alt+x").unwrap()];
440 km.set_action(GlobalAction::OpenCommandPalette, new_combos);
441 assert_eq!(
442 km.resolve(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
443 Some(GlobalAction::OpenCommandPalette),
444 );
445 assert_eq!(
446 km.resolve(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)),
447 None,
448 "old Ctrl+P must be gone after set_action",
449 );
450 }
451
452 #[test]
453 fn parse_rejects_unknown_segments_and_empty_payload() {
454 assert!(KeyCombo::parse("Ctrl+Foo").is_none());
455 assert!(KeyCombo::parse("Ctrl+").is_none());
458 assert!(KeyCombo::parse("Ctrl+Ctrl+p").is_some());
461 }
462
463 #[test]
464 fn shared_combo_resolves_in_default_bindings_order() {
465 let mut o: HashMap<String, Vec<String>> = HashMap::new();
471 o.insert("Interrupt".into(), vec!["Ctrl+c".into(), "Ctrl+p".into()]);
472 let km = Keymap::from_settings(&o);
473 let ev = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
474 assert_eq!(km.resolve(ev), Some(GlobalAction::Interrupt));
475 }
476
477 #[test]
482 fn defaults_match_unified_hardcoded_keys() {
483 let km = Keymap::from_settings(&HashMap::new());
484
485 assert!(km.matches(GlobalAction::Interrupt, &ctrl(KeyCode::Char('c'))));
487 assert!(!km.matches(
488 GlobalAction::Interrupt,
489 &press(KeyCode::Char('c'), KeyModifiers::NONE),
490 ));
491
492 assert!(km.matches(
494 GlobalAction::Submit,
495 &press(KeyCode::Enter, KeyModifiers::NONE),
496 ));
497 assert!(!km.matches(GlobalAction::Submit, &ctrl(KeyCode::Enter)));
498
499 assert!(km.matches(GlobalAction::SendNow, &ctrl(KeyCode::Enter)));
501 assert!(!km.matches(
502 GlobalAction::SendNow,
503 &press(KeyCode::Enter, KeyModifiers::NONE),
504 ));
505
506 assert!(km.matches(GlobalAction::ToggleQueuePanel, &ctrl(KeyCode::Char(';')),));
508 assert!(!km.matches(
509 GlobalAction::ToggleQueuePanel,
510 &press(KeyCode::Char(';'), KeyModifiers::NONE),
511 ));
512
513 assert!(km.matches(
516 GlobalAction::ScrollUp,
517 &press(KeyCode::PageUp, KeyModifiers::NONE),
518 ));
519 assert!(!km.matches(
520 GlobalAction::ScrollUp,
521 &press(KeyCode::Up, KeyModifiers::NONE),
522 ));
523
524 assert!(km.matches(
526 GlobalAction::ScrollDown,
527 &press(KeyCode::PageDown, KeyModifiers::NONE),
528 ));
529 assert!(!km.matches(
530 GlobalAction::ScrollDown,
531 &press(KeyCode::Down, KeyModifiers::NONE),
532 ));
533
534 assert!(km.matches(GlobalAction::Clear, &ctrl(KeyCode::Char('l'))));
538 assert!(!km.matches(
539 GlobalAction::Clear,
540 &press(KeyCode::Char('l'), KeyModifiers::NONE),
541 ));
542
543 assert!(km.matches(
545 GlobalAction::Help,
546 &press(KeyCode::Char('?'), KeyModifiers::NONE),
547 ));
548 assert!(!km.matches(GlobalAction::Help, &ctrl(KeyCode::Char('?'))));
549
550 assert!(km.matches(GlobalAction::ModelPicker, &ctrl(KeyCode::Char('g'))));
552 assert!(!km.matches(
553 GlobalAction::ModelPicker,
554 &press(KeyCode::Char('g'), KeyModifiers::NONE),
555 ));
556
557 assert!(km.matches(GlobalAction::ToggleThinking, &ctrl(KeyCode::Char('t')),));
559 assert!(!km.matches(
560 GlobalAction::ToggleThinking,
561 &press(KeyCode::Char('t'), KeyModifiers::NONE),
562 ));
563
564 assert!(km.matches(GlobalAction::ToggleMultiline, &ctrl(KeyCode::Char('m')),));
566
567 assert!(km.matches(GlobalAction::FoldAll, &ctrl(KeyCode::Char('e'))));
569 }
570
571 #[test]
576 fn matches_handles_ctrl_enter() {
577 let km = Keymap::from_settings(&HashMap::new());
578 let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
579 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
580
581 assert!(km.matches(GlobalAction::SendNow, &ctrl_enter));
582 assert!(!km.matches(GlobalAction::Submit, &ctrl_enter));
583 assert!(km.matches(GlobalAction::Submit, &enter));
584 assert!(!km.matches(GlobalAction::SendNow, &enter));
585 }
586
587 #[test]
590 fn parse_rejects_garbage_payload() {
591 assert!(KeyCombo::parse("Ctrl+").is_none());
593 assert!(KeyCombo::parse("notakey").is_none());
596 assert!(KeyCombo::parse("a+b").is_none());
598 }
599}