Skip to main content

fresh/input/
command_registry.rs

1//! Dynamic command registry for plugins and extensions
2//!
3//! This module allows plugins to register custom commands dynamically
4//! while maintaining the built-in command set.
5
6use crate::input::commands::{get_all_commands, Command, CommandSource, Suggestion};
7use crate::input::fuzzy::fuzzy_match;
8use crate::input::keybindings::Action;
9use crate::input::keybindings::KeyContext;
10use std::sync::{Arc, RwLock};
11
12/// Registry for managing editor commands
13///
14/// Supports both built-in commands and dynamically registered plugin commands.
15/// Thread-safe for use across multiple threads (e.g., from async tasks).
16pub struct CommandRegistry {
17    /// Built-in commands (loaded once at startup)
18    builtin_commands: Vec<Command>,
19
20    /// Plugin-registered commands (dynamically added/removed)
21    plugin_commands: Arc<RwLock<Vec<Command>>>,
22
23    /// Command usage history (most recent first)
24    /// Used to sort command palette suggestions by recency
25    command_history: Vec<String>,
26}
27
28impl CommandRegistry {
29    /// Maximum number of commands to keep in history
30    const MAX_HISTORY_SIZE: usize = 50;
31
32    /// Create a new command registry with built-in commands
33    pub fn new() -> Self {
34        Self {
35            builtin_commands: get_all_commands(),
36            plugin_commands: Arc::new(RwLock::new(Vec::new())),
37            command_history: Vec::new(),
38        }
39    }
40
41    /// Refresh built-in commands (e.g. after locale change)
42    pub fn refresh_builtin_commands(&mut self) {
43        self.builtin_commands = get_all_commands();
44    }
45
46    /// `true` iff `action` matches a registered command that was
47    /// declared with `terminalBypass: true` (plugins) or already
48    /// bypasses via the built-in `is_terminal_ui_action` allowlist.
49    ///
50    /// Built-in actions (CommandPalette, QuickOpen, …) are intentionally
51    /// not flagged here — they bypass through
52    /// [`KeybindingResolver::is_terminal_ui_action`] which the
53    /// terminal input handler checks separately. This method is the
54    /// plugin-driven extension: any plugin command can opt in to the
55    /// same bypass-while-terminal-is-focused behaviour via the
56    /// `registerCommand({ terminalBypass: true })` option.
57    pub fn is_terminal_bypass_action(&self, action: &crate::input::keybindings::Action) -> bool {
58        use crate::input::keybindings::Action;
59        let Action::PluginAction(target) = action else {
60            return false;
61        };
62        let plugins = self.plugin_commands.read().unwrap();
63        plugins.iter().any(|cmd| {
64            cmd.terminal_bypass
65                && matches!(&cmd.action, Action::PluginAction(name) if name == target)
66        })
67    }
68
69    /// Record that a command was used (for history/sorting)
70    ///
71    /// This moves the command to the front of the history list.
72    /// Recently used commands appear first in suggestions.
73    pub fn record_usage(&mut self, command_name: &str) {
74        // Remove existing entry if present
75        self.command_history.retain(|name| name != command_name);
76
77        // Add to front (most recent)
78        self.command_history.insert(0, command_name.to_string());
79
80        // Trim to max size
81        if self.command_history.len() > Self::MAX_HISTORY_SIZE {
82            self.command_history.truncate(Self::MAX_HISTORY_SIZE);
83        }
84    }
85
86    /// Get the position of a command in history (0 = most recent)
87    /// Returns None if command is not in history
88    fn history_position(&self, command_name: &str) -> Option<usize> {
89        self.command_history
90            .iter()
91            .position(|name| name == command_name)
92    }
93
94    /// Register a new command (typically from a plugin)
95    ///
96    /// If a command with the same name already exists, it will be replaced.
97    /// This allows plugins to override built-in commands.
98    /// Note: For plugin commands, prefer `try_register` which enforces
99    /// first-writer-wins semantics. This method is kept for internal use
100    /// and hot-reload scenarios.
101    pub fn register(&self, command: Command) {
102        tracing::debug!(
103            "CommandRegistry::register: name='{}', action={:?}",
104            command.name,
105            command.action
106        );
107        let mut commands = self.plugin_commands.write().unwrap();
108
109        // Remove existing command with same name
110        commands.retain(|c| c.name != command.name);
111
112        // Add new command
113        commands.push(command);
114        tracing::debug!(
115            "CommandRegistry::register: plugin_commands now has {} items",
116            commands.len()
117        );
118    }
119
120    /// Try to register a command, failing if a command with the same name
121    /// is already registered by a different plugin (first-writer-wins).
122    ///
123    /// Returns `Ok(())` on success, or `Err` with the name of the existing
124    /// plugin that owns the command.
125    pub fn try_register(&self, command: Command) -> Result<(), (String, CommandSource)> {
126        let mut commands = self.plugin_commands.write().unwrap();
127
128        if let Some(existing) = commands.iter().find(|c| c.name == command.name) {
129            // Allow same plugin to re-register (hot-reload)
130            if existing.source == command.source {
131                commands.retain(|c| c.name != command.name);
132                commands.push(command);
133                return Ok(());
134            }
135            return Err((existing.name.clone(), existing.source.clone()));
136        }
137
138        commands.push(command);
139        Ok(())
140    }
141
142    /// Unregister a command by name
143    pub fn unregister(&self, name: &str) {
144        let mut commands = self.plugin_commands.write().unwrap();
145        commands.retain(|c| c.name != name);
146    }
147
148    /// Unregister all commands whose name starts with a prefix
149    pub fn unregister_by_prefix(&self, prefix: &str) {
150        let mut commands = self.plugin_commands.write().unwrap();
151        commands.retain(|c| !c.name.starts_with(prefix));
152    }
153
154    /// Unregister all commands registered by a specific plugin
155    pub fn unregister_by_plugin(&self, plugin_name: &str) {
156        let mut commands = self.plugin_commands.write().unwrap();
157        let before = commands.len();
158        commands.retain(|c| {
159            if let super::commands::CommandSource::Plugin(ref name) = c.source {
160                name != plugin_name
161            } else {
162                true
163            }
164        });
165        let removed = before - commands.len();
166        if removed > 0 {
167            tracing::debug!(
168                "Unregistered {} commands from plugin '{}'",
169                removed,
170                plugin_name
171            );
172        }
173    }
174
175    /// Get all commands (built-in + plugin)
176    pub fn get_all(&self) -> Vec<Command> {
177        let mut all_commands = self.builtin_commands.clone();
178
179        let plugin_commands = self.plugin_commands.read().unwrap();
180        let plugin_count = plugin_commands.len();
181
182        // Debug: check if vi_mode_toggle is in plugin commands
183        let target_action =
184            crate::input::keybindings::Action::PluginAction("vi_mode_toggle".to_string());
185        let has_target = plugin_commands.iter().any(|c| c.action == target_action);
186        if has_target {
187            tracing::debug!("get_all: vi_mode_toggle found via comparison!");
188        } else if plugin_count > 0 {
189            tracing::debug!(
190                "get_all: {} plugin commands but vi_mode_toggle NOT found",
191                plugin_count
192            );
193        }
194
195        all_commands.extend(plugin_commands.iter().cloned());
196
197        tracing::trace!(
198            "CommandRegistry::get_all: {} builtin + {} plugin = {} total",
199            self.builtin_commands.len(),
200            plugin_count,
201            all_commands.len()
202        );
203        all_commands
204    }
205
206    /// Filter commands by fuzzy matching query with context awareness
207    ///
208    /// When query is empty, commands are sorted by recency (most recently used first).
209    /// When query is not empty, commands are sorted by match quality (fzf-style scoring)
210    /// with recency as tiebreaker for equal scores.
211    /// Disabled commands always appear after enabled ones.
212    ///
213    /// `has_lsp_config` indicates whether the active buffer's language has an LSP server
214    /// configured. When false, LSP start/restart/toggle commands are disabled.
215    #[allow(clippy::too_many_arguments)]
216    pub fn filter(
217        &self,
218        query: &str,
219        current_context: KeyContext,
220        keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
221        selection_active: bool,
222        active_custom_contexts: &std::collections::HashSet<String>,
223        active_buffer_mode: Option<&str>,
224        has_lsp_config: bool,
225    ) -> Vec<Suggestion> {
226        let commands = self.get_all();
227
228        // Helper function to check if command should be visible (custom context check)
229        // Commands with unmet custom contexts are completely hidden, not just disabled
230        // A custom context is satisfied if:
231        // 1. It's in the global active_custom_contexts set, OR
232        // 2. It matches the focused buffer's mode (for buffer-scoped commands)
233        let is_visible = |cmd: &Command| -> bool {
234            cmd.custom_contexts.is_empty()
235                || cmd.custom_contexts.iter().all(|ctx| {
236                    active_custom_contexts.contains(ctx)
237                        || active_buffer_mode.is_some_and(|mode| mode == ctx)
238                })
239        };
240
241        // Helper function to check if command is available in current context
242        let is_available = |cmd: &Command| -> bool {
243            // Global commands are always available
244            if cmd.contexts.contains(&KeyContext::Global) {
245                return true;
246            }
247
248            // Check built-in contexts
249            cmd.contexts.is_empty() || cmd.contexts.contains(&current_context)
250        };
251
252        // Helper to create a suggestion from a command
253        let current_context_ref = &current_context;
254        let make_suggestion =
255            |cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
256                let mut available = is_available(cmd);
257                if cmd.action == Action::FindInSelection && !selection_active {
258                    available = false;
259                }
260                // Disable LSP start/restart/toggle commands when no LSP is configured
261                if !has_lsp_config
262                    && matches!(cmd.action, Action::LspRestart | Action::LspToggleForBuffer)
263                {
264                    available = false;
265                }
266                let keybinding = keybinding_resolver
267                    .get_keybinding_for_action(&cmd.action, current_context_ref.clone());
268                let history_pos = self.history_position(&cmd.name);
269
270                let suggestion = Suggestion::new(localized_name)
271                    .with_description(localized_desc)
272                    .set_disabled(!available)
273                    .with_keybinding(keybinding)
274                    .with_source(Some(cmd.source.clone()));
275                (suggestion, history_pos, score)
276            };
277
278        // Match by name or description
279        // Commands with unmet custom contexts are completely hidden
280        // match_kind: 0 = name match, 1 = description match
281        let mut suggestions: Vec<(Suggestion, Option<usize>, i32, u8)> = commands
282            .iter()
283            .filter(|cmd| is_visible(cmd))
284            .filter_map(|cmd| {
285                let localized_name = cmd.get_localized_name();
286                let name_result = fuzzy_match(query, &localized_name);
287                if name_result.matched {
288                    let localized_desc = cmd.get_localized_description();
289                    let (suggestion, hist, score) =
290                        make_suggestion(cmd, name_result.score, localized_name, localized_desc);
291                    Some((suggestion, hist, score, 0))
292                } else if !query.is_empty() {
293                    let localized_desc = cmd.get_localized_description();
294                    let desc_result = fuzzy_match(query, &localized_desc);
295                    if desc_result.matched {
296                        let (suggestion, hist, score) =
297                            make_suggestion(cmd, desc_result.score, localized_name, localized_desc);
298                        Some((suggestion, hist, score, 1))
299                    } else {
300                        None
301                    }
302                } else {
303                    None
304                }
305            })
306            .collect();
307
308        // Sort by:
309        // 1. Disabled status (enabled first)
310        // 2. Match kind (name matches before description matches) - only when query is not empty
311        // 3. Fuzzy match score (higher is better) - only when query is not empty
312        // 4. History position (recent first, then never-used alphabetically)
313        let has_query = !query.is_empty();
314        suggestions.sort_by(
315            |(a, a_hist, a_score, a_kind), (b, b_hist, b_score, b_kind)| {
316                // First sort by disabled status
317                match a.disabled.cmp(&b.disabled) {
318                    std::cmp::Ordering::Equal => {}
319                    other => return other,
320                }
321
322                if has_query {
323                    // Name matches before description matches
324                    match a_kind.cmp(b_kind) {
325                        std::cmp::Ordering::Equal => {}
326                        other => return other,
327                    }
328
329                    // Within the same kind, sort by fuzzy score (higher is better)
330                    match b_score.cmp(a_score) {
331                        std::cmp::Ordering::Equal => {}
332                        other => return other,
333                    }
334                }
335
336                // Then sort by history position (lower = more recent = better)
337                match (a_hist, b_hist) {
338                    (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
339                    (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history
340                    (None, Some(_)) => std::cmp::Ordering::Greater,
341                    (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands
342                }
343            },
344        );
345
346        // Extract just the suggestions
347        suggestions.into_iter().map(|(s, _, _, _)| s).collect()
348    }
349
350    /// Get count of registered plugin commands
351    pub fn plugin_command_count(&self) -> usize {
352        self.plugin_commands.read().unwrap().len()
353    }
354
355    /// Get count of total commands (built-in + plugin)
356    pub fn total_command_count(&self) -> usize {
357        self.builtin_commands.len() + self.plugin_command_count()
358    }
359
360    /// Find a command by exact name match
361    pub fn find_by_name(&self, name: &str) -> Option<Command> {
362        // Check plugin commands first (they can override built-in)
363        {
364            let plugin_commands = self.plugin_commands.read().unwrap();
365            if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
366                return Some(cmd.clone());
367            }
368        }
369
370        // Then check built-in commands
371        self.builtin_commands
372            .iter()
373            .find(|c| c.name == name)
374            .cloned()
375    }
376}
377
378impl Default for CommandRegistry {
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::input::commands::CommandSource;
388    use crate::input::keybindings::Action;
389
390    #[test]
391    fn test_command_registry_creation() {
392        let registry = CommandRegistry::new();
393        assert!(registry.total_command_count() > 0); // Has built-in commands
394        assert_eq!(registry.plugin_command_count(), 0); // No plugin commands yet
395    }
396
397    #[test]
398    fn test_register_command() {
399        let registry = CommandRegistry::new();
400
401        let custom_command = Command {
402            name: "Test Command".to_string(),
403            description: "A test command".to_string(),
404            action: Action::None,
405            contexts: vec![],
406            custom_contexts: vec![],
407            source: CommandSource::Builtin,
408            terminal_bypass: false,
409        };
410
411        registry.register(custom_command.clone());
412        assert_eq!(registry.plugin_command_count(), 1);
413
414        let found = registry.find_by_name("Test Command");
415        assert!(found.is_some());
416        assert_eq!(found.unwrap().description, "A test command");
417    }
418
419    #[test]
420    fn test_unregister_command() {
421        let registry = CommandRegistry::new();
422
423        let custom_command = Command {
424            name: "Test Command".to_string(),
425            description: "A test command".to_string(),
426            action: Action::None,
427            contexts: vec![],
428            custom_contexts: vec![],
429            source: CommandSource::Builtin,
430            terminal_bypass: false,
431        };
432
433        registry.register(custom_command);
434        assert_eq!(registry.plugin_command_count(), 1);
435
436        registry.unregister("Test Command");
437        assert_eq!(registry.plugin_command_count(), 0);
438    }
439
440    #[test]
441    fn test_register_replaces_existing() {
442        let registry = CommandRegistry::new();
443
444        let command1 = Command {
445            name: "Test Command".to_string(),
446            description: "First version".to_string(),
447            action: Action::None,
448            contexts: vec![],
449            custom_contexts: vec![],
450            source: CommandSource::Builtin,
451            terminal_bypass: false,
452        };
453
454        let command2 = Command {
455            name: "Test Command".to_string(),
456            description: "Second version".to_string(),
457            action: Action::None,
458            contexts: vec![],
459            custom_contexts: vec![],
460            source: CommandSource::Builtin,
461            terminal_bypass: false,
462        };
463
464        registry.register(command1);
465        assert_eq!(registry.plugin_command_count(), 1);
466
467        registry.register(command2);
468        assert_eq!(registry.plugin_command_count(), 1); // Still just one
469
470        let found = registry.find_by_name("Test Command").unwrap();
471        assert_eq!(found.description, "Second version");
472    }
473
474    #[test]
475    fn test_unregister_by_prefix() {
476        let registry = CommandRegistry::new();
477
478        registry.register(Command {
479            name: "Plugin A: Command 1".to_string(),
480            description: "".to_string(),
481            action: Action::None,
482            contexts: vec![],
483            custom_contexts: vec![],
484            source: CommandSource::Builtin,
485            terminal_bypass: false,
486        });
487
488        registry.register(Command {
489            name: "Plugin A: Command 2".to_string(),
490            description: "".to_string(),
491            action: Action::None,
492            contexts: vec![],
493            custom_contexts: vec![],
494            source: CommandSource::Builtin,
495            terminal_bypass: false,
496        });
497
498        registry.register(Command {
499            name: "Plugin B: Command".to_string(),
500            description: "".to_string(),
501            action: Action::None,
502            contexts: vec![],
503            custom_contexts: vec![],
504            source: CommandSource::Builtin,
505            terminal_bypass: false,
506        });
507
508        assert_eq!(registry.plugin_command_count(), 3);
509
510        registry.unregister_by_prefix("Plugin A:");
511        assert_eq!(registry.plugin_command_count(), 1);
512
513        let remaining = registry.find_by_name("Plugin B: Command");
514        assert!(remaining.is_some());
515    }
516
517    #[test]
518    fn test_filter_commands() {
519        use crate::config::Config;
520        use crate::input::keybindings::KeybindingResolver;
521
522        let registry = CommandRegistry::new();
523        let config = Config::default();
524        let keybindings = KeybindingResolver::new(&config);
525
526        registry.register(Command {
527            name: "Test Save".to_string(),
528            description: "Test save command".to_string(),
529            action: Action::None,
530            contexts: vec![KeyContext::Normal],
531            custom_contexts: vec![],
532            source: CommandSource::Builtin,
533            terminal_bypass: false,
534        });
535
536        let empty_contexts = std::collections::HashSet::new();
537        let results = registry.filter(
538            "save",
539            KeyContext::Normal,
540            &keybindings,
541            false,
542            &empty_contexts,
543            None,
544            true,
545        );
546        assert!(results.len() >= 2); // At least "Save File" + "Test Save"
547
548        // Check that both built-in and custom commands appear
549        let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
550        assert!(names.iter().any(|n| n.contains("Save")));
551    }
552
553    #[test]
554    fn test_context_filtering() {
555        use crate::config::Config;
556        use crate::input::keybindings::KeybindingResolver;
557
558        let registry = CommandRegistry::new();
559        let config = Config::default();
560        let keybindings = KeybindingResolver::new(&config);
561
562        registry.register(Command {
563            name: "Normal Only".to_string(),
564            description: "Available only in normal context".to_string(),
565            action: Action::None,
566            contexts: vec![KeyContext::Normal],
567            custom_contexts: vec![],
568            source: CommandSource::Builtin,
569            terminal_bypass: false,
570        });
571
572        registry.register(Command {
573            name: "Popup Only".to_string(),
574            description: "Available only in popup context".to_string(),
575            action: Action::None,
576            contexts: vec![KeyContext::Popup],
577            custom_contexts: vec![],
578            source: CommandSource::Builtin,
579            terminal_bypass: false,
580        });
581
582        // In normal context, "Popup Only" should be disabled
583        let empty_contexts = std::collections::HashSet::new();
584        let results = registry.filter(
585            "",
586            KeyContext::Normal,
587            &keybindings,
588            false,
589            &empty_contexts,
590            None,
591            true,
592        );
593        let popup_only = results.iter().find(|s| s.text == "Popup Only");
594        assert!(popup_only.is_some());
595        assert!(popup_only.unwrap().disabled);
596
597        // In popup context, "Normal Only" should be disabled
598        let results = registry.filter(
599            "",
600            KeyContext::Popup,
601            &keybindings,
602            false,
603            &empty_contexts,
604            None,
605            true,
606        );
607        let normal_only = results.iter().find(|s| s.text == "Normal Only");
608        assert!(normal_only.is_some());
609        assert!(normal_only.unwrap().disabled);
610    }
611
612    #[test]
613    fn test_get_all_merges_commands() {
614        let registry = CommandRegistry::new();
615        let initial_count = registry.total_command_count();
616
617        registry.register(Command {
618            name: "Custom 1".to_string(),
619            description: "".to_string(),
620            action: Action::None,
621            contexts: vec![],
622            custom_contexts: vec![],
623            source: CommandSource::Builtin,
624            terminal_bypass: false,
625        });
626
627        registry.register(Command {
628            name: "Custom 2".to_string(),
629            description: "".to_string(),
630            action: Action::None,
631            contexts: vec![],
632            custom_contexts: vec![],
633            source: CommandSource::Builtin,
634            terminal_bypass: false,
635        });
636
637        let all = registry.get_all();
638        assert_eq!(all.len(), initial_count + 2);
639    }
640
641    #[test]
642    fn test_plugin_command_overrides_builtin() {
643        let registry = CommandRegistry::new();
644
645        // Check a built-in command exists
646        let builtin = registry.find_by_name("Save File");
647        assert!(builtin.is_some());
648        let original_desc = builtin.unwrap().description;
649
650        // Override it with a plugin command
651        registry.register(Command {
652            name: "Save File".to_string(),
653            description: "Custom save implementation".to_string(),
654            action: Action::None,
655            contexts: vec![],
656            custom_contexts: vec![],
657            source: CommandSource::Builtin,
658            terminal_bypass: false,
659        });
660
661        // Should now find the custom version
662        let custom = registry.find_by_name("Save File").unwrap();
663        assert_eq!(custom.description, "Custom save implementation");
664        assert_ne!(custom.description, original_desc);
665    }
666
667    #[test]
668    fn test_record_usage() {
669        let mut registry = CommandRegistry::new();
670
671        registry.record_usage("Save File");
672        assert_eq!(registry.history_position("Save File"), Some(0));
673
674        registry.record_usage("Open File");
675        assert_eq!(registry.history_position("Open File"), Some(0));
676        assert_eq!(registry.history_position("Save File"), Some(1));
677
678        // Using Save File again should move it to front
679        registry.record_usage("Save File");
680        assert_eq!(registry.history_position("Save File"), Some(0));
681        assert_eq!(registry.history_position("Open File"), Some(1));
682    }
683
684    #[test]
685    fn test_history_sorting() {
686        use crate::config::Config;
687        use crate::input::keybindings::KeybindingResolver;
688
689        let mut registry = CommandRegistry::new();
690        let config = Config::default();
691        let keybindings = KeybindingResolver::new(&config);
692
693        // Record some commands
694        registry.record_usage("Quit");
695        registry.record_usage("Save File");
696        registry.record_usage("Open File");
697
698        // Filter with empty query should return history-sorted results
699        let empty_contexts = std::collections::HashSet::new();
700        let results = registry.filter(
701            "",
702            KeyContext::Normal,
703            &keybindings,
704            false,
705            &empty_contexts,
706            None,
707            true,
708        );
709
710        // Find positions of our test commands in results
711        let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
712        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
713        let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
714
715        // Most recently used should be first
716        assert!(
717            open_pos < save_pos,
718            "Open File should come before Save File"
719        );
720        assert!(save_pos < quit_pos, "Save File should come before Quit");
721    }
722
723    #[test]
724    fn test_history_max_size() {
725        let mut registry = CommandRegistry::new();
726
727        // Add more than MAX_HISTORY_SIZE commands
728        for i in 0..60 {
729            registry.record_usage(&format!("Command {}", i));
730        }
731
732        // Should be trimmed to MAX_HISTORY_SIZE
733        assert_eq!(
734            registry.command_history.len(),
735            CommandRegistry::MAX_HISTORY_SIZE
736        );
737
738        // Most recent should still be at front
739        assert_eq!(registry.history_position("Command 59"), Some(0));
740
741        // Oldest should be trimmed
742        assert_eq!(registry.history_position("Command 0"), None);
743    }
744
745    #[test]
746    fn test_unused_commands_alphabetical() {
747        use crate::config::Config;
748        use crate::input::keybindings::KeybindingResolver;
749
750        let mut registry = CommandRegistry::new();
751        let config = Config::default();
752        let keybindings = KeybindingResolver::new(&config);
753
754        // Register some custom commands (never used)
755        registry.register(Command {
756            name: "Zebra Command".to_string(),
757            description: "".to_string(),
758            action: Action::None,
759            contexts: vec![],
760            custom_contexts: vec![],
761            source: CommandSource::Builtin,
762            terminal_bypass: false,
763        });
764
765        registry.register(Command {
766            name: "Alpha Command".to_string(),
767            description: "".to_string(),
768            action: Action::None,
769            contexts: vec![],
770            custom_contexts: vec![],
771            source: CommandSource::Builtin,
772            terminal_bypass: false,
773        });
774
775        // Use one built-in command
776        registry.record_usage("Save File");
777
778        let empty_contexts = std::collections::HashSet::new();
779        let results = registry.filter(
780            "",
781            KeyContext::Normal,
782            &keybindings,
783            false,
784            &empty_contexts,
785            None,
786            true,
787        );
788
789        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
790        let alpha_pos = results
791            .iter()
792            .position(|s| s.text == "Alpha Command")
793            .unwrap();
794        let zebra_pos = results
795            .iter()
796            .position(|s| s.text == "Zebra Command")
797            .unwrap();
798
799        // Used command should be first
800        assert!(
801            save_pos < alpha_pos,
802            "Save File should come before Alpha Command"
803        );
804        // Unused commands should be alphabetical
805        assert!(
806            alpha_pos < zebra_pos,
807            "Alpha Command should come before Zebra Command"
808        );
809    }
810
811    #[test]
812    fn test_required_commands_exist() {
813        // This test ensures that all required command palette entries exist.
814        // If this test fails, it means a command was removed or renamed.
815        crate::i18n::set_locale("en");
816        let registry = CommandRegistry::new();
817
818        let required_commands = [
819            // LSP commands
820            ("Show Completions", Action::LspCompletion),
821            ("Go to Definition", Action::LspGotoDefinition),
822            ("Show Hover Info", Action::LspHover),
823            ("Find References", Action::LspReferences),
824            // Help commands
825            ("Show Manual", Action::ShowHelp),
826            ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
827            // Scroll commands
828            ("Scroll Up", Action::ScrollUp),
829            ("Scroll Down", Action::ScrollDown),
830            ("Scroll Tabs Left", Action::ScrollTabsLeft),
831            ("Scroll Tabs Right", Action::ScrollTabsRight),
832            // Navigation commands
833            ("Smart Home", Action::SmartHome),
834            // Delete commands
835            ("Delete Word Backward", Action::DeleteWordBackward),
836            ("Delete Word Forward", Action::DeleteWordForward),
837            ("Delete to End of Line", Action::DeleteToLineEnd),
838        ];
839
840        for (name, expected_action) in required_commands {
841            let cmd = registry.find_by_name(name);
842            assert!(
843                cmd.is_some(),
844                "Command '{}' should exist in command palette",
845                name
846            );
847            assert_eq!(
848                cmd.unwrap().action,
849                expected_action,
850                "Command '{}' should have action {:?}",
851                name,
852                expected_action
853            );
854        }
855    }
856
857    #[test]
858    fn test_try_register_first_writer_wins() {
859        let registry = CommandRegistry::new();
860
861        let cmd_a = Command {
862            name: "My Command".to_string(),
863            description: "From plugin A".to_string(),
864            action: Action::None,
865            contexts: vec![],
866            custom_contexts: vec![],
867            source: CommandSource::Plugin("plugin-a".to_string()),
868            terminal_bypass: false,
869        };
870
871        let cmd_b = Command {
872            name: "My Command".to_string(),
873            description: "From plugin B".to_string(),
874            action: Action::None,
875            contexts: vec![],
876            custom_contexts: vec![],
877            source: CommandSource::Plugin("plugin-b".to_string()),
878            terminal_bypass: false,
879        };
880
881        // First registration succeeds
882        assert!(registry.try_register(cmd_a).is_ok());
883        assert_eq!(registry.plugin_command_count(), 1);
884
885        // Second registration by different plugin fails
886        let result = registry.try_register(cmd_b);
887        assert!(result.is_err());
888        assert_eq!(registry.plugin_command_count(), 1);
889
890        // Original plugin's command is still there
891        let found = registry.find_by_name("My Command").unwrap();
892        assert_eq!(found.description, "From plugin A");
893    }
894
895    #[test]
896    fn test_try_register_same_plugin_allowed() {
897        let registry = CommandRegistry::new();
898
899        let cmd1 = Command {
900            name: "My Command".to_string(),
901            description: "Version 1".to_string(),
902            action: Action::None,
903            contexts: vec![],
904            custom_contexts: vec![],
905            source: CommandSource::Plugin("plugin-a".to_string()),
906            terminal_bypass: false,
907        };
908
909        let cmd2 = Command {
910            name: "My Command".to_string(),
911            description: "Version 2".to_string(),
912            action: Action::None,
913            contexts: vec![],
914            custom_contexts: vec![],
915            source: CommandSource::Plugin("plugin-a".to_string()),
916            terminal_bypass: false,
917        };
918
919        assert!(registry.try_register(cmd1).is_ok());
920        // Same plugin re-registering is allowed (hot-reload)
921        assert!(registry.try_register(cmd2).is_ok());
922        assert_eq!(registry.plugin_command_count(), 1);
923
924        let found = registry.find_by_name("My Command").unwrap();
925        assert_eq!(found.description, "Version 2");
926    }
927
928    #[test]
929    fn test_try_register_after_unregister() {
930        let registry = CommandRegistry::new();
931
932        let cmd_a = Command {
933            name: "My Command".to_string(),
934            description: "From plugin A".to_string(),
935            action: Action::None,
936            contexts: vec![],
937            custom_contexts: vec![],
938            source: CommandSource::Plugin("plugin-a".to_string()),
939            terminal_bypass: false,
940        };
941
942        let cmd_b = Command {
943            name: "My Command".to_string(),
944            description: "From plugin B".to_string(),
945            action: Action::None,
946            contexts: vec![],
947            custom_contexts: vec![],
948            source: CommandSource::Plugin("plugin-b".to_string()),
949            terminal_bypass: false,
950        };
951
952        // Plugin A registers
953        assert!(registry.try_register(cmd_a).is_ok());
954
955        // Unregister clears the slot
956        registry.unregister("My Command");
957        assert_eq!(registry.plugin_command_count(), 0);
958
959        // Now plugin B can register
960        assert!(registry.try_register(cmd_b).is_ok());
961        let found = registry.find_by_name("My Command").unwrap();
962        assert_eq!(found.description, "From plugin B");
963    }
964}