1use hashbrown::{HashMap, HashSet};
2use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use std::time::{Duration, Instant};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum Action {
13 Interrupt,
15 Exit,
17 BackgroundOperation,
19 OpenModelPicker,
21 ClearScreen,
23 ScrollPageUp,
25 ScrollPageDown,
27 EditQueue,
29 HistoryPrevious,
31 HistoryNext,
33 ToggleLogs,
35 ToggleToolDisplayMode,
37 ToggleTaskPanel,
39 OpenTranscriptReview,
41 ToggleTranscriptRenderMode,
43 GeneratePromptSuggestion,
45 JumpToLastChange,
47}
48
49impl Action {
50 fn name(self) -> &'static str {
52 match self {
53 Action::Interrupt => "interrupt",
54 Action::Exit => "exit",
55 Action::BackgroundOperation => "background_operation",
56 Action::OpenModelPicker => "open_model_picker",
57 Action::ClearScreen => "clear_screen",
58 Action::ScrollPageUp => "scroll_page_up",
59 Action::ScrollPageDown => "scroll_page_down",
60 Action::EditQueue => "edit_queue",
61 Action::HistoryPrevious => "history_previous",
62 Action::HistoryNext => "history_next",
63 Action::ToggleLogs => "toggle_logs",
64 Action::ToggleToolDisplayMode => "toggle_tool_display_mode",
65 Action::ToggleTaskPanel => "toggle_task_panel",
66 Action::OpenTranscriptReview => "open_transcript_review",
67 Action::ToggleTranscriptRenderMode => "toggle_transcript_render_mode",
68 Action::GeneratePromptSuggestion => "generate_prompt_suggestion",
69 Action::JumpToLastChange => "jump_to_last_change",
70 }
71 }
72
73 fn all() -> &'static [Action] {
75 &[
76 Action::Interrupt,
77 Action::Exit,
78 Action::BackgroundOperation,
79 Action::OpenModelPicker,
80 Action::ClearScreen,
81 Action::ScrollPageUp,
82 Action::ScrollPageDown,
83 Action::EditQueue,
84 Action::HistoryPrevious,
85 Action::HistoryNext,
86 Action::ToggleLogs,
87 Action::ToggleToolDisplayMode,
88 Action::ToggleTaskPanel,
89 Action::OpenTranscriptReview,
90 Action::ToggleTranscriptRenderMode,
91 Action::GeneratePromptSuggestion,
92 Action::JumpToLastChange,
93 ]
94 }
95
96 fn from_name(name: &str) -> Option<Self> {
98 Self::all().iter().find(|a| a.name() == name).copied()
99 }
100}
101
102pub fn parse_key_binding(s: &str) -> Option<(KeyCode, KeyModifiers)> {
109 let normalized = s.trim().to_ascii_lowercase();
110 let s = normalized.as_str();
111 if s.is_empty() {
112 return None;
113 }
114
115 let parts: Vec<&str> = s.split('+').collect();
116 let (modifiers, key_part) = if parts.len() == 1 {
117 (KeyModifiers::empty(), parts[0])
118 } else {
119 let mut mods = KeyModifiers::empty();
120 for part in &parts[..parts.len() - 1] {
121 match *part {
122 "ctrl" | "control" => mods.insert(KeyModifiers::CONTROL),
123 "shift" => mods.insert(KeyModifiers::SHIFT),
124 "alt" | "option" => mods.insert(KeyModifiers::ALT),
125 "meta" => mods.insert(KeyModifiers::META),
126 "cmd" | "command" | "super" | "gui" | "win" => {
127 mods.insert(KeyModifiers::SUPER);
128 }
129 _ => return None,
130 }
131 }
132 (mods, parts[parts.len() - 1])
133 };
134
135 let code = match key_part {
136 "enter" => KeyCode::Enter,
137 "tab" => KeyCode::Tab,
138 "backtab" => KeyCode::BackTab,
139 "esc" | "escape" => KeyCode::Esc,
140 "backspace" => KeyCode::Backspace,
141 "delete" => KeyCode::Delete,
142 "space" => KeyCode::Char(' '),
143 "up" => KeyCode::Up,
144 "down" => KeyCode::Down,
145 "left" => KeyCode::Left,
146 "right" => KeyCode::Right,
147 "pageup" => KeyCode::PageUp,
148 "pagedown" => KeyCode::PageDown,
149 "home" => KeyCode::Home,
150 "end" => KeyCode::End,
151 "insert" => KeyCode::Insert,
152 "null" => KeyCode::Null,
153 "capslock" => KeyCode::CapsLock,
154 "scrolllock" => KeyCode::ScrollLock,
155 "numlock" => KeyCode::NumLock,
156 "printscreen" => KeyCode::PrintScreen,
157 "pause" => KeyCode::Pause,
158 "menu" => KeyCode::Menu,
159 name if name.starts_with('f') && name.len() > 1 => {
160 let n: u8 = name[1..].parse().ok()?;
161 match n {
162 1 => KeyCode::F(1),
163 2 => KeyCode::F(2),
164 3 => KeyCode::F(3),
165 4 => KeyCode::F(4),
166 5 => KeyCode::F(5),
167 6 => KeyCode::F(6),
168 7 => KeyCode::F(7),
169 8 => KeyCode::F(8),
170 9 => KeyCode::F(9),
171 10 => KeyCode::F(10),
172 11 => KeyCode::F(11),
173 12 => KeyCode::F(12),
174 _ => return None,
175 }
176 }
177 ch => {
178 let chars: Vec<char> = ch.chars().collect();
179 if chars.len() == 1 {
180 KeyCode::Char(chars[0])
181 } else {
182 return None;
183 }
184 }
185 };
186
187 Some((code, modifiers))
188}
189
190fn default_bindings() -> HashMap<Action, Vec<(KeyCode, KeyModifiers)>> {
192 use Action::*;
193 let mut m = HashMap::new();
194
195 m.insert(
196 Interrupt,
197 vec![
198 (KeyCode::Char('c'), KeyModifiers::CONTROL),
199 (KeyCode::Char('C'), KeyModifiers::CONTROL),
200 (KeyCode::Char('\u{3}'), KeyModifiers::empty()),
201 ],
202 );
203 m.insert(
204 Exit,
205 vec![
206 (KeyCode::Char('d'), KeyModifiers::CONTROL),
207 (KeyCode::Char('D'), KeyModifiers::CONTROL),
208 ],
209 );
210 m.insert(
211 BackgroundOperation,
212 vec![
213 (KeyCode::Char('b'), KeyModifiers::CONTROL),
214 (KeyCode::Char('B'), KeyModifiers::CONTROL),
215 ],
216 );
217 m.insert(
218 OpenModelPicker,
219 vec![
220 (KeyCode::Char('m'), KeyModifiers::CONTROL),
221 (KeyCode::Char('M'), KeyModifiers::CONTROL),
222 ],
223 );
224 m.insert(
225 ClearScreen,
226 vec![
227 (KeyCode::Char('l'), KeyModifiers::CONTROL),
228 (KeyCode::Char('L'), KeyModifiers::CONTROL),
229 ],
230 );
231 m.insert(ScrollPageUp, vec![(KeyCode::PageUp, KeyModifiers::empty())]);
232 m.insert(ScrollPageDown, vec![(KeyCode::PageDown, KeyModifiers::empty())]);
233
234 m.insert(EditQueue, vec![(KeyCode::Up, KeyModifiers::ALT), (KeyCode::Up, KeyModifiers::META)]);
235
236 m.insert(HistoryPrevious, vec![(KeyCode::Up, KeyModifiers::empty())]);
237 m.insert(HistoryNext, vec![(KeyCode::Down, KeyModifiers::empty())]);
238
239 m.insert(
240 ToggleToolDisplayMode,
241 vec![
242 (KeyCode::Char('t'), KeyModifiers::ALT),
243 (KeyCode::Char('T'), KeyModifiers::ALT),
244 ],
245 );
246 m.insert(
247 ToggleTaskPanel,
248 vec![
249 (KeyCode::Char('g'), KeyModifiers::ALT),
250 (KeyCode::Char('G'), KeyModifiers::ALT),
251 ],
252 );
253 m.insert(
254 OpenTranscriptReview,
255 vec![
256 (KeyCode::Char('t'), KeyModifiers::CONTROL),
257 (KeyCode::Char('T'), KeyModifiers::CONTROL),
258 ],
259 );
260 m.insert(ToggleTranscriptRenderMode, vec![(KeyCode::Char('r'), KeyModifiers::empty())]);
261 m.insert(
262 GeneratePromptSuggestion,
263 vec![
264 (KeyCode::Char('p'), KeyModifiers::ALT),
265 (KeyCode::Char('P'), KeyModifiers::ALT),
266 ],
267 );
268 m.insert(JumpToLastChange, vec![(KeyCode::End, KeyModifiers::CONTROL)]);
269
270 m
271}
272
273#[derive(Debug, Clone)]
278pub struct BindingStore {
279 entries: Vec<(KeyCode, KeyModifiers, Action)>,
281 primary_labels: HashMap<Action, String>,
283 overridden_actions: HashSet<Action>,
285}
286
287impl Default for BindingStore {
288 fn default() -> Self {
289 Self::defaults()
290 }
291}
292
293impl BindingStore {
294 pub(crate) fn new(overlay: HashMap<String, Vec<String>>) -> Self {
300 let mut merged: HashMap<Action, Vec<(KeyCode, KeyModifiers)>> = default_bindings();
301 let mut configured_actions = HashSet::new();
302
303 for (action_name, key_specs) in overlay {
304 let Some(action) = Action::from_name(&action_name) else {
305 tracing::debug!(%action_name, "unknown action in keybinding overlay, skipping");
306 continue;
307 };
308
309 let parsed: Vec<(KeyCode, KeyModifiers)> = key_specs.iter().filter_map(|s| parse_key_binding(s)).collect();
310
311 if key_specs.is_empty() {
312 merged.remove(&action);
314 configured_actions.insert(action);
315 } else if parsed.is_empty() {
316 tracing::warn!(
317 action = action.name(),
318 "keybinding overlay contains no valid bindings; keeping defaults"
319 );
320 } else {
321 merged.insert(action, parsed);
322 configured_actions.insert(action);
323 }
324 }
325
326 let mut entries = Vec::new();
327 for action in Action::all()
331 .iter()
332 .copied()
333 .filter(|action| configured_actions.contains(action))
334 .chain(
335 Action::all()
336 .iter()
337 .copied()
338 .filter(|action| !configured_actions.contains(action)),
339 )
340 {
341 let Some(keys) = merged.get(&action) else {
342 continue;
343 };
344 for &(code, mods) in keys {
345 entries.push((code, mods, action));
346 }
347 }
348
349 let mut primary_labels = HashMap::new();
354 let mut claimed_bindings = Vec::with_capacity(entries.len());
355 for &(code, mods, action) in &entries {
356 let is_reachable = !claimed_bindings
357 .iter()
358 .any(|&(claimed_code, claimed_mods)| binding_covers(claimed_code, claimed_mods, code, mods));
359 if is_reachable {
360 primary_labels.entry(action).or_insert_with(|| format_key_label(code, mods));
361 }
362 claimed_bindings.push((code, mods));
363 }
364
365 Self {
366 entries,
367 primary_labels,
368 overridden_actions: configured_actions,
369 }
370 }
371
372 fn defaults() -> Self {
374 Self::new(HashMap::new())
375 }
376
377 pub(crate) fn resolve(&self, key: &KeyEvent) -> Option<Action> {
382 let (key_code, key_modifiers) = normalize_terminal_control_key(key.code, key.modifiers);
387 let mut best: Option<(usize, Action)> = None;
388
389 for (i, &(code, mods, action)) in self.entries.iter().enumerate() {
392 let code_match = match (code, key_code) {
393 (KeyCode::Char(bc), KeyCode::Char(kc)) if bc.eq_ignore_ascii_case(&kc) => true,
394 _ => code == key_code,
395 };
396
397 if !code_match {
398 continue;
399 }
400
401 if !key_modifiers.contains(mods) {
403 continue;
404 }
405
406 let char_shift_grace = if let KeyCode::Char(_) = key_code {
409 KeyModifiers::SHIFT
410 } else {
411 KeyModifiers::empty()
412 };
413 let extra = key_modifiers.difference(mods);
414 if extra.intersection(!char_shift_grace) != KeyModifiers::empty() {
415 continue;
416 }
417
418 best = match best {
421 None => Some((i, action)),
422 Some((bi, _)) if i < bi => Some((i, action)),
423 Some(other) => Some(other),
424 };
425 }
426
427 best.map(|(_, action)| action)
428 }
429
430 pub(crate) fn primary_key_label(&self, action: Action) -> Option<&str> {
431 self.primary_labels.get(&action).map(String::as_str)
432 }
433
434 pub(crate) fn action_is_overridden(&self, action: Action) -> bool {
435 self.overridden_actions.contains(&action)
436 }
437}
438
439fn binding_covers(
440 existing_code: KeyCode,
441 existing_modifiers: KeyModifiers,
442 candidate_code: KeyCode,
443 candidate_modifiers: KeyModifiers,
444) -> bool {
445 let code_matches = match (existing_code, candidate_code) {
446 (KeyCode::Char(existing), KeyCode::Char(candidate)) => existing.eq_ignore_ascii_case(&candidate),
447 _ => existing_code == candidate_code,
448 };
449 if !code_matches || !candidate_modifiers.contains(existing_modifiers) {
450 return false;
451 }
452
453 let char_shift_grace = if matches!(candidate_code, KeyCode::Char(_)) {
454 KeyModifiers::SHIFT
455 } else {
456 KeyModifiers::empty()
457 };
458 candidate_modifiers
459 .difference(existing_modifiers)
460 .intersection(!char_shift_grace)
461 == KeyModifiers::empty()
462}
463
464pub(crate) fn normalize_terminal_control_event(mut key: KeyEvent) -> KeyEvent {
465 let (code, modifiers) = normalize_terminal_control_key(key.code, key.modifiers);
466 key.code = code;
467 key.modifiers = modifiers;
468 key
469}
470
471pub(crate) const DOUBLE_ESCAPE_WINDOW: Duration = Duration::from_millis(800);
478
479pub(crate) fn is_double_escape_press(last_press: Option<Instant>, now: Instant) -> bool {
484 last_press.is_some_and(|last| now.duration_since(last) <= DOUBLE_ESCAPE_WINDOW)
485}
486
487pub(crate) fn is_readline_editing_key(key: &KeyEvent) -> bool {
490 let modifiers = key.modifiers;
491 let has_control = modifiers.contains(KeyModifiers::CONTROL);
492 let has_alt = modifiers.contains(KeyModifiers::ALT);
493 let has_command = modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::META);
494
495 if has_command {
496 return false;
497 }
498
499 (has_control
500 && !has_alt
501 && matches!(
502 key.code,
503 KeyCode::Char('f')
504 | KeyCode::Char('F')
505 | KeyCode::Char('p')
506 | KeyCode::Char('P')
507 | KeyCode::Char('n')
508 | KeyCode::Char('N')
509 | KeyCode::Char('t')
510 | KeyCode::Char('T')
511 ))
512 || (has_alt
513 && !has_control
514 && matches!(
515 key.code,
516 KeyCode::Char('d')
517 | KeyCode::Char('D')
518 | KeyCode::Char('u')
519 | KeyCode::Char('U')
520 | KeyCode::Char('l')
521 | KeyCode::Char('L')
522 | KeyCode::Char('c')
523 | KeyCode::Char('C')
524 | KeyCode::Char('\\')
525 ))
526}
527
528fn normalize_terminal_control_key(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
529 let Some(control) = (match code {
530 KeyCode::Char(character) => Some(character as u32),
531 _ => None,
532 }) else {
533 return (code, modifiers);
534 };
535
536 if !(1..=26).contains(&control) || matches!(control, 9 | 13) {
540 return (code, modifiers);
541 }
542
543 let Some(letter) = char::from_u32(u32::from(b'a') + control - 1) else {
544 return (code, modifiers);
545 };
546 (KeyCode::Char(letter), modifiers | KeyModifiers::CONTROL)
547}
548
549fn format_key_label(code: KeyCode, modifiers: KeyModifiers) -> String {
550 let mut parts = Vec::with_capacity(5);
551 if modifiers.contains(KeyModifiers::CONTROL) {
552 parts.push("Ctrl");
553 }
554 if modifiers.contains(KeyModifiers::ALT) {
555 parts.push("Alt");
556 }
557 if modifiers.contains(KeyModifiers::SUPER) {
558 parts.push("Cmd");
559 } else if modifiers.contains(KeyModifiers::META) {
560 parts.push("Meta");
561 }
562 if modifiers.contains(KeyModifiers::SHIFT) {
563 parts.push("Shift");
564 }
565
566 let key = match code {
567 KeyCode::Char(' ') => "Space".to_string(),
568 KeyCode::Char(character) => character.to_ascii_uppercase().to_string(),
569 KeyCode::Enter => "Enter".to_string(),
570 KeyCode::Tab => "Tab".to_string(),
571 KeyCode::BackTab => "Backtab".to_string(),
572 KeyCode::Esc => "Esc".to_string(),
573 KeyCode::Backspace => "Backspace".to_string(),
574 KeyCode::Delete => "Delete".to_string(),
575 KeyCode::Insert => "Insert".to_string(),
576 KeyCode::Up => "Up".to_string(),
577 KeyCode::Down => "Down".to_string(),
578 KeyCode::Left => "Left".to_string(),
579 KeyCode::Right => "Right".to_string(),
580 KeyCode::PageUp => "PageUp".to_string(),
581 KeyCode::PageDown => "PageDown".to_string(),
582 KeyCode::Home => "Home".to_string(),
583 KeyCode::End => "End".to_string(),
584 KeyCode::F(number) => format!("F{number}"),
585 other => format!("{other:?}"),
586 };
587 parts.push(key.as_str());
588 parts.join("+")
589}
590
591impl super::Session {
592 pub(crate) fn resolve_rebindable_action(&self, key: &KeyEvent) -> Option<Action> {
593 self.bindings.resolve(key)
594 }
595
596 pub(crate) fn dispatch_rebindable_action(&mut self, action: Action) -> Option<super::super::types::InlineEvent> {
597 super::events::dispatch_rebindable_action(self, action)
598 }
599
600 pub(crate) fn primary_binding_label(&self, action: Action) -> Option<&str> {
601 self.bindings.primary_key_label(action)
602 }
603
604 pub(crate) fn rebindable_action_is_overridden(&self, action: Action) -> bool {
605 self.bindings.action_is_overridden(action)
606 }
607
608 pub(crate) fn set_bindings(&mut self, bindings: BindingStore) {
609 self.bindings = bindings;
610 self.mark_visual_dirty();
611 }
612
613 pub(crate) fn transcript_review_hints_visible(&self) -> bool {
614 self.appearance.show_transcript_review_hints
615 }
616
617 pub(crate) fn transcript_review_shortcut_guide_visible(&self) -> bool {
618 self.appearance.show_transcript_review_shortcut_guide
619 }
620
621 pub(crate) fn transcript_review_close_button_visible(&self) -> bool {
622 self.appearance.show_transcript_review_close_button
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use ratatui::crossterm::event::KeyEvent;
630
631 #[test]
632 fn test_parse_key_binding_simple() {
633 let (code, mods) = parse_key_binding("ctrl+c").unwrap();
634 assert_eq!(code, KeyCode::Char('c'));
635 assert!(mods.contains(KeyModifiers::CONTROL));
636 assert!(!mods.contains(KeyModifiers::SHIFT));
637 }
638
639 #[test]
640 fn test_parse_key_binding_modifier_combos() {
641 let (code, mods) = parse_key_binding("ctrl+shift+enter").unwrap();
642 assert_eq!(code, KeyCode::Enter);
643 assert!(mods.contains(KeyModifiers::CONTROL));
644 assert!(mods.contains(KeyModifiers::SHIFT));
645 }
646
647 #[test]
648 fn test_parse_key_binding_is_case_insensitive() {
649 let (code, mods) = parse_key_binding("CTRL+SHIFT+X").unwrap();
650 assert_eq!(code, KeyCode::Char('x'));
651 assert!(mods.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT));
652 }
653
654 #[test]
655 fn test_parse_key_binding_func_keys() {
656 let (code, _) = parse_key_binding("f5").unwrap();
657 assert_eq!(code, KeyCode::F(5));
658 }
659
660 #[test]
661 fn test_parse_key_binding_special() {
662 let (code, _) = parse_key_binding("pageup").unwrap();
663 assert_eq!(code, KeyCode::PageUp);
664 let (code, _) = parse_key_binding("backtab").unwrap();
665 assert_eq!(code, KeyCode::BackTab);
666 }
667
668 #[test]
669 fn test_default_bindings_resolve() {
670 let store = BindingStore::defaults();
671
672 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
674 assert_eq!(store.resolve(&key), Some(Action::Interrupt));
675
676 let key = KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty());
678 assert_eq!(store.resolve(&key), Some(Action::ScrollPageUp));
679
680 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT);
682 assert_eq!(store.resolve(&key), Some(Action::EditQueue));
683
684 let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
686 assert_eq!(store.resolve(&key), None);
687 }
688
689 #[test]
690 fn test_default_bindings_case_insensitive() {
691 let store = BindingStore::defaults();
692
693 let key = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL);
695 assert_eq!(store.resolve(&key), Some(Action::Interrupt));
696 }
697
698 #[test]
699 fn test_default_tool_display_binding_is_alt_t() {
700 let store = BindingStore::defaults();
701 let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::ALT);
702 assert_eq!(store.resolve(&key), Some(Action::ToggleToolDisplayMode));
703 }
704
705 #[test]
706 fn test_default_task_panel_binding_is_alt_g() {
707 let store = BindingStore::defaults();
708 let key = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::ALT);
709 assert_eq!(store.resolve(&key), Some(Action::ToggleTaskPanel));
710 }
711
712 #[test]
713 fn test_default_transcript_review_bindings() {
714 let store = BindingStore::defaults();
715 assert_eq!(
716 store.resolve(&KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
717 Some(Action::OpenTranscriptReview)
718 );
719 assert_eq!(
720 store.resolve(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty())),
721 Some(Action::ToggleTranscriptRenderMode)
722 );
723 }
724
725 #[test]
726 fn test_raw_control_character_matches_transcript_review_binding() {
727 let store = BindingStore::defaults();
728
729 assert_eq!(
730 store.resolve(&KeyEvent::new(KeyCode::Char('\u{14}'), KeyModifiers::empty())),
731 Some(Action::OpenTranscriptReview)
732 );
733 }
734
735 #[test]
736 fn test_transcript_review_bindings_can_be_rebound() {
737 let mut overlay = HashMap::new();
738 overlay.insert("open_transcript_review".to_string(), vec!["ctrl+x".to_string()]);
739 overlay.insert("toggle_transcript_render_mode".to_string(), vec!["alt+x".to_string()]);
740 let store = BindingStore::new(overlay);
741
742 assert_eq!(
743 store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
744 Some(Action::OpenTranscriptReview)
745 );
746 assert_eq!(
747 store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
748 Some(Action::ToggleTranscriptRenderMode)
749 );
750 assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty())), None);
751 }
752
753 #[test]
754 fn test_task_panel_binding_can_be_rebound() {
755 let mut overlay = HashMap::new();
756 overlay.insert("toggle_task_panel".to_string(), vec!["ctrl+x".to_string()]);
757 let store = BindingStore::new(overlay);
758
759 assert_eq!(
760 store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
761 Some(Action::ToggleTaskPanel)
762 );
763 assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::ALT)), None);
764 }
765
766 #[test]
767 fn test_tool_display_binding_can_be_rebound() {
768 let mut overlay = HashMap::new();
769 overlay.insert("toggle_tool_display_mode".to_string(), vec!["ctrl+x".to_string()]);
770 let store = BindingStore::new(overlay);
771
772 assert_eq!(
773 store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
774 Some(Action::ToggleToolDisplayMode)
775 );
776 assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('t'), KeyModifiers::ALT)), None);
777 }
778
779 #[test]
780 fn test_user_overlay_overrides_default() {
781 let mut overlay = HashMap::new();
782 overlay.insert("interrupt".to_string(), vec!["ctrl+x".to_string()]);
783 let store = BindingStore::new(overlay);
784
785 let key_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
787 assert_eq!(store.resolve(&key_c), None);
788
789 let key_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
791 assert_eq!(store.resolve(&key_x), Some(Action::Interrupt));
792 }
793
794 #[test]
795 fn test_user_overlay_unbind() {
796 let mut overlay = HashMap::new();
797 overlay.insert("interrupt".to_string(), Vec::new());
798 let store = BindingStore::new(overlay);
799
800 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
801 assert_eq!(store.resolve(&key), None);
802 }
803
804 #[test]
805 fn test_invalid_overlay_keeps_defaults_and_does_not_override_action() {
806 let mut overlay = HashMap::new();
807 overlay.insert("interrupt".to_string(), vec!["not-a-key".to_string()]);
808 let store = BindingStore::new(overlay);
809
810 assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), Some(Action::Interrupt));
811 assert!(!store.action_is_overridden(Action::Interrupt));
812 }
813
814 #[test]
815 fn test_configured_binding_collision_is_deterministic() {
816 let mut overlay = HashMap::new();
817 overlay.insert("interrupt".to_string(), vec!["ctrl+x".to_string()]);
818 overlay.insert("open_model_picker".to_string(), vec!["ctrl+x".to_string()]);
819 let store = BindingStore::new(overlay);
820
821 assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), Some(Action::Interrupt));
822 assert_eq!(store.primary_key_label(Action::Interrupt), Some("Ctrl+X"));
823 assert_eq!(store.primary_key_label(Action::OpenModelPicker), None);
824 }
825
826 #[test]
827 fn test_parse_invalid_key() {
828 assert!(parse_key_binding("").is_none());
829 assert!(parse_key_binding("invalid_key_name").is_none());
830 assert!(parse_key_binding("ctrl+invalid").is_none());
831 assert!(parse_key_binding("+ctrl+c").is_none());
832 }
833
834 #[test]
835 fn test_action_name_roundtrip() {
836 for action in Action::all() {
837 let name = action.name();
838 let parsed = Action::from_name(name);
839 assert_eq!(parsed, Some(*action));
840 }
841 }
842
843 #[test]
844 fn test_action_from_name_unknown() {
845 assert_eq!(Action::from_name("nonexistent"), None);
846 }
847
848 #[test]
849 fn double_escape_press_requires_an_armed_consecutive_press() {
850 let now = Instant::now();
851 let exactly_at_window = now.checked_sub(DOUBLE_ESCAPE_WINDOW).expect("clock is past the window");
852 let beyond_window = now
853 .checked_sub(DOUBLE_ESCAPE_WINDOW + Duration::from_millis(1))
854 .expect("clock is past the window");
855
856 assert!(!is_double_escape_press(None, now));
857 assert!(is_double_escape_press(Some(now), now));
858 assert!(is_double_escape_press(Some(exactly_at_window), now));
859 assert!(!is_double_escape_press(Some(beyond_window), now));
860 }
861}