Skip to main content

agent_engine/skills/
keybinds.rs

1//! Plugin keybinds — registry, parser, and matching for custom keyboard shortcuts.
2//!
3//! Plugins declare keybinds in `plugin.json`. Users override in config.
4//! Core keybinds (Ctrl+C, Esc, etc.) are never overridable.
5
6use crossterm::event::{KeyCode, KeyModifiers};
7use std::collections::HashSet;
8use std::path::PathBuf;
9
10/// A key combination (modifiers + key).
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct KeyCombo {
13    pub code: KeyCode,
14    pub modifiers: KeyModifiers,
15}
16
17/// What happens when a keybind fires.
18#[derive(Debug, Clone)]
19pub enum KeybindAction {
20    /// Execute a slash command (e.g. "scholar quantum")
21    SlashCommand(String),
22    /// Load a skill by name
23    LoadSkill(String),
24    /// Submit text as a user message
25    InjectPrompt(String),
26    /// Run a script and inject output as system message
27    RunScript { script: String, plugin_dir: PathBuf },
28    /// Explicitly disabled (user override)
29    Disabled,
30}
31
32/// Where a keybind came from — for conflict resolution and display.
33#[derive(Debug, Clone, PartialEq)]
34pub enum KeybindSource {
35    Core,
36    User,
37    Plugin(String),
38}
39
40/// A registered keybind.
41#[derive(Debug, Clone)]
42pub struct Keybind {
43    pub key: KeyCombo,
44    pub action: KeybindAction,
45    pub description: String,
46    pub source: KeybindSource,
47}
48
49/// A keybind that was rejected during plugin registration because the
50/// key was already taken. Phase 8 slice 8B.2.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct KeybindCollision {
53    /// Plugin whose keybind was rejected.
54    pub losing_plugin: String,
55    /// Notation of the key (e.g. "ctrl+space"), as written in the manifest.
56    pub key: String,
57    /// What already owned this key — either another plugin name (string)
58    /// or the literal "core" if `reserved.contains(&combo)`.
59    pub winning_owner: String,
60    /// Optional reason: "invalid notation: …" for parse errors,
61    /// "conflicts with core" for reserved keys, "already registered"
62    /// for plugin-vs-plugin collisions.
63    pub reason: String,
64}
65
66/// Registry of all keybinds with conflict resolution.
67#[derive(Debug, Clone)]
68pub struct KeybindRegistry {
69    binds: Vec<Keybind>,
70    reserved: HashSet<KeyCombo>,
71    collisions: Vec<KeybindCollision>,
72}
73
74impl Default for KeybindRegistry {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl KeybindRegistry {
81    pub fn new() -> Self {
82        let mut registry = Self {
83            binds: Vec::new(),
84            reserved: HashSet::new(),
85            collisions: Vec::new(),
86        };
87        registry.register_core();
88        registry
89    }
90
91    /// Plugin keybinds that were rejected due to collisions. Phase 8 slice 8B.2.
92    pub fn collisions(&self) -> &[KeybindCollision] {
93        &self.collisions
94    }
95
96    /// Reset the recorded collision list (e.g. before a registry rebuild).
97    pub fn clear_collisions(&mut self) {
98        self.collisions.clear();
99    }
100
101    /// Register core keybinds that can never be overridden.
102    fn register_core(&mut self) {
103        let core_keys = vec![
104            (KeyCode::Char('c'), KeyModifiers::CONTROL, "Quit"),
105            (KeyCode::Esc, KeyModifiers::NONE, "Abort stream"),
106            (KeyCode::Enter, KeyModifiers::NONE, "Submit"),
107            (KeyCode::Enter, KeyModifiers::SHIFT, "Newline"),
108            (KeyCode::Tab, KeyModifiers::NONE, "Autocomplete"),
109            (KeyCode::Char('a'), KeyModifiers::CONTROL, "Cursor start"),
110            (KeyCode::Char('e'), KeyModifiers::CONTROL, "Cursor end"),
111            (KeyCode::Char('u'), KeyModifiers::CONTROL, "Clear input"),
112            (KeyCode::Char('w'), KeyModifiers::CONTROL, "Delete word"),
113            (KeyCode::Char('o'), KeyModifiers::CONTROL, "Toggle output"),
114            (KeyCode::Left, KeyModifiers::ALT, "Jump word left"),
115            (KeyCode::Right, KeyModifiers::ALT, "Jump word right"),
116            (KeyCode::Up, KeyModifiers::SHIFT, "Scroll up"),
117            (KeyCode::Down, KeyModifiers::SHIFT, "Scroll down"),
118            (KeyCode::Up, KeyModifiers::CONTROL, "Watch older subagent"),
119            (
120                KeyCode::Down,
121                KeyModifiers::CONTROL,
122                "Watch newer subagent / main",
123            ),
124            // The same on Alt: macOS takes Ctrl-↑/↓ for Mission Control.
125            (KeyCode::Up, KeyModifiers::ALT, "Watch older subagent"),
126            (KeyCode::Down, KeyModifiers::ALT, "Watch newer subagent / main"),
127            (KeyCode::Up, KeyModifiers::NONE, "History up"),
128            (KeyCode::Down, KeyModifiers::NONE, "History down"),
129            (KeyCode::Left, KeyModifiers::NONE, "Cursor left"),
130            (KeyCode::Right, KeyModifiers::NONE, "Cursor right"),
131            (KeyCode::Backspace, KeyModifiers::NONE, "Backspace"),
132            (KeyCode::Backspace, KeyModifiers::ALT, "Delete word"),
133            (KeyCode::Home, KeyModifiers::NONE, "Cursor start"),
134            (KeyCode::End, KeyModifiers::NONE, "Cursor end"),
135        ];
136        for (code, modifiers, desc) in core_keys {
137            let combo = KeyCombo { code, modifiers };
138            self.reserved.insert(combo.clone());
139            self.binds.push(Keybind {
140                key: combo,
141                action: KeybindAction::Disabled, // core actions handled elsewhere
142                description: desc.to_string(),
143                source: KeybindSource::Core,
144            });
145        }
146    }
147
148    /// Register keybinds from a plugin manifest.
149    pub fn register_plugin(
150        &mut self,
151        plugin_name: &str,
152        keybinds: &[ManifestKeybind],
153        plugin_dir: &std::path::Path,
154    ) {
155        for kb in keybinds {
156            let combo = match parse_key(&kb.key) {
157                Ok(c) => c,
158                Err(e) => {
159                    tracing::warn!(
160                        "plugin '{}': invalid keybind '{}': {}",
161                        plugin_name,
162                        kb.key,
163                        e
164                    );
165                    self.collisions.push(KeybindCollision {
166                        losing_plugin: plugin_name.to_string(),
167                        key: kb.key.clone(),
168                        winning_owner: "n/a".to_string(),
169                        reason: format!("invalid notation: {}", e),
170                    });
171                    continue;
172                }
173            };
174
175            // Skip if reserved (core)
176            if self.reserved.contains(&combo) {
177                tracing::warn!(
178                    "plugin '{}': keybind '{}' conflicts with core — skipped",
179                    plugin_name,
180                    kb.key
181                );
182                self.collisions.push(KeybindCollision {
183                    losing_plugin: plugin_name.to_string(),
184                    key: kb.key.clone(),
185                    winning_owner: "core".to_string(),
186                    reason: "conflicts with core".to_string(),
187                });
188                continue;
189            }
190
191            // Skip if already registered by another plugin
192            if let Some(existing) = self
193                .binds
194                .iter()
195                .find(|b| b.key == combo && b.source != KeybindSource::Core)
196            {
197                let winning_owner = match &existing.source {
198                    KeybindSource::Plugin(name) => name.clone(),
199                    KeybindSource::User => "user".to_string(),
200                    KeybindSource::Core => "core".to_string(),
201                };
202                tracing::warn!(
203                    "plugin '{}': keybind '{}' already registered — skipped",
204                    plugin_name,
205                    kb.key
206                );
207                self.collisions.push(KeybindCollision {
208                    losing_plugin: plugin_name.to_string(),
209                    key: kb.key.clone(),
210                    winning_owner,
211                    reason: "already registered".to_string(),
212                });
213                continue;
214            }
215
216            let action = match kb.action.as_str() {
217                "slash_command" => {
218                    KeybindAction::SlashCommand(kb.command.clone().unwrap_or_default())
219                }
220                "load_skill" => KeybindAction::LoadSkill(kb.skill.clone().unwrap_or_default()),
221                "inject_prompt" => {
222                    KeybindAction::InjectPrompt(kb.prompt.clone().unwrap_or_default())
223                }
224                "run_script" => KeybindAction::RunScript {
225                    script: kb.script.clone().unwrap_or_default(),
226                    plugin_dir: plugin_dir.to_path_buf(),
227                },
228                other => {
229                    tracing::warn!(
230                        "plugin '{}': unknown keybind action '{}'",
231                        plugin_name,
232                        other
233                    );
234                    continue;
235                }
236            };
237
238            self.binds.push(Keybind {
239                key: combo,
240                action,
241                description: kb.description.clone().unwrap_or_default(),
242                source: KeybindSource::Plugin(plugin_name.to_string()),
243            });
244        }
245    }
246
247    /// Register user keybind overrides from config.
248    pub fn register_user(&mut self, config_keybinds: &std::collections::HashMap<String, String>) {
249        for (key_str, value) in config_keybinds {
250            let combo = match parse_key(key_str) {
251                Ok(c) => c,
252                Err(e) => {
253                    tracing::warn!("config: invalid keybind '{}': {}", key_str, e);
254                    continue;
255                }
256            };
257
258            // Skip core — even users can't override these
259            if self.reserved.contains(&combo) {
260                tracing::warn!("config: keybind '{}' is a core bind — skipped", key_str);
261                continue;
262            }
263
264            // Remove any existing plugin bind for this key
265            self.binds
266                .retain(|b| b.key != combo || b.source == KeybindSource::Core);
267
268            let action = if value == "disabled" {
269                KeybindAction::Disabled
270            } else if let Some(stripped) = value.strip_prefix('/') {
271                let cmd = stripped.to_string();
272                KeybindAction::SlashCommand(cmd)
273            } else {
274                KeybindAction::InjectPrompt(value.clone())
275            };
276
277            self.binds.push(Keybind {
278                key: combo,
279                action,
280                description: format!("User: {}", value),
281                source: KeybindSource::User,
282            });
283        }
284    }
285
286    /// Live-replace the keybind that fires `slash_command`.
287    ///
288    /// Removes every existing user/plugin bind whose action is the same
289    /// slash command, then registers `new_key → /slash_command` as a User
290    /// bind. Used by /settings to hot-swap the sidecar toggle key without
291    /// requiring a restart.
292    pub fn set_slash_command_key(
293        &mut self,
294        slash_command: &str,
295        new_key: &str,
296    ) -> Result<(), String> {
297        let combo = parse_key(new_key)?;
298        if self.reserved.contains(&combo) {
299            return Err(format!("'{}' is reserved by core — cannot rebind", new_key));
300        }
301        // Drop any existing bind for this exact command (any source ≠ Core).
302        self.binds.retain(|b| {
303            if b.source == KeybindSource::Core {
304                return true;
305            }
306            !matches!(&b.action, KeybindAction::SlashCommand(c) if c == slash_command)
307        });
308        // Drop any existing non-core bind sitting on the new key (avoid
309        // collision with another plugin bind).
310        self.binds
311            .retain(|b| b.key != combo || b.source == KeybindSource::Core);
312        self.binds.push(Keybind {
313            key: combo,
314            action: KeybindAction::SlashCommand(slash_command.to_string()),
315            description: format!("User: /{}", slash_command),
316            source: KeybindSource::User,
317        });
318        Ok(())
319    }
320
321    /// Match a key event against registered keybinds.
322    /// Returns None for core binds (handled by the existing match block).
323    pub fn match_key(&self, code: KeyCode, modifiers: KeyModifiers) -> Option<&Keybind> {
324        let combo = KeyCombo { code, modifiers };
325
326        // Skip core — those are handled by the static match in input.rs
327        if self.reserved.contains(&combo) {
328            return None;
329        }
330
331        self.binds
332            .iter()
333            .find(|b| b.key == combo && !matches!(b.source, KeybindSource::Core))
334    }
335
336    /// All registered keybinds (for display in /keybinds and settings).
337    pub fn all(&self) -> &[Keybind] {
338        &self.binds
339    }
340
341    /// Non-core keybinds only (plugin + user).
342    pub fn custom_binds(&self) -> Vec<&Keybind> {
343        self.binds
344            .iter()
345            .filter(|b| !matches!(b.source, KeybindSource::Core))
346            .collect()
347    }
348}
349
350/// Keybind declaration from plugin.json manifest.
351#[derive(Debug, Clone, serde::Deserialize)]
352pub struct ManifestKeybind {
353    pub key: String,
354    #[serde(default)]
355    pub action: String,
356    #[serde(default)]
357    pub command: Option<String>,
358    #[serde(default)]
359    pub skill: Option<String>,
360    #[serde(default)]
361    pub prompt: Option<String>,
362    #[serde(default)]
363    pub script: Option<String>,
364    #[serde(default)]
365    pub description: Option<String>,
366}
367
368/// Parse key notation string into a KeyCombo.
369///
370/// Format: `[modifier-]*key`
371/// Modifiers: `C` (Ctrl), `S` (Shift), `A` (Alt)
372/// Keys: single char, `F1`–`F12`, `Space`, `Tab`, `Enter`, `Esc`
373///
374/// Examples:
375/// - `C-s` → Ctrl+S
376/// - `C-S-s` → Ctrl+Shift+S
377/// - `A-p` → Alt+P
378/// - `F5` → F5
379/// - `C-Space` → Ctrl+Space
380pub fn parse_key(notation: &str) -> Result<KeyCombo, String> {
381    let notation = notation.trim();
382    if notation.is_empty() {
383        return Err("empty key notation".to_string());
384    }
385
386    let parts: Vec<&str> = notation.split('-').collect();
387    let mut modifiers = KeyModifiers::empty();
388
389    // All parts except the last are modifiers
390    for part in &parts[..parts.len().saturating_sub(1)] {
391        match *part {
392            "C" => modifiers |= KeyModifiers::CONTROL,
393            "S" => modifiers |= KeyModifiers::SHIFT,
394            "A" => modifiers |= KeyModifiers::ALT,
395            other => {
396                return Err(format!(
397                    "unknown modifier: '{}' (expected C, S, or A)",
398                    other
399                ))
400            }
401        }
402    }
403
404    let key_str = parts.last().ok_or("missing key")?;
405    let code = match *key_str {
406        k if k.len() == 1 => {
407            let ch = k.chars().next().unwrap();
408            KeyCode::Char(ch.to_ascii_lowercase())
409        }
410        k if k.starts_with('F') && k.len() <= 3 => {
411            let n: u8 = k[1..]
412                .parse()
413                .map_err(|_| format!("invalid F-key: '{}'", k))?;
414            if !(1..=12).contains(&n) {
415                return Err(format!("F-key out of range: F{} (expected F1–F12)", n));
416            }
417            KeyCode::F(n)
418        }
419        "Space" => KeyCode::Char(' '),
420        "Tab" => KeyCode::Tab,
421        "Enter" => KeyCode::Enter,
422        "Esc" => KeyCode::Esc,
423        "Backspace" | "BS" => KeyCode::Backspace,
424        "Delete" | "Del" => KeyCode::Delete,
425        "Home" => KeyCode::Home,
426        "End" => KeyCode::End,
427        "PageUp" | "PgUp" => KeyCode::PageUp,
428        "PageDown" | "PgDn" => KeyCode::PageDown,
429        "Up" => KeyCode::Up,
430        "Down" => KeyCode::Down,
431        "Left" => KeyCode::Left,
432        "Right" => KeyCode::Right,
433        other => return Err(format!("unknown key: '{}'", other)),
434    };
435
436    Ok(KeyCombo { code, modifiers })
437}
438
439/// Format a KeyCombo back to notation string (for display).
440pub fn format_key(combo: &KeyCombo) -> String {
441    let mut parts = Vec::new();
442    if combo.modifiers.contains(KeyModifiers::CONTROL) {
443        parts.push("Ctrl");
444    }
445    if combo.modifiers.contains(KeyModifiers::ALT) {
446        parts.push("Alt");
447    }
448    if combo.modifiers.contains(KeyModifiers::SHIFT) {
449        parts.push("Shift");
450    }
451
452    let key = match combo.code {
453        KeyCode::Char(' ') => "Space".to_string(),
454        KeyCode::Char(c) => c.to_uppercase().to_string(),
455        KeyCode::F(n) => format!("F{}", n),
456        KeyCode::Tab => "Tab".to_string(),
457        KeyCode::Enter => "Enter".to_string(),
458        KeyCode::Esc => "Esc".to_string(),
459        KeyCode::Backspace => "Backspace".to_string(),
460        KeyCode::Delete => "Delete".to_string(),
461        KeyCode::Home => "Home".to_string(),
462        KeyCode::End => "End".to_string(),
463        KeyCode::PageUp => "PageUp".to_string(),
464        KeyCode::PageDown => "PageDown".to_string(),
465        KeyCode::Up => "↑".to_string(),
466        KeyCode::Down => "↓".to_string(),
467        KeyCode::Left => "←".to_string(),
468        KeyCode::Right => "→".to_string(),
469        _ => "?".to_string(),
470    };
471    parts.push(&key);
472    parts.join("+")
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    // ── parse_key tests ──
480
481    #[test]
482    fn parse_single_char() {
483        let k = parse_key("s").unwrap();
484        assert_eq!(k.code, KeyCode::Char('s'));
485        assert_eq!(k.modifiers, KeyModifiers::NONE);
486    }
487
488    #[test]
489    fn parse_ctrl_char() {
490        let k = parse_key("C-s").unwrap();
491        assert_eq!(k.code, KeyCode::Char('s'));
492        assert_eq!(k.modifiers, KeyModifiers::CONTROL);
493    }
494
495    #[test]
496    fn parse_ctrl_shift() {
497        let k = parse_key("C-S-s").unwrap();
498        assert_eq!(k.code, KeyCode::Char('s'));
499        assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
500    }
501
502    #[test]
503    fn parse_alt() {
504        let k = parse_key("A-p").unwrap();
505        assert_eq!(k.code, KeyCode::Char('p'));
506        assert_eq!(k.modifiers, KeyModifiers::ALT);
507    }
508
509    #[test]
510    fn parse_ctrl_alt() {
511        let k = parse_key("C-A-x").unwrap();
512        assert_eq!(k.code, KeyCode::Char('x'));
513        assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::ALT);
514    }
515
516    #[test]
517    fn parse_f_keys() {
518        let k = parse_key("F5").unwrap();
519        assert_eq!(k.code, KeyCode::F(5));
520        assert_eq!(k.modifiers, KeyModifiers::NONE);
521
522        let k = parse_key("C-F12").unwrap();
523        assert_eq!(k.code, KeyCode::F(12));
524        assert_eq!(k.modifiers, KeyModifiers::CONTROL);
525    }
526
527    #[test]
528    fn parse_special_keys() {
529        assert_eq!(parse_key("Space").unwrap().code, KeyCode::Char(' '));
530        assert_eq!(parse_key("Tab").unwrap().code, KeyCode::Tab);
531        assert_eq!(parse_key("Enter").unwrap().code, KeyCode::Enter);
532        assert_eq!(parse_key("Esc").unwrap().code, KeyCode::Esc);
533        assert_eq!(parse_key("Backspace").unwrap().code, KeyCode::Backspace);
534        assert_eq!(parse_key("Home").unwrap().code, KeyCode::Home);
535        assert_eq!(parse_key("End").unwrap().code, KeyCode::End);
536    }
537
538    #[test]
539    fn parse_ctrl_space() {
540        let k = parse_key("C-Space").unwrap();
541        assert_eq!(k.code, KeyCode::Char(' '));
542        assert_eq!(k.modifiers, KeyModifiers::CONTROL);
543    }
544
545    #[test]
546    fn parse_uppercase_normalized_to_lower() {
547        let k = parse_key("C-S").unwrap();
548        assert_eq!(k.code, KeyCode::Char('s'));
549    }
550
551    #[test]
552    fn parse_empty_errors() {
553        assert!(parse_key("").is_err());
554        assert!(parse_key("  ").is_err());
555    }
556
557    #[test]
558    fn parse_unknown_modifier_errors() {
559        assert!(parse_key("X-s").is_err());
560    }
561
562    #[test]
563    fn parse_unknown_key_errors() {
564        assert!(parse_key("C-FooBar").is_err());
565    }
566
567    #[test]
568    fn parse_f_key_out_of_range() {
569        assert!(parse_key("F0").is_err());
570        assert!(parse_key("F13").is_err());
571    }
572
573    // ── format_key tests ──
574
575    #[test]
576    fn format_ctrl_shift_s() {
577        let k = KeyCombo {
578            code: KeyCode::Char('s'),
579            modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
580        };
581        assert_eq!(format_key(&k), "Ctrl+Shift+S");
582    }
583
584    #[test]
585    fn format_f5() {
586        let k = KeyCombo {
587            code: KeyCode::F(5),
588            modifiers: KeyModifiers::NONE,
589        };
590        assert_eq!(format_key(&k), "F5");
591    }
592
593    #[test]
594    fn format_alt_space() {
595        let k = KeyCombo {
596            code: KeyCode::Char(' '),
597            modifiers: KeyModifiers::ALT,
598        };
599        assert_eq!(format_key(&k), "Alt+Space");
600    }
601
602    // ── registry tests ──
603
604    #[test]
605    fn core_binds_are_reserved() {
606        let reg = KeybindRegistry::new();
607        // Ctrl+C should not match (it's core)
608        assert!(reg
609            .match_key(KeyCode::Char('c'), KeyModifiers::CONTROL)
610            .is_none());
611    }
612
613    #[test]
614    fn plugin_bind_matches() {
615        let mut reg = KeybindRegistry::new();
616        reg.register_plugin(
617            "test",
618            &[ManifestKeybind {
619                key: "C-S-s".to_string(),
620                action: "slash_command".to_string(),
621                command: Some("scholar".to_string()),
622                skill: None,
623                prompt: None,
624                script: None,
625                description: Some("Search papers".to_string()),
626            }],
627            std::path::Path::new("/tmp"),
628        );
629
630        let result = reg.match_key(
631            KeyCode::Char('s'),
632            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
633        );
634        assert!(result.is_some());
635        assert_eq!(result.unwrap().description, "Search papers");
636    }
637
638    #[test]
639    fn plugin_cannot_override_core() {
640        let mut reg = KeybindRegistry::new();
641        reg.register_plugin(
642            "evil",
643            &[ManifestKeybind {
644                key: "C-c".to_string(),
645                action: "inject_prompt".to_string(),
646                command: None,
647                skill: None,
648                prompt: Some("hacked".to_string()),
649                script: None,
650                description: Some("evil".to_string()),
651            }],
652            std::path::Path::new("/tmp"),
653        );
654
655        // Ctrl+C should still not match (core)
656        assert!(reg
657            .match_key(KeyCode::Char('c'), KeyModifiers::CONTROL)
658            .is_none());
659    }
660
661    #[test]
662    fn user_overrides_plugin() {
663        let mut reg = KeybindRegistry::new();
664        reg.register_plugin(
665            "test",
666            &[ManifestKeybind {
667                key: "F5".to_string(),
668                action: "slash_command".to_string(),
669                command: Some("scholar".to_string()),
670                skill: None,
671                prompt: None,
672                script: None,
673                description: Some("Scholar".to_string()),
674            }],
675            std::path::Path::new("/tmp"),
676        );
677
678        let mut overrides = std::collections::HashMap::new();
679        overrides.insert("F5".to_string(), "/compact".to_string());
680        reg.register_user(&overrides);
681
682        let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
683        assert!(result.is_some());
684        assert_eq!(result.unwrap().description, "User: /compact");
685    }
686
687    #[test]
688    fn user_can_disable_bind() {
689        let mut reg = KeybindRegistry::new();
690        reg.register_plugin(
691            "test",
692            &[ManifestKeybind {
693                key: "F5".to_string(),
694                action: "slash_command".to_string(),
695                command: Some("scholar".to_string()),
696                skill: None,
697                prompt: None,
698                script: None,
699                description: Some("Scholar".to_string()),
700            }],
701            std::path::Path::new("/tmp"),
702        );
703
704        let mut overrides = std::collections::HashMap::new();
705        overrides.insert("F5".to_string(), "disabled".to_string());
706        reg.register_user(&overrides);
707
708        let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
709        assert!(result.is_some());
710        assert!(matches!(result.unwrap().action, KeybindAction::Disabled));
711    }
712
713    #[test]
714    fn duplicate_plugin_binds_first_wins() {
715        let mut reg = KeybindRegistry::new();
716        reg.register_plugin(
717            "first",
718            &[ManifestKeybind {
719                key: "F5".to_string(),
720                action: "slash_command".to_string(),
721                command: Some("first".to_string()),
722                skill: None,
723                prompt: None,
724                script: None,
725                description: Some("First".to_string()),
726            }],
727            std::path::Path::new("/tmp"),
728        );
729
730        reg.register_plugin(
731            "second",
732            &[ManifestKeybind {
733                key: "F5".to_string(),
734                action: "slash_command".to_string(),
735                command: Some("second".to_string()),
736                skill: None,
737                prompt: None,
738                script: None,
739                description: Some("Second".to_string()),
740            }],
741            std::path::Path::new("/tmp"),
742        );
743
744        let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
745        assert!(result.is_some());
746        assert_eq!(result.unwrap().description, "First");
747    }
748
749    #[test]
750    fn custom_binds_excludes_core() {
751        let reg = KeybindRegistry::new();
752        let custom = reg.custom_binds();
753        assert!(custom.is_empty()); // No plugins registered = no custom binds
754    }
755
756    #[test]
757    fn set_slash_command_key_replaces_existing_sidecar_toggle() {
758        let mut reg = KeybindRegistry::new();
759        let mut overrides = std::collections::HashMap::new();
760        overrides.insert("F8".to_string(), "/sidecar toggle".to_string());
761        reg.register_user(&overrides);
762        let f8 = parse_key("F8").unwrap();
763        assert!(reg.match_key(f8.code, f8.modifiers).is_some());
764
765        // Move sidecar toggle from F8 → C-G
766        reg.set_slash_command_key("sidecar toggle", "C-G").unwrap();
767
768        // F8 no longer fires
769        assert!(reg.match_key(f8.code, f8.modifiers).is_none());
770        // C-G now does
771        let cg = parse_key("C-G").unwrap();
772        let bind = reg
773            .match_key(cg.code, cg.modifiers)
774            .expect("C-G bind missing");
775        assert!(matches!(&bind.action, KeybindAction::SlashCommand(c) if c == "sidecar toggle"));
776    }
777
778    #[test]
779    fn set_slash_command_key_rejects_core_chord() {
780        let mut reg = KeybindRegistry::new();
781        // Esc is reserved core
782        let err = reg
783            .set_slash_command_key("sidecar toggle", "Esc")
784            .unwrap_err();
785        assert!(
786            err.contains("reserved"),
787            "expected reserved error, got: {err}"
788        );
789    }
790
791    // ── collision recording (Phase 8 slice 8B.2) ──
792
793    fn mk_kb(key: &str, cmd: &str) -> ManifestKeybind {
794        ManifestKeybind {
795            key: key.to_string(),
796            action: "slash_command".to_string(),
797            command: Some(cmd.to_string()),
798            skill: None,
799            prompt: None,
800            script: None,
801            description: Some(cmd.to_string()),
802        }
803    }
804
805    /// 04-tui I7: Alt-↑/↓ switch subagent views (macOS takes Ctrl-↑/↓ for
806    /// Mission Control), so a plugin can't take them either.
807    #[test]
808    fn alt_arrows_are_reserved_for_subagent_views() {
809        let mut reg = KeybindRegistry::new();
810        reg.register_plugin(
811            "p",
812            &[mk_kb("A-Up", "x"), mk_kb("A-Down", "y")],
813            std::path::Path::new("/tmp"),
814        );
815        assert!(reg.match_key(KeyCode::Up, KeyModifiers::ALT).is_none());
816        assert!(reg.match_key(KeyCode::Down, KeyModifiers::ALT).is_none());
817        assert_eq!(reg.collisions().len(), 2);
818    }
819
820    #[test]
821    fn register_plugin_records_core_collision() {
822        let mut reg = KeybindRegistry::new();
823        // C-c is reserved by core (Quit).
824        reg.register_plugin(
825            "evil",
826            &[mk_kb("C-c", "hack")],
827            std::path::Path::new("/tmp"),
828        );
829        assert_eq!(reg.collisions().len(), 1);
830        let c = &reg.collisions()[0];
831        assert_eq!(c.losing_plugin, "evil");
832        assert_eq!(c.winning_owner, "core");
833        assert_eq!(c.reason, "conflicts with core");
834        assert_eq!(c.key, "C-c");
835    }
836
837    #[test]
838    fn register_plugin_records_plugin_vs_plugin_collision() {
839        let mut reg = KeybindRegistry::new();
840        // C-Space is not reserved by core.
841        reg.register_plugin(
842            "A",
843            &[mk_kb("C-Space", "alpha")],
844            std::path::Path::new("/tmp"),
845        );
846        reg.register_plugin(
847            "B",
848            &[mk_kb("C-Space", "beta")],
849            std::path::Path::new("/tmp"),
850        );
851        assert_eq!(reg.collisions().len(), 1);
852        let c = &reg.collisions()[0];
853        assert_eq!(c.losing_plugin, "B");
854        assert_eq!(c.winning_owner, "A");
855        assert_eq!(c.reason, "already registered");
856    }
857
858    #[test]
859    fn register_plugin_records_invalid_key_notation() {
860        let mut reg = KeybindRegistry::new();
861        reg.register_plugin(
862            "weird",
863            &[mk_kb("this is not a key", "noop")],
864            std::path::Path::new("/tmp"),
865        );
866        assert_eq!(reg.collisions().len(), 1);
867        let c = &reg.collisions()[0];
868        assert_eq!(c.losing_plugin, "weird");
869        assert_eq!(c.winning_owner, "n/a");
870        assert!(
871            c.reason.starts_with("invalid notation"),
872            "reason should start with 'invalid notation', got: {}",
873            c.reason
874        );
875    }
876
877    #[test]
878    fn collisions_is_empty_when_no_conflicts() {
879        let mut reg = KeybindRegistry::new();
880        reg.register_plugin("solo", &[mk_kb("F7", "solo")], std::path::Path::new("/tmp"));
881        assert!(reg.collisions().is_empty());
882    }
883
884    #[test]
885    fn multiple_collisions_are_all_recorded() {
886        let mut reg = KeybindRegistry::new();
887        reg.register_plugin(
888            "A",
889            &[mk_kb("C-Space", "alpha")],
890            std::path::Path::new("/tmp"),
891        );
892        // Two more plugins each colliding on the same key.
893        reg.register_plugin(
894            "B",
895            &[mk_kb("C-Space", "beta")],
896            std::path::Path::new("/tmp"),
897        );
898        reg.register_plugin(
899            "C",
900            &[mk_kb("C-Space", "gamma")],
901            std::path::Path::new("/tmp"),
902        );
903        assert_eq!(reg.collisions().len(), 2);
904        assert_eq!(reg.collisions()[0].losing_plugin, "B");
905        assert_eq!(reg.collisions()[1].losing_plugin, "C");
906        for c in reg.collisions() {
907            assert_eq!(c.winning_owner, "A");
908            assert_eq!(c.reason, "already registered");
909        }
910    }
911}