Skip to main content

oxicode/tui_vt/
keymap.rs

1//! Global keyboard shortcut resolver for the TUI.
2//!
3//! Tasks 5 and 6 (settings-panel keybindings) and T8 (per-feature shortcuts
4//! merged from `feat/tui-omp-ideas`) consume [`GlobalAction`], [`KeyCombo`],
5//! and [`Keymap`]. The resolver is fully additive: user overrides in
6//! `Settings::keybindings` extend the default bindings rather than replacing
7//! the whole table; a user override for an action replaces only that
8//! action's combo list (and only when the override list parses to at least
9//! one valid combo).
10//!
11//! `KeyCombo` parses and serializes the canonical textual form used in
12//! settings files and in the `/settings` panel:
13//!
14//! - modifier prefix order is `Ctrl+`, `Alt+`, `Shift+`.
15//! - the char payload is uppercased when `SHIFT` is set, mirroring crossterm's
16//!   `KeyEvent::normalize_case` (shifted letter keys arrive as uppercase
17//!   `KeyCode::Char`); `parse` normalizes the input the same way so a
18//!   shift-letter combo parses canonically regardless of case.
19//! - named keys (`Enter`, `Esc`, `Tab`, `Backspace`, `PageUp`, `PageDown`,
20//!   arrow keys, `Home`, `End`, `BackTab`, `Delete`) keep their literal
21//!   names; the parser maps lower-case variants too.
22//!
23//! Note: the original `feat/tui-omp-ideas` branch shipped its own
24//! `KeyAction` enum + `~/.oxicode/keybindings.yml` loader + `KeyCombo`
25//! struct + `matches`/`conflicts` API. The squash merged that into the
26//! established `GlobalAction` resolver: every `KeyAction` variant maps to
27//! a `GlobalAction` (Submit→Submit, SubmitNow→SendNow, QueueToggle→
28//! ToggleQueuePanel, Interrupt→Interrupt, Clear→FoldAll, ModelPicker→
29//! OpenCommandPalette, ToggleThinking→ToggleMultiline, Help→Help, ScrollUp→
30//! ScrollUp, ScrollDown→ScrollDown). The YAML loader is dropped in favor
31//! of `Settings::keybindings`.
32
33use std::collections::HashMap;
34
35use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
36
37/// Every remappable action in the TUI. The set is the union of the
38/// settings-panel's `GlobalAction` (FoldAll/SendNow/etc.) and the
39/// `feat/tui-omp-ideas` branch's `KeyAction` (Submit/ScrollUp/etc.). Each
40/// variant maps to a single user-visible keybinding in the TUI; char-level
41/// composer input (typing, vim insert mode) is NOT represented here and is
42/// left untouched.
43#[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
60/// Default bindings. Combos mirror the keys that were hardcoded in the
61/// input dispatch before this module existed (settings-panel work) plus
62/// the keys introduced by `feat/tui-omp-ideas` (Submit, ScrollUp/Down,
63/// Clear, Help, ModelPicker, ToggleThinking). The order matters:
64/// [`GlobalAction::all`] / [`Keymap::resolve`] iterate in this order, so
65/// when two actions share a combo (only reachable via user overrides) the
66/// earlier action wins — deterministically across runs.
67pub const DEFAULT_KEYBINDINGS: &[(GlobalAction, &str)] = &[
68    (GlobalAction::Interrupt, "Ctrl+c"),
69    (GlobalAction::Submit, "Enter"),
70    // Shift+Enter keeps the pre-keymap multiline muscle memory alive:
71    // in multiline mode plain Enter inserts a newline while Shift+Enter
72    // sends (the `feat/tui-omp-ideas` branch pinned both combos).
73    (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    /// Parse a textual combo like `"Ctrl+p"`, `"Ctrl+Shift+e"`, `"Ctrl+Enter"`,
126    /// `"Alt+p"`, `"PageUp"`, `"?"` into a `KeyCombo`. Returns `None` on any
127    /// unrecognized segment. Shift-letter payloads normalize to uppercase;
128    /// named-key spelling is case-insensitive on input.
129    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                        // Reject "Enter+Esc" / "a+b" — too ambiguous for a
144                        // user-facing override. `parse_key_string`-style
145                        // split used to accept these; ours is stricter.
146                        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    /// Serialize back to the canonical textual form. Round-trips with
159    /// [`KeyCombo::parse`] for every combo produced by [`DEFAULT_KEYBINDINGS`]
160    /// and for shifted letter chars (which crossterm normalizes to uppercase).
161    #[allow(clippy::inherent_to_string)] // brief signature is `to_string(&self) -> String`
162    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                // Crossterm's `KeyEvent::normalize_case` already maps a
190                // shifted letter to `Char(uppercase)` with `SHIFT` set, so we
191                // emit the char as-is (which is uppercase for shifted letters).
192                out.push(c);
193            }
194            // Fallback for named keys we don't handle explicitly (F-keys,
195            // etc.) — defer to crossterm's own formatting.
196            _ => return self.code.to_string(),
197        }
198        out
199    }
200}
201
202/// Map a token in the position after the modifier prefixes to a
203/// `KeyCode`. Single printable ASCII chars map to `KeyCode::Char` (with
204/// the SHIFT-uppercase normalization); named keys (`Enter`, `Esc`,
205/// `PageUp`, `?` if standalone, …) map to the corresponding variant.
206/// Returns `None` on anything unrecognized.
207fn parse_key_name(token: &str, mods: KeyModifiers) -> Option<KeyCode> {
208    // 1. Named keys (case-insensitive) — list kept in lockstep with the
209    //    emit side of `to_string` plus the spelled-out aliases that
210    //    `feat/tui-omp-ideas` accepted (`backtab`, `pgup`, …).
211    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    // 2. Single printable character. SHIFT-letter normalizes to uppercase
232    //    to match crossterm's `KeyEvent::normalize_case`.
233    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    /// Build a keymap seeded with [`DEFAULT_KEYBINDINGS`], then layer user
252    /// overrides on top. A user override replaces only the combos for the
253    /// named action; other actions keep their defaults. An override that
254    /// fails to parse (or produces zero combos) is silently ignored — the
255    /// defaults remain.
256    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    /// Resolve an incoming [`KeyEvent`] to its global action, if any.
281    /// Actions are checked in [`DEFAULT_KEYBINDINGS`] order, so when two
282    /// actions share a combo (only reachable via user overrides) the earlier
283    /// default action wins — deterministically across runs.
284    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    /// True if `event` triggers `action` under the current bindings. An
295    /// action matches when *any* of its registered combos matches.
296    /// Convenience accessor for callers that already have a `&KeyEvent`
297    /// and want a direct membership check (the original
298    /// `feat/tui-omp-ideas` API surface).
299    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    /// Replace the binding list for a single action (used by Task 6's
308    /// map-editor when the user confirms a new combo).
309    pub fn set_action(&mut self, action: GlobalAction, combos: Vec<KeyCombo>) {
310        self.bindings.insert(action, combos);
311    }
312
313    /// The live combo list for `action` (defaults + user overrides,
314    /// merged). The Keybindings map-editor reads this to append to or
315    /// remove from the effective list.
316    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        // The override replaces the combos for that action; other actions
346        // keep their defaults. The override combo must resolve and the
347        // original default for the same action must not — user wins.
348        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        // Shifted-letter payloads serialize as uppercase (crossterm's
383        // `KeyEvent::normalize_case` produces `Char(uppercase)` for them).
384        // PageUp/PageDown/named keys must also round-trip via the
385        // canonical form used in DEFAULT_KEYBINDINGS.
386        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        // `parse` normalizes lowercase shift to uppercase too.
403        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        // Empty trailing segment has zero chars, falls to the catch-all arm,
456        // and returns None.
457        assert!(KeyCombo::parse("Ctrl+").is_none());
458        // A double-Ctrl modifier is redundant but harmless; the payload
459        // still parses.
460        assert!(KeyCombo::parse("Ctrl+Ctrl+p").is_some());
461    }
462
463    #[test]
464    fn shared_combo_resolves_in_default_bindings_order() {
465        // A user override can bind a combo that collides with another
466        // action's default (here: Interrupt takes over Ctrl+p, which is
467        // OpenCommandPalette's default). Resolution must be deterministic:
468        // the earlier action in DEFAULT_KEYBINDINGS wins (Interrupt is
469        // listed before OpenCommandPalette).
470        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    /// Ported from the `feat/tui-omp-ideas` `KeyAction` API: every default
478    /// key from the branch's hardcoded dispatch must still resolve to the
479    /// unified `GlobalAction` enum. This pins the wiring change so a future
480    /// edit cannot silently rebind an action.
481    #[test]
482    fn defaults_match_unified_hardcoded_keys() {
483        let km = Keymap::from_settings(&HashMap::new());
484
485        // Ctrl+C → Interrupt
486        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        // Plain Enter → Submit (Enter must NOT match SendNow).
493        assert!(km.matches(
494            GlobalAction::Submit,
495            &press(KeyCode::Enter, KeyModifiers::NONE),
496        ));
497        assert!(!km.matches(GlobalAction::Submit, &ctrl(KeyCode::Enter)));
498
499        // Ctrl+Enter → SendNow (SubmitNow alias), plain Enter must not.
500        assert!(km.matches(GlobalAction::SendNow, &ctrl(KeyCode::Enter)));
501        assert!(!km.matches(
502            GlobalAction::SendNow,
503            &press(KeyCode::Enter, KeyModifiers::NONE),
504        ));
505
506        // Ctrl+; → ToggleQueuePanel (QueueToggle alias).
507        assert!(km.matches(GlobalAction::ToggleQueuePanel, &ctrl(KeyCode::Char(';')),));
508        assert!(!km.matches(
509            GlobalAction::ToggleQueuePanel,
510            &press(KeyCode::Char(';'), KeyModifiers::NONE),
511        ));
512
513        // PageUp → ScrollUp; the Up arrow must NOT match (no scroll-up
514        // spill into single-line arrow keys).
515        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        // PageDown → ScrollDown; Down arrow must NOT match.
525        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        // Ctrl+L → Clear (the branch used Ctrl+E for Clear, but Ctrl+E is
535        // already FoldAll on main; Ctrl+L is the canonical "clear screen"
536        // chord and is the default we shipped).
537        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        // ? → Help; Ctrl+? must NOT match (modifiers are exact).
544        assert!(km.matches(
545            GlobalAction::Help,
546            &press(KeyCode::Char('?'), KeyModifiers::NONE),
547        ));
548        assert!(!km.matches(GlobalAction::Help, &ctrl(KeyCode::Char('?'))));
549
550        // Ctrl+G → ModelPicker.
551        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        // Ctrl+T → ToggleThinking.
558        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        // Ctrl+M → ToggleMultiline (legacy wiring from main).
565        assert!(km.matches(GlobalAction::ToggleMultiline, &ctrl(KeyCode::Char('m')),));
566
567        // Ctrl+E → FoldAll.
568        assert!(km.matches(GlobalAction::FoldAll, &ctrl(KeyCode::Char('e'))));
569    }
570
571    /// Plain Enter and Ctrl+Enter must remain distinct. The original
572    /// `feat/tui-omp-ideas` regression `matches_handles_ctrl_enter` covered
573    /// this — re-asserted under the unified enum so a future parse change
574    /// cannot collapse the two.
575    #[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    /// `parse_key_name` rejects nonsense keys so a malformed user override
588    /// is dropped (and the action stays on its default).
589    #[test]
590    fn parse_rejects_garbage_payload() {
591        // Empty token in the middle of the combo.
592        assert!(KeyCombo::parse("Ctrl+").is_none());
593        // Unknown key name — `notakey` is not in the named-keys table and
594        // is too long for the single-char arm.
595        assert!(KeyCombo::parse("notakey").is_none());
596        // Two key tokens — too ambiguous.
597        assert!(KeyCombo::parse("a+b").is_none());
598    }
599}