Skip to main content

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