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