Skip to main content

rpi_tui/
keybindings.rs

1//! Keybinding system for TUI.
2//!
3//! Provides configurable keyboard shortcuts for TUI actions.
4//! This is a 1:1 port of the TypeScript keybindings.ts
5
6use std::collections::HashMap;
7
8use crossterm::event::{KeyCode, KeyModifiers};
9
10/// A keybinding identifier.
11pub type KeybindingId = &'static str;
12
13/// A key combination.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct KeyCombo {
16    pub code: KeyCode,
17    pub modifiers: KeyModifiers,
18}
19
20impl KeyCombo {
21    /// Create a new key combination.
22    pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
23        Self { code, modifiers }
24    }
25
26    /// Create from just a key code (no modifiers).
27    pub fn from_code(code: KeyCode) -> Self {
28        Self {
29            code,
30            modifiers: KeyModifiers::NONE,
31        }
32    }
33
34    /// Add Ctrl modifier.
35    pub fn ctrl(self) -> Self {
36        Self {
37            modifiers: self.modifiers | KeyModifiers::CONTROL,
38            ..self
39        }
40    }
41
42    /// Add Shift modifier.
43    pub fn shift(self) -> Self {
44        Self {
45            modifiers: self.modifiers | KeyModifiers::SHIFT,
46            ..self
47        }
48    }
49
50    /// Add Alt modifier.
51    pub fn alt(self) -> Self {
52        Self {
53            modifiers: self.modifiers | KeyModifiers::ALT,
54            ..self
55        }
56    }
57
58    /// Add Super modifier.
59    pub fn super_key(self) -> Self {
60        Self {
61            modifiers: self.modifiers | KeyModifiers::SUPER,
62            ..self
63        }
64    }
65
66    /// Human-readable key text for hints (mirrors upstream's `formatKeyText`,
67    /// e.g. `Alt+Q`, `Ctrl+Shift+F`, `Enter`). Modifiers are ordered
68    /// Ctrl, Alt, Shift, Super.
69    pub fn display(&self) -> String {
70        let mut parts: Vec<String> = Vec::new();
71        if self.modifiers.contains(KeyModifiers::CONTROL) {
72            parts.push("Ctrl".into());
73        }
74        if self.modifiers.contains(KeyModifiers::ALT) {
75            parts.push("Alt".into());
76        }
77        // `BackTab` already carries the shift; don't render it twice.
78        if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
79            parts.push("Shift".into());
80        }
81        if self.modifiers.contains(KeyModifiers::SUPER) {
82            parts.push("Super".into());
83        }
84        let key = match self.code {
85            KeyCode::Char(c) => c.to_uppercase().to_string(),
86            KeyCode::Enter => "Enter".into(),
87            KeyCode::Esc => "Esc".into(),
88            KeyCode::Tab | KeyCode::BackTab => "Tab".into(),
89            KeyCode::Backspace => "Backspace".into(),
90            KeyCode::Delete => "Delete".into(),
91            KeyCode::Insert => "Insert".into(),
92            KeyCode::Up => "Up".into(),
93            KeyCode::Down => "Down".into(),
94            KeyCode::Left => "Left".into(),
95            KeyCode::Right => "Right".into(),
96            KeyCode::Home => "Home".into(),
97            KeyCode::End => "End".into(),
98            KeyCode::PageUp => "PageUp".into(),
99            KeyCode::PageDown => "PageDown".into(),
100            other => format!("{other:?}"),
101        };
102        parts.push(key);
103        parts.join("+")
104    }
105}
106
107/// Keybinding definition.
108#[derive(Debug, Clone)]
109pub struct KeybindingDefinition {
110    pub default_keys: Vec<KeyCombo>,
111    pub description: Option<&'static str>,
112}
113
114/// Keybinding conflict.
115#[derive(Debug, Clone)]
116pub struct KeybindingConflict {
117    pub key: KeyCombo,
118    pub keybindings: Vec<KeybindingId>,
119}
120
121/// Keybindings manager.
122#[derive(Clone)]
123pub struct Keybindings {
124    definitions: HashMap<KeybindingId, KeybindingDefinition>,
125    user_bindings: HashMap<KeybindingId, Vec<KeyCombo>>,
126    keys_by_id: HashMap<KeybindingId, Vec<KeyCombo>>,
127    conflicts: Vec<KeybindingConflict>,
128}
129
130impl Keybindings {
131    /// Create a new keybindings manager with default bindings.
132    pub fn new() -> Self {
133        let definitions = Self::default_definitions();
134        let keys_by_id = definitions
135            .iter()
136            .map(|(id, def)| (*id, def.default_keys.clone()))
137            .collect();
138        Self {
139            definitions,
140            user_bindings: HashMap::new(),
141            keys_by_id,
142            conflicts: Vec::new(),
143        }
144    }
145
146    /// Get default keybinding definitions.
147    fn default_definitions() -> HashMap<KeybindingId, KeybindingDefinition> {
148        use KeyCode::*;
149        use KeyModifiers as M;
150
151        let mut map = HashMap::new();
152
153        // Application-level actions. These IDs and their default keys match
154        // upstream, so a copied settings.json overrides them the same way.
155        map.insert(
156            "app.interrupt",
157            KeybindingDefinition {
158                default_keys: vec![KeyCombo::new(Esc, M::NONE)],
159                description: Some("Cancel or abort"),
160            },
161        );
162        map.insert(
163            "app.clear",
164            KeybindingDefinition {
165                default_keys: vec![KeyCombo::new(Char('c'), M::CONTROL)],
166                description: Some("Clear editor"),
167            },
168        );
169        map.insert(
170            "app.exit",
171            KeybindingDefinition {
172                default_keys: vec![KeyCombo::new(Char('d'), M::CONTROL)],
173                description: Some("Exit when editor is empty"),
174            },
175        );
176        map.insert(
177            "app.model.select",
178            KeybindingDefinition {
179                default_keys: vec![KeyCombo::new(Char('l'), M::CONTROL)],
180                description: Some("Open model selector"),
181            },
182        );
183        map.insert(
184            "app.model.cycleForward",
185            KeybindingDefinition {
186                default_keys: vec![KeyCombo::new(Char('p'), M::CONTROL)],
187                description: Some("Cycle to next model"),
188            },
189        );
190        map.insert(
191            "app.model.cycleBackward",
192            KeybindingDefinition {
193                // Upstream: `alt+p` on Windows, `shift+ctrl+p` elsewhere.
194                default_keys: vec![if cfg!(windows) {
195                    KeyCombo::new(Char('p'), M::ALT)
196                } else {
197                    KeyCombo::new(Char('p'), M::CONTROL | M::SHIFT)
198                }],
199                description: Some("Cycle to previous model"),
200            },
201        );
202        map.insert(
203            "app.tools.expand",
204            KeybindingDefinition {
205                default_keys: vec![KeyCombo::new(Char('o'), M::CONTROL)],
206                description: Some("Toggle tool output"),
207            },
208        );
209        map.insert(
210            "app.thinking.toggle",
211            KeybindingDefinition {
212                default_keys: vec![KeyCombo::new(Char('t'), M::CONTROL)],
213                description: Some("Toggle thinking blocks"),
214            },
215        );
216        map.insert(
217            "app.editor.external",
218            KeybindingDefinition {
219                default_keys: vec![KeyCombo::new(Char('g'), M::CONTROL)],
220                description: Some("Open external editor"),
221            },
222        );
223        map.insert(
224            "app.thinking.cycle",
225            KeybindingDefinition {
226                default_keys: vec![KeyCombo::new(Tab, M::SHIFT)],
227                description: Some("Cycle thinking level"),
228            },
229        );
230        map.insert(
231            "app.clipboard.pasteImage",
232            KeybindingDefinition {
233                // Upstream: `alt+v` on Windows, `ctrl+v` elsewhere. rpi keeps
234                // `ctrl+v` on Windows too — its clipboard *text* fallback
235                // (crossterm never emits `Event::Paste` on the Windows console)
236                // rides the same binding, so native's key is added, not swapped.
237                default_keys: if cfg!(windows) {
238                    vec![
239                        KeyCombo::new(Char('v'), M::CONTROL),
240                        KeyCombo::new(Char('v'), M::ALT),
241                    ]
242                } else {
243                    vec![KeyCombo::new(Char('v'), M::CONTROL)]
244                },
245                description: Some("Paste image from clipboard"),
246            },
247        );
248        map.insert(
249            "app.suspend",
250            KeybindingDefinition {
251                // Upstream binds no key on Windows at all.
252                default_keys: if cfg!(windows) {
253                    vec![]
254                } else {
255                    vec![KeyCombo::new(Char('z'), M::CONTROL)]
256                },
257                description: Some("Suspend to background"),
258            },
259        );
260        map.insert(
261            "app.message.followUp",
262            KeybindingDefinition {
263                // Upstream: `ctrl+q` on Windows, `alt+enter` elsewhere. rpi
264                // keeps `alt+enter` on Windows as well (it already worked) and
265                // adds native's key alongside it.
266                default_keys: if cfg!(windows) {
267                    vec![
268                        KeyCombo::new(Char('q'), M::CONTROL),
269                        KeyCombo::new(Enter, M::ALT),
270                    ]
271                } else {
272                    vec![KeyCombo::new(Enter, M::ALT)]
273                },
274                description: Some("Queue follow-up message"),
275            },
276        );
277        map.insert(
278            "app.message.dequeue",
279            KeybindingDefinition {
280                // Upstream: `alt+q` on Windows, `alt+up` elsewhere.
281                default_keys: vec![if cfg!(windows) {
282                    KeyCombo::new(Char('q'), M::ALT)
283                } else {
284                    KeyCombo::new(Up, M::ALT)
285                }],
286                description: Some("Restore queued messages"),
287            },
288        );
289        map.insert(
290            "app.message.copy",
291            KeybindingDefinition {
292                default_keys: vec![KeyCombo::new(Char('x'), M::CONTROL)],
293                description: Some("Copy selection or last assistant message"),
294            },
295        );
296
297        // Editor navigation and editing
298        map.insert(
299            "tui.editor.cursorUp",
300            KeybindingDefinition {
301                default_keys: vec![KeyCombo::new(Up, M::NONE)],
302                description: Some("Move cursor up"),
303            },
304        );
305        map.insert(
306            "tui.editor.cursorDown",
307            KeybindingDefinition {
308                default_keys: vec![KeyCombo::new(Down, M::NONE)],
309                description: Some("Move cursor down"),
310            },
311        );
312        map.insert(
313            "tui.editor.historyPrevious",
314            KeybindingDefinition {
315                default_keys: vec![],
316                description: Some("Select previous prompt history entry"),
317            },
318        );
319        map.insert(
320            "tui.editor.historyNext",
321            KeybindingDefinition {
322                default_keys: vec![],
323                description: Some("Select next prompt history entry"),
324            },
325        );
326        map.insert(
327            "tui.editor.cursorLeft",
328            KeybindingDefinition {
329                default_keys: vec![
330                    KeyCombo::new(Left, M::NONE),
331                    KeyCombo::new(Char('b'), M::CONTROL),
332                ],
333                description: Some("Move cursor left"),
334            },
335        );
336        map.insert(
337            "tui.editor.cursorRight",
338            KeybindingDefinition {
339                default_keys: vec![
340                    KeyCombo::new(Right, M::NONE),
341                    KeyCombo::new(Char('f'), M::CONTROL),
342                ],
343                description: Some("Move cursor right"),
344            },
345        );
346        map.insert(
347            "tui.editor.cursorWordLeft",
348            KeybindingDefinition {
349                default_keys: vec![
350                    KeyCombo::new(Left, M::ALT),
351                    KeyCombo::new(Left, M::CONTROL),
352                    KeyCombo::new(Char('b'), M::ALT),
353                ],
354                description: Some("Move cursor word left"),
355            },
356        );
357        map.insert(
358            "tui.editor.cursorWordRight",
359            KeybindingDefinition {
360                default_keys: vec![
361                    KeyCombo::new(Right, M::ALT),
362                    KeyCombo::new(Right, M::CONTROL),
363                    KeyCombo::new(Char('f'), M::ALT),
364                ],
365                description: Some("Move cursor word right"),
366            },
367        );
368        map.insert(
369            "tui.editor.cursorLineStart",
370            KeybindingDefinition {
371                default_keys: vec![
372                    KeyCombo::new(Home, M::NONE),
373                    KeyCombo::new(Home, M::CONTROL),
374                    KeyCombo::new(Char('a'), M::CONTROL),
375                ],
376                description: Some("Move to line start"),
377            },
378        );
379        map.insert(
380            "tui.editor.cursorLineEnd",
381            KeybindingDefinition {
382                default_keys: vec![
383                    KeyCombo::new(End, M::NONE),
384                    KeyCombo::new(End, M::CONTROL),
385                    KeyCombo::new(Char('e'), M::CONTROL),
386                ],
387                description: Some("Move to line end"),
388            },
389        );
390        map.insert(
391            "tui.editor.jumpForward",
392            KeybindingDefinition {
393                default_keys: vec![KeyCombo::new(Char(']'), M::CONTROL)],
394                description: Some("Jump forward to character"),
395            },
396        );
397        map.insert(
398            "tui.editor.jumpBackward",
399            KeybindingDefinition {
400                default_keys: vec![KeyCombo::new(Char(']'), M::CONTROL | M::ALT)],
401                description: Some("Jump backward to character"),
402            },
403        );
404        map.insert(
405            "tui.editor.pageUp",
406            KeybindingDefinition {
407                default_keys: vec![
408                    KeyCombo::new(PageUp, M::NONE),
409                    KeyCombo::new(PageUp, M::CONTROL),
410                ],
411                description: Some("Page up"),
412            },
413        );
414        map.insert(
415            "tui.editor.pageDown",
416            KeybindingDefinition {
417                default_keys: vec![
418                    KeyCombo::new(PageDown, M::NONE),
419                    KeyCombo::new(PageDown, M::CONTROL),
420                ],
421                description: Some("Page down"),
422            },
423        );
424        map.insert(
425            "tui.editor.deleteCharBackward",
426            KeybindingDefinition {
427                default_keys: vec![KeyCombo::new(Backspace, M::NONE)],
428                description: Some("Delete character backward"),
429            },
430        );
431        map.insert(
432            "tui.editor.deleteCharForward",
433            KeybindingDefinition {
434                default_keys: vec![
435                    KeyCombo::new(Delete, M::NONE),
436                    KeyCombo::new(Char('d'), M::CONTROL),
437                ],
438                description: Some("Delete character forward"),
439            },
440        );
441        map.insert(
442            "tui.editor.deleteWordBackward",
443            KeybindingDefinition {
444                default_keys: vec![
445                    KeyCombo::new(Char('w'), M::CONTROL),
446                    KeyCombo::new(Backspace, M::ALT),
447                ],
448                description: Some("Delete word backward"),
449            },
450        );
451        map.insert(
452            "tui.editor.deleteWordForward",
453            KeybindingDefinition {
454                default_keys: vec![
455                    KeyCombo::new(Char('d'), M::ALT),
456                    KeyCombo::new(Delete, M::ALT),
457                ],
458                description: Some("Delete word forward"),
459            },
460        );
461        map.insert(
462            "tui.editor.deleteToLineStart",
463            KeybindingDefinition {
464                default_keys: vec![KeyCombo::new(Char('u'), M::CONTROL)],
465                description: Some("Delete to line start"),
466            },
467        );
468        map.insert(
469            "tui.editor.deleteToLineEnd",
470            KeybindingDefinition {
471                default_keys: vec![KeyCombo::new(Char('k'), M::CONTROL)],
472                description: Some("Delete to line end"),
473            },
474        );
475        map.insert(
476            "tui.editor.yank",
477            KeybindingDefinition {
478                default_keys: vec![KeyCombo::new(Char('y'), M::CONTROL)],
479                description: Some("Yank"),
480            },
481        );
482        map.insert(
483            "tui.editor.yankPop",
484            KeybindingDefinition {
485                default_keys: vec![KeyCombo::new(Char('y'), M::ALT)],
486                description: Some("Yank pop"),
487            },
488        );
489        map.insert(
490            "tui.editor.undo",
491            KeybindingDefinition {
492                default_keys: vec![KeyCombo::new(Char('-'), M::CONTROL)],
493                description: Some("Undo"),
494            },
495        );
496
497        // Generic input actions
498        map.insert(
499            "tui.input.newLine",
500            KeybindingDefinition {
501                default_keys: vec![
502                    KeyCombo::new(Enter, M::SHIFT),
503                    KeyCombo::new(Char('j'), M::CONTROL),
504                ],
505                description: Some("Insert newline"),
506            },
507        );
508        map.insert(
509            "tui.input.submit",
510            KeybindingDefinition {
511                default_keys: vec![KeyCombo::new(Enter, M::NONE)],
512                description: Some("Submit input"),
513            },
514        );
515        map.insert(
516            "tui.input.tab",
517            KeybindingDefinition {
518                default_keys: vec![KeyCombo::new(Tab, M::NONE)],
519                description: Some("Tab / autocomplete"),
520            },
521        );
522        map.insert(
523            "tui.input.copy",
524            KeybindingDefinition {
525                default_keys: vec![KeyCombo::new(Char('c'), M::CONTROL)],
526                description: Some("Copy selection"),
527            },
528        );
529
530        // Generic selection actions
531        map.insert(
532            "tui.select.up",
533            KeybindingDefinition {
534                default_keys: vec![KeyCombo::new(Up, M::NONE)],
535                description: Some("Move selection up"),
536            },
537        );
538        map.insert(
539            "tui.select.down",
540            KeybindingDefinition {
541                default_keys: vec![KeyCombo::new(Down, M::NONE)],
542                description: Some("Move selection down"),
543            },
544        );
545        map.insert(
546            "tui.select.pageUp",
547            KeybindingDefinition {
548                default_keys: vec![KeyCombo::new(PageUp, M::NONE)],
549                description: Some("Selection page up"),
550            },
551        );
552        map.insert(
553            "tui.select.pageDown",
554            KeybindingDefinition {
555                default_keys: vec![KeyCombo::new(PageDown, M::NONE)],
556                description: Some("Selection page down"),
557            },
558        );
559        map.insert(
560            "tui.select.confirm",
561            KeybindingDefinition {
562                default_keys: vec![KeyCombo::new(Enter, M::NONE)],
563                description: Some("Confirm selection"),
564            },
565        );
566        map.insert(
567            "tui.select.cancel",
568            KeybindingDefinition {
569                default_keys: vec![
570                    KeyCombo::new(Esc, M::NONE),
571                    KeyCombo::new(Char('c'), M::CONTROL),
572                ],
573                description: Some("Cancel selection"),
574            },
575        );
576
577        // Alternate-screen viewport navigation
578        map.insert(
579            "tui.altScreen.pageUp",
580            KeybindingDefinition {
581                default_keys: vec![KeyCombo::new(PageUp, M::NONE)],
582                description: Some("Scroll viewport up one page"),
583            },
584        );
585        map.insert(
586            "tui.altScreen.pageDown",
587            KeybindingDefinition {
588                default_keys: vec![KeyCombo::new(PageDown, M::NONE)],
589                description: Some("Scroll viewport down one page"),
590            },
591        );
592        map.insert(
593            "tui.altScreen.halfPageUp",
594            KeybindingDefinition {
595                default_keys: vec![],
596                description: Some("Scroll viewport up half a page"),
597            },
598        );
599        map.insert(
600            "tui.altScreen.halfPageDown",
601            KeybindingDefinition {
602                default_keys: vec![],
603                description: Some("Scroll viewport down half a page"),
604            },
605        );
606        map.insert(
607            "tui.altScreen.lineUp",
608            KeybindingDefinition {
609                default_keys: vec![],
610                description: Some("Scroll viewport up one line"),
611            },
612        );
613        map.insert(
614            "tui.altScreen.lineDown",
615            KeybindingDefinition {
616                default_keys: vec![],
617                description: Some("Scroll viewport down one line"),
618            },
619        );
620        map.insert(
621            "tui.altScreen.previousPrompt",
622            KeybindingDefinition {
623                default_keys: vec![KeyCombo::new(Up, M::CONTROL | M::SHIFT)],
624                description: Some("Jump to previous semantic prompt"),
625            },
626        );
627        map.insert(
628            "tui.altScreen.nextPrompt",
629            KeybindingDefinition {
630                default_keys: vec![KeyCombo::new(Down, M::CONTROL | M::SHIFT)],
631                description: Some("Jump to next semantic prompt"),
632            },
633        );
634        map.insert(
635            "tui.altScreen.search",
636            KeybindingDefinition {
637                default_keys: vec![KeyCombo::new(Char('f'), M::CONTROL | M::SHIFT)],
638                description: Some("Search the primary scroll view"),
639            },
640        );
641        map.insert(
642            "tui.altScreen.searchNext",
643            KeybindingDefinition {
644                default_keys: vec![
645                    KeyCombo::new(Enter, M::NONE),
646                    KeyCombo::new(Char('g'), M::CONTROL),
647                ],
648                description: Some("Select the next search match"),
649            },
650        );
651        map.insert(
652            "tui.altScreen.searchPrevious",
653            KeybindingDefinition {
654                default_keys: vec![
655                    KeyCombo::new(Enter, M::SHIFT),
656                    KeyCombo::new(Char('g'), M::CONTROL | M::SHIFT),
657                ],
658                description: Some("Select the previous search match"),
659            },
660        );
661        map.insert(
662            "tui.altScreen.searchClose",
663            KeybindingDefinition {
664                default_keys: vec![KeyCombo::new(Esc, M::NONE)],
665                description: Some("Close transcript search"),
666            },
667        );
668        map.insert(
669            "tui.altScreen.top",
670            KeybindingDefinition {
671                default_keys: vec![KeyCombo::new(Home, M::NONE)],
672                description: Some("Scroll viewport to top"),
673            },
674        );
675        map.insert(
676            "tui.altScreen.bottom",
677            KeybindingDefinition {
678                default_keys: vec![KeyCombo::new(End, M::NONE)],
679                description: Some("Scroll viewport to bottom"),
680            },
681        );
682
683        map
684    }
685
686    /// Check if a key event matches a keybinding.
687    pub fn matches(&self, event: &crossterm::event::KeyEvent, binding_id: KeybindingId) -> bool {
688        if let Some(combos) = self.keys_by_id.get(binding_id) {
689            let combo = KeyCombo::new(event.code, event.modifiers);
690            return combos.contains(&combo);
691        }
692        false
693    }
694
695    /// Get the keybinding ID for a key event.
696    pub fn get_binding(&self, event: &crossterm::event::KeyEvent) -> Option<KeybindingId> {
697        let combo = KeyCombo::new(event.code, event.modifiers);
698        for (id, combos) in &self.keys_by_id {
699            if combos.contains(&combo) {
700                return Some(*id);
701            }
702        }
703        None
704    }
705
706    /// Get keys for a keybinding.
707    pub fn get_keys(&self, binding_id: KeybindingId) -> Vec<KeyCombo> {
708        self.keys_by_id.get(binding_id).cloned().unwrap_or_default()
709    }
710
711    /// Get definition for a keybinding.
712    pub fn get_definition(&self, binding_id: KeybindingId) -> Option<&KeybindingDefinition> {
713        self.definitions.get(binding_id)
714    }
715
716    /// Get conflicts.
717    pub fn get_conflicts(&self) -> &[KeybindingConflict] {
718        &self.conflicts
719    }
720
721    /// Set user bindings.
722    pub fn set_user_bindings(&mut self, user_bindings: HashMap<KeybindingId, Vec<KeyCombo>>) {
723        self.user_bindings = user_bindings;
724        self.rebuild();
725    }
726
727    /// Rebuild the keybindings after user bindings change.
728    fn rebuild(&mut self) {
729        self.keys_by_id.clear();
730        self.conflicts.clear();
731
732        // Track which keys are claimed by which keybindings
733        let mut key_claims: HashMap<KeyCombo, Vec<KeybindingId>> = HashMap::new();
734
735        for (id, def) in &self.definitions {
736            let keys = if let Some(user_keys) = self.user_bindings.get(id) {
737                user_keys.clone()
738            } else {
739                def.default_keys.clone()
740            };
741
742            // Track claims
743            for key in &keys {
744                key_claims.entry(*key).or_default().push(*id);
745            }
746
747            self.keys_by_id.insert(*id, keys);
748        }
749
750        // Find conflicts
751        for (key, claimants) in key_claims {
752            if claimants.len() > 1 {
753                self.conflicts.push(KeybindingConflict {
754                    key,
755                    keybindings: claimants,
756                });
757            }
758        }
759    }
760
761    /// Set a single keybinding.
762    pub fn set(&mut self, binding_id: KeybindingId, combos: Vec<KeyCombo>) {
763        self.user_bindings.insert(binding_id, combos);
764        self.rebuild();
765    }
766}
767
768impl Default for Keybindings {
769    fn default() -> Self {
770        Self::new()
771    }
772}
773
774/// Global keybindings manager.
775static GLOBAL_KEYBINDINGS: std::sync::OnceLock<std::sync::Mutex<Keybindings>> =
776    std::sync::OnceLock::new();
777
778/// Set global keybindings.
779pub fn set_keybindings(keybindings: Keybindings) {
780    let guard = GLOBAL_KEYBINDINGS.get_or_init(|| std::sync::Mutex::new(Keybindings::new()));
781    if let Ok(mut kb) = guard.lock() {
782        *kb = keybindings;
783    }
784}
785
786/// Get global keybindings.
787pub fn get_keybindings() -> std::sync::MutexGuard<'static, Keybindings> {
788    let guard = GLOBAL_KEYBINDINGS.get_or_init(|| std::sync::Mutex::new(Keybindings::new()));
789    guard.lock().expect("Failed to lock global keybindings")
790}
791
792/// Predefined keybinding IDs.
793pub mod keys {
794    use super::KeybindingId;
795
796    pub const INTERRUPT: KeybindingId = "app.interrupt";
797    pub const CLEAR: KeybindingId = "app.clear";
798    pub const EXIT: KeybindingId = "app.exit";
799    pub const MODEL_SELECT: KeybindingId = "app.model.select";
800    pub const MODEL_CYCLE_FORWARD: KeybindingId = "app.model.cycleForward";
801    pub const MODEL_CYCLE_BACKWARD: KeybindingId = "app.model.cycleBackward";
802    pub const TOOLS_EXPAND: KeybindingId = "app.tools.expand";
803    pub const THINKING_TOGGLE: KeybindingId = "app.thinking.toggle";
804    pub const EXTERNAL_EDITOR: KeybindingId = "app.editor.external";
805    pub const THINKING_CYCLE: KeybindingId = "app.thinking.cycle";
806    pub const PASTE_IMAGE: KeybindingId = "app.clipboard.pasteImage";
807    pub const SUSPEND: KeybindingId = "app.suspend";
808    pub const MESSAGE_FOLLOW_UP: KeybindingId = "app.message.followUp";
809    pub const DEQUEUE: KeybindingId = "app.message.dequeue";
810    pub const MESSAGE_COPY: KeybindingId = "app.message.copy";
811
812    // Editor
813    pub const CURSOR_UP: KeybindingId = "tui.editor.cursorUp";
814    pub const CURSOR_DOWN: KeybindingId = "tui.editor.cursorDown";
815    pub const CURSOR_LEFT: KeybindingId = "tui.editor.cursorLeft";
816    pub const CURSOR_RIGHT: KeybindingId = "tui.editor.cursorRight";
817    pub const CURSOR_WORD_LEFT: KeybindingId = "tui.editor.cursorWordLeft";
818    pub const CURSOR_WORD_RIGHT: KeybindingId = "tui.editor.cursorWordRight";
819    pub const CURSOR_LINE_START: KeybindingId = "tui.editor.cursorLineStart";
820    pub const CURSOR_LINE_END: KeybindingId = "tui.editor.cursorLineEnd";
821    pub const DELETE_CHAR_BACKWARD: KeybindingId = "tui.editor.deleteCharBackward";
822    pub const DELETE_CHAR_FORWARD: KeybindingId = "tui.editor.deleteCharForward";
823    pub const DELETE_WORD_BACKWARD: KeybindingId = "tui.editor.deleteWordBackward";
824    pub const DELETE_WORD_FORWARD: KeybindingId = "tui.editor.deleteWordForward";
825    pub const DELETE_TO_LINE_START: KeybindingId = "tui.editor.deleteToLineStart";
826    pub const DELETE_TO_LINE_END: KeybindingId = "tui.editor.deleteToLineEnd";
827    pub const YANK: KeybindingId = "tui.editor.yank";
828    pub const YANK_POP: KeybindingId = "tui.editor.yankPop";
829    pub const UNDO: KeybindingId = "tui.editor.undo";
830
831    // Input
832    pub const INPUT_SUBMIT: KeybindingId = "tui.input.submit";
833    pub const INPUT_NEW_LINE: KeybindingId = "tui.input.newLine";
834    pub const INPUT_TAB: KeybindingId = "tui.input.tab";
835    pub const INPUT_COPY: KeybindingId = "tui.input.copy";
836
837    // Select
838    pub const SELECT_UP: KeybindingId = "tui.select.up";
839    pub const SELECT_DOWN: KeybindingId = "tui.select.down";
840    pub const SELECT_PAGE_UP: KeybindingId = "tui.select.pageUp";
841    pub const SELECT_PAGE_DOWN: KeybindingId = "tui.select.pageDown";
842    pub const SELECT_CONFIRM: KeybindingId = "tui.select.confirm";
843    pub const SELECT_CANCEL: KeybindingId = "tui.select.cancel";
844
845    // Alt screen
846    pub const PAGE_UP: KeybindingId = "tui.altScreen.pageUp";
847    pub const PAGE_DOWN: KeybindingId = "tui.altScreen.pageDown";
848    pub const HALF_PAGE_UP: KeybindingId = "tui.altScreen.halfPageUp";
849    pub const HALF_PAGE_DOWN: KeybindingId = "tui.altScreen.halfPageDown";
850    pub const LINE_UP: KeybindingId = "tui.altScreen.lineUp";
851    pub const LINE_DOWN: KeybindingId = "tui.altScreen.lineDown";
852    pub const TOP: KeybindingId = "tui.altScreen.top";
853    pub const BOTTOM: KeybindingId = "tui.altScreen.bottom";
854    pub const SEARCH: KeybindingId = "tui.altScreen.search";
855    pub const SEARCH_NEXT: KeybindingId = "tui.altScreen.searchNext";
856    pub const SEARCH_PREVIOUS: KeybindingId = "tui.altScreen.searchPrevious";
857    pub const SEARCH_CLOSE: KeybindingId = "tui.altScreen.searchClose";
858    pub const PREVIOUS_PROMPT: KeybindingId = "tui.altScreen.previousPrompt";
859    pub const NEXT_PROMPT: KeybindingId = "tui.altScreen.nextPrompt";
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    #[test]
867    fn test_keybindings_new() {
868        let kb = Keybindings::new();
869        assert!(kb.definitions.len() > 0);
870    }
871
872    #[test]
873    fn test_matches() {
874        let kb = Keybindings::new();
875        let event = crossterm::event::KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
876        assert!(kb.matches(&event, keys::INPUT_SUBMIT));
877    }
878
879    #[test]
880    fn test_get_keys() {
881        let kb = Keybindings::new();
882        let keys = kb.get_keys(keys::CURSOR_LEFT);
883        assert!(!keys.is_empty());
884    }
885
886    #[test]
887    fn key_combo_display_formats_hints() {
888        assert_eq!(
889            KeyCombo::new(KeyCode::Char('q'), KeyModifiers::ALT).display(),
890            "Alt+Q"
891        );
892        assert_eq!(
893            KeyCombo::new(
894                KeyCode::Char('f'),
895                KeyModifiers::CONTROL | KeyModifiers::SHIFT
896            )
897            .display(),
898            "Ctrl+Shift+F"
899        );
900        assert_eq!(
901            KeyCombo::new(KeyCode::Enter, KeyModifiers::NONE).display(),
902            "Enter"
903        );
904        assert_eq!(
905            KeyCombo::new(KeyCode::Up, KeyModifiers::ALT).display(),
906            "Alt+Up"
907        );
908    }
909
910    #[test]
911    fn dequeue_binding_has_a_platform_default() {
912        let kb = Keybindings::new();
913        let keys = kb.get_keys(keys::DEQUEUE);
914        assert_eq!(keys.len(), 1);
915        if cfg!(windows) {
916            assert_eq!(keys[0].display(), "Alt+Q");
917        } else {
918            assert_eq!(keys[0].display(), "Alt+Up");
919        }
920    }
921
922    #[test]
923    fn dequeue_matches_its_platform_default_event() {
924        // Guards the hotkey itself: a real Alt+Q (Windows) / Alt+Up (elsewhere)
925        // event must resolve to `app.message.dequeue`, since the TUI key loop
926        // routes the "edit queued messages" action solely through this match.
927        let kb = Keybindings::new();
928        let event = if cfg!(windows) {
929            crossterm::event::KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
930        } else {
931            crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)
932        };
933        assert!(kb.matches(&event, keys::DEQUEUE));
934    }
935}