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