Skip to main content

zeph_tui/
command.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Commands dispatched from the TUI command palette to the agent loop.
5///
6/// Each variant corresponds to a slash-command or keybinding action that the
7/// TUI can trigger. The agent loop receives these via an `mpsc` channel and
8/// produces a [`crate::event::AgentEvent::CommandResult`] response.
9///
10/// # Examples
11///
12/// ```rust
13/// use zeph_tui::TuiCommand;
14///
15/// let cmd = TuiCommand::SkillList;
16/// assert_eq!(cmd, TuiCommand::SkillList);
17/// ```
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TuiCommand {
20    // Existing view commands
21    SkillList,
22    McpList,
23    MemoryStats,
24    ViewCost,
25    ViewTools,
26    ViewConfig,
27    ViewAutonomy,
28    // New action commands
29    Quit,
30    Help,
31    NewSession,
32    ToggleTheme,
33    // Session history browser (H keybind)
34    SessionBrowser,
35    // Daemon / remote connection commands
36    DaemonConnect,
37    DaemonDisconnect,
38    DaemonStatus,
39    // Filter inspection
40    ViewFilters,
41    // Document ingestion
42    Ingest,
43    // Gateway
44    GatewayStatus,
45    // Scheduler
46    SchedulerList,
47    // Sub-agents (runtime)
48    AgentList,
49    AgentStatus,
50    AgentCancelPrompt,
51    AgentSpawnPrompt,
52    // Router
53    RouterStats,
54    // Sub-agent definitions (CRUD)
55    AgentsShow,
56    AgentsCreate,
57    AgentsEdit,
58    AgentsDelete,
59    // Security
60    SecurityEvents,
61    // Plan / orchestration
62    PlanStatus,
63    PlanConfirm,
64    PlanCancel,
65    PlanList,
66    PlanToggleView,
67    // Graph memory
68    GraphStats,
69    GraphEntities,
70    GraphFactsPrompt,
71    GraphCommunities,
72    GraphBackfillPrompt,
73    // Experiments
74    ExperimentStart,
75    ExperimentStop,
76    ExperimentStatus,
77    ExperimentReport,
78    ExperimentBest,
79    // LSP context injection
80    LspStatus,
81    // Log file
82    ViewLog,
83    // Config migration
84    MigrateConfig,
85    // Server-side compaction
86    ServerCompactionStatus,
87    // Compression guidelines
88    ViewGuidelines,
89    // Think-Augmented Function Calling
90    TafcStatus,
91    // SleepGate forgetting sweep
92    ForgettingSweep,
93    // Trajectory-informed memory (#2498)
94    TrajectoryStats,
95    // TiMem memory tree (#2262)
96    MemoryTreeStats,
97    // Task registry panel (#2962)
98    TaskPanel,
99    // Plugin management (#2806)
100    PluginList,
101    PluginAdd,
102    PluginRemove,
103    // Multi-session management (#3130, phase-1)
104    SessionSwitchNext,
105    SessionSwitchPrev,
106    SessionClose,
107    // Plugin overlay status (#3147)
108    PluginListOverlay,
109    // ACP read-only inspection (#3270)
110    AcpDirsList,
111    AcpAuthMethodsView,
112    AcpStatus,
113    // ACP sub-agent delegation (#3272)
114    SubagentSpawn { command: String },
115    // Sandbox egress status (#3294)
116    SandboxStatus,
117}
118
119/// Metadata for a single entry in the command palette.
120///
121/// Used for both display (label, category, shortcut hint) and fuzzy-matching
122/// (id + label are scored by [`filter_commands`]).
123///
124/// # Examples
125///
126/// ```rust
127/// use zeph_tui::command::{command_registry, CommandEntry};
128///
129/// let registry = command_registry();
130/// let quit = registry.iter().find(|e| e.id == "app:quit").unwrap();
131/// assert_eq!(quit.shortcut, Some("q"));
132/// ```
133pub struct CommandEntry {
134    /// Stable identifier used in fuzzy search and slash-command routing (e.g. `"skill:list"`).
135    pub id: &'static str,
136    /// Human-readable label shown in the command palette list.
137    pub label: &'static str,
138    /// Logical group for categorised display (e.g. `"memory"`, `"agent"`).
139    pub category: &'static str,
140    /// Optional keyboard shortcut hint (e.g. `"q"`, `"?"`).
141    pub shortcut: Option<&'static str>,
142    /// The [`TuiCommand`] dispatched when this entry is selected.
143    pub command: TuiCommand,
144}
145
146/// Returns the static registry of core TUI commands.
147///
148/// This includes navigation, session management, view toggles, and app-level
149/// actions. Extended commands (agent, plan, graph, experiment, infra) are in
150/// [`extra_command_registry`] and daemon commands in [`daemon_command_registry`].
151///
152/// Lazily initialised on first call and then shared for the process lifetime.
153///
154/// # Examples
155///
156/// ```rust
157/// use zeph_tui::command::command_registry;
158///
159/// let registry = command_registry();
160/// assert!(!registry.is_empty());
161/// assert!(registry.iter().any(|e| e.id == "app:quit"));
162/// ```
163#[must_use]
164pub fn command_registry() -> &'static [CommandEntry] {
165    static COMMANDS: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
166    COMMANDS.get_or_init(build_core_commands)
167}
168
169fn build_view_commands() -> Vec<CommandEntry> {
170    vec![
171        CommandEntry {
172            id: "skill:list",
173            label: "List loaded skills",
174            category: "skill",
175            shortcut: None,
176            command: TuiCommand::SkillList,
177        },
178        CommandEntry {
179            id: "mcp:list",
180            label: "List MCP servers and tools",
181            category: "mcp",
182            shortcut: None,
183            command: TuiCommand::McpList,
184        },
185        CommandEntry {
186            id: "memory:stats",
187            label: "Show memory statistics",
188            category: "memory",
189            shortcut: None,
190            command: TuiCommand::MemoryStats,
191        },
192        CommandEntry {
193            id: "view:cost",
194            label: "Show cost breakdown",
195            category: "view",
196            shortcut: None,
197            command: TuiCommand::ViewCost,
198        },
199        CommandEntry {
200            id: "view:tools",
201            label: "List available tools",
202            category: "view",
203            shortcut: None,
204            command: TuiCommand::ViewTools,
205        },
206        CommandEntry {
207            id: "view:config",
208            label: "Show active configuration",
209            category: "view",
210            shortcut: None,
211            command: TuiCommand::ViewConfig,
212        },
213        CommandEntry {
214            id: "view:autonomy",
215            label: "Show autonomy/trust level",
216            category: "view",
217            shortcut: None,
218            command: TuiCommand::ViewAutonomy,
219        },
220        CommandEntry {
221            id: "tasks",
222            label: "Toggle task registry panel",
223            category: "view",
224            shortcut: None,
225            command: TuiCommand::TaskPanel,
226        },
227    ]
228}
229
230fn build_session_commands() -> Vec<CommandEntry> {
231    vec![
232        CommandEntry {
233            id: "session:new",
234            label: "Start new conversation",
235            category: "session",
236            shortcut: None,
237            command: TuiCommand::NewSession,
238        },
239        CommandEntry {
240            id: "session:history",
241            label: "Browse session history",
242            category: "session",
243            shortcut: Some("H"),
244            command: TuiCommand::SessionBrowser,
245        },
246        CommandEntry {
247            id: "session:next",
248            label: "Switch to next session (/session next)",
249            category: "session",
250            shortcut: None,
251            command: TuiCommand::SessionSwitchNext,
252        },
253        CommandEntry {
254            id: "session:prev",
255            label: "Switch to previous session (/session prev)",
256            category: "session",
257            shortcut: None,
258            command: TuiCommand::SessionSwitchPrev,
259        },
260        CommandEntry {
261            id: "session:close",
262            label: "Close current session (/session close)",
263            category: "session",
264            shortcut: None,
265            command: TuiCommand::SessionClose,
266        },
267    ]
268}
269
270fn build_app_commands() -> Vec<CommandEntry> {
271    vec![
272        CommandEntry {
273            id: "app:quit",
274            label: "Quit application",
275            category: "app",
276            shortcut: Some("q"),
277            command: TuiCommand::Quit,
278        },
279        CommandEntry {
280            id: "app:help",
281            label: "Show keybindings help",
282            category: "app",
283            shortcut: Some("?"),
284            command: TuiCommand::Help,
285        },
286        CommandEntry {
287            id: "app:theme",
288            label: "Toggle theme (dark/light)",
289            category: "app",
290            shortcut: None,
291            command: TuiCommand::ToggleTheme,
292        },
293    ]
294}
295
296fn build_plugin_commands() -> Vec<CommandEntry> {
297    vec![
298        CommandEntry {
299            id: "plugin:list",
300            label: "List installed plugins (/plugins list)",
301            category: "plugin",
302            shortcut: None,
303            command: TuiCommand::PluginList,
304        },
305        CommandEntry {
306            id: "plugin:add",
307            label: "Install a plugin (/plugins add <source>)",
308            category: "plugin",
309            shortcut: None,
310            command: TuiCommand::PluginAdd,
311        },
312        CommandEntry {
313            id: "plugin:remove",
314            label: "Remove an installed plugin (/plugins remove <name>)",
315            category: "plugin",
316            shortcut: None,
317            command: TuiCommand::PluginRemove,
318        },
319        CommandEntry {
320            id: "plugin:overlay",
321            label: "Plugin overlay status — source and skipped plugins (/plugins overlay)",
322            category: "plugin",
323            shortcut: None,
324            command: TuiCommand::PluginListOverlay,
325        },
326    ]
327}
328
329fn build_core_commands() -> Vec<CommandEntry> {
330    let mut cmds = build_view_commands();
331    cmds.extend(build_session_commands());
332    cmds.extend(build_app_commands());
333    cmds.extend(build_plugin_commands());
334    cmds
335}
336
337/// Returns the static registry of daemon / remote-connection commands.
338///
339/// These commands manage connectivity to a background Zeph daemon process.
340///
341/// # Examples
342///
343/// ```rust
344/// use zeph_tui::command::daemon_command_registry;
345///
346/// let registry = daemon_command_registry();
347/// assert!(registry.iter().any(|e| e.id == "daemon:connect"));
348/// ```
349#[must_use]
350pub fn daemon_command_registry() -> &'static [CommandEntry] {
351    static DAEMON_COMMANDS: &[CommandEntry] = &[
352        CommandEntry {
353            id: "daemon:connect",
354            label: "Connect to remote daemon",
355            category: "daemon",
356            shortcut: None,
357            command: TuiCommand::DaemonConnect,
358        },
359        CommandEntry {
360            id: "daemon:disconnect",
361            label: "Disconnect from daemon",
362            category: "daemon",
363            shortcut: None,
364            command: TuiCommand::DaemonDisconnect,
365        },
366        CommandEntry {
367            id: "daemon:status",
368            label: "Show connection status",
369            category: "daemon",
370            shortcut: None,
371            command: TuiCommand::DaemonStatus,
372        },
373    ];
374    DAEMON_COMMANDS
375}
376
377/// Returns the extended command registry (infrastructure, agent, plan, graph, experiment).
378///
379/// Lazily initialised on first call and then shared for the process lifetime.
380/// Prefer [`filter_commands`] when you need a merged, fuzzy-filtered view.
381///
382/// # Examples
383///
384/// ```rust
385/// use zeph_tui::command::extra_command_registry;
386///
387/// let registry = extra_command_registry();
388/// assert!(registry.iter().any(|e| e.id == "graph:stats"));
389/// assert!(registry.iter().any(|e| e.id == "experiment:start"));
390/// ```
391#[must_use]
392pub fn extra_command_registry() -> &'static [CommandEntry] {
393    static EXTRA: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
394    EXTRA.get_or_init(build_extra_commands)
395}
396
397fn build_infra_commands() -> Vec<CommandEntry> {
398    vec![
399        CommandEntry {
400            id: "view:filters",
401            label: "Show output filter statistics",
402            category: "view",
403            shortcut: None,
404            command: TuiCommand::ViewFilters,
405        },
406        CommandEntry {
407            id: "ingest",
408            label: "Ingest document into memory (/ingest <path>)",
409            category: "memory",
410            shortcut: None,
411            command: TuiCommand::Ingest,
412        },
413        CommandEntry {
414            id: "gateway:status",
415            label: "Show gateway server status",
416            category: "gateway",
417            shortcut: None,
418            command: TuiCommand::GatewayStatus,
419        },
420        CommandEntry {
421            id: "scheduler:list",
422            label: "List scheduled tasks",
423            category: "scheduler",
424            shortcut: None,
425            command: TuiCommand::SchedulerList,
426        },
427        CommandEntry {
428            id: "router:stats",
429            label: "Show Thompson router alpha/beta per provider",
430            category: "router",
431            shortcut: None,
432            command: TuiCommand::RouterStats,
433        },
434        CommandEntry {
435            id: "security:events",
436            label: "Show security event history",
437            category: "security",
438            shortcut: None,
439            command: TuiCommand::SecurityEvents,
440        },
441        CommandEntry {
442            id: "sandbox:status",
443            label: "Show sandbox status: backend, denied_domains, fail_if_unavailable",
444            category: "security",
445            shortcut: None,
446            command: TuiCommand::SandboxStatus,
447        },
448        CommandEntry {
449            id: "log:status",
450            label: "Show log file path and recent entries (/log)",
451            category: "log",
452            shortcut: None,
453            command: TuiCommand::ViewLog,
454        },
455        CommandEntry {
456            id: "config:migrate",
457            label: "Show config migration diff (missing parameters)",
458            category: "config",
459            shortcut: None,
460            command: TuiCommand::MigrateConfig,
461        },
462        CommandEntry {
463            id: "compaction:status",
464            label: "Show server-side compaction status",
465            category: "context",
466            shortcut: None,
467            command: TuiCommand::ServerCompactionStatus,
468        },
469        CommandEntry {
470            id: "tafc:status",
471            label: "Show Think-Augmented Function Calling (TAFC) status (/tafc)",
472            category: "tools",
473            shortcut: None,
474            command: TuiCommand::TafcStatus,
475        },
476        CommandEntry {
477            id: "memory:forgetting-sweep",
478            label: "Run forgetting sweep once (/forgetting-sweep)",
479            category: "memory",
480            shortcut: None,
481            command: TuiCommand::ForgettingSweep,
482        },
483        CommandEntry {
484            id: "memory:trajectory",
485            label: "Show trajectory memory statistics (/memory trajectory)",
486            category: "memory",
487            shortcut: None,
488            command: TuiCommand::TrajectoryStats,
489        },
490        CommandEntry {
491            id: "memory:tree",
492            label: "Show memory tree statistics (/memory tree)",
493            category: "memory",
494            shortcut: None,
495            command: TuiCommand::MemoryTreeStats,
496        },
497    ]
498}
499
500fn build_agent_plan_commands() -> Vec<CommandEntry> {
501    vec![
502        CommandEntry {
503            id: "agent:list",
504            label: "List sub-agents (/agent list)",
505            category: "agent",
506            shortcut: None,
507            command: TuiCommand::AgentList,
508        },
509        CommandEntry {
510            id: "agent:status",
511            label: "Show sub-agent status (/agent status)",
512            category: "agent",
513            shortcut: None,
514            command: TuiCommand::AgentStatus,
515        },
516        CommandEntry {
517            id: "agent:cancel",
518            label: "Cancel a sub-agent (/agent cancel <id>)",
519            category: "agent",
520            shortcut: None,
521            command: TuiCommand::AgentCancelPrompt,
522        },
523        CommandEntry {
524            id: "agent:spawn",
525            label: "Spawn a sub-agent (/agent spawn <name>)",
526            category: "agent",
527            shortcut: None,
528            command: TuiCommand::AgentSpawnPrompt,
529        },
530        CommandEntry {
531            id: "agents:show",
532            label: "Show sub-agent definition details (/agents show <name>)",
533            category: "agents",
534            shortcut: None,
535            command: TuiCommand::AgentsShow,
536        },
537        CommandEntry {
538            id: "agents:create",
539            label: "Create a new sub-agent definition (/agents create <name>)",
540            category: "agents",
541            shortcut: None,
542            command: TuiCommand::AgentsCreate,
543        },
544        CommandEntry {
545            id: "agents:edit",
546            label: "Edit a sub-agent definition (/agents edit <name>)",
547            category: "agents",
548            shortcut: None,
549            command: TuiCommand::AgentsEdit,
550        },
551        CommandEntry {
552            id: "agents:delete",
553            label: "Delete a sub-agent definition (/agents delete <name>)",
554            category: "agents",
555            shortcut: None,
556            command: TuiCommand::AgentsDelete,
557        },
558        CommandEntry {
559            id: "plan:status",
560            label: "Show orchestration plan status (/plan status)",
561            category: "plan",
562            shortcut: None,
563            command: TuiCommand::PlanStatus,
564        },
565        CommandEntry {
566            id: "plan:confirm",
567            label: "Confirm and execute pending plan (/plan confirm)",
568            category: "plan",
569            shortcut: None,
570            command: TuiCommand::PlanConfirm,
571        },
572        CommandEntry {
573            id: "plan:cancel",
574            label: "Cancel current plan (/plan cancel)",
575            category: "plan",
576            shortcut: None,
577            command: TuiCommand::PlanCancel,
578        },
579        CommandEntry {
580            id: "plan:list",
581            label: "List recent plans (/plan list)",
582            category: "plan",
583            shortcut: None,
584            command: TuiCommand::PlanList,
585        },
586        CommandEntry {
587            id: "plan:toggle",
588            label: "Toggle plan view / subagents panel (p)",
589            category: "plan",
590            shortcut: Some("p"),
591            command: TuiCommand::PlanToggleView,
592        },
593    ]
594}
595
596fn build_graph_experiment_commands() -> Vec<CommandEntry> {
597    vec![
598        CommandEntry {
599            id: "graph:stats",
600            label: "Show graph memory statistics (/graph)",
601            category: "graph",
602            shortcut: None,
603            command: TuiCommand::GraphStats,
604        },
605        CommandEntry {
606            id: "graph:entities",
607            label: "List graph entities (/graph entities)",
608            category: "graph",
609            shortcut: None,
610            command: TuiCommand::GraphEntities,
611        },
612        CommandEntry {
613            id: "graph:facts",
614            label: "Show entity facts (/graph facts <name>)",
615            category: "graph",
616            shortcut: None,
617            command: TuiCommand::GraphFactsPrompt,
618        },
619        CommandEntry {
620            id: "graph:communities",
621            label: "List graph communities (/graph communities)",
622            category: "graph",
623            shortcut: None,
624            command: TuiCommand::GraphCommunities,
625        },
626        CommandEntry {
627            id: "graph:backfill",
628            label: "Backfill graph from existing messages (/graph backfill)",
629            category: "graph",
630            shortcut: None,
631            command: TuiCommand::GraphBackfillPrompt,
632        },
633        CommandEntry {
634            id: "experiment:start",
635            label: "Start experiment session (/experiment start [N])",
636            category: "experiment",
637            shortcut: None,
638            command: TuiCommand::ExperimentStart,
639        },
640        CommandEntry {
641            id: "experiment:stop",
642            label: "Stop running experiment (/experiment stop)",
643            category: "experiment",
644            shortcut: None,
645            command: TuiCommand::ExperimentStop,
646        },
647        CommandEntry {
648            id: "experiment:status",
649            label: "Show experiment status (/experiment status)",
650            category: "experiment",
651            shortcut: None,
652            command: TuiCommand::ExperimentStatus,
653        },
654        CommandEntry {
655            id: "experiment:report",
656            label: "Show experiment results (/experiment report)",
657            category: "experiment",
658            shortcut: None,
659            command: TuiCommand::ExperimentReport,
660        },
661        CommandEntry {
662            id: "experiment:best",
663            label: "Show best experiment result (/experiment best)",
664            category: "experiment",
665            shortcut: None,
666            command: TuiCommand::ExperimentBest,
667        },
668        CommandEntry {
669            id: "guidelines:view",
670            label: "Show compression guidelines (/guidelines)",
671            category: "memory",
672            shortcut: None,
673            command: TuiCommand::ViewGuidelines,
674        },
675    ]
676}
677
678fn build_extra_commands() -> Vec<CommandEntry> {
679    let mut cmds = build_infra_commands();
680    cmds.extend(build_agent_plan_commands());
681    cmds.extend(build_graph_experiment_commands());
682    cmds.push(CommandEntry {
683        id: "lsp:status",
684        label: "Show LSP context injection status (/lsp)",
685        category: "lsp",
686        shortcut: None,
687        command: TuiCommand::LspStatus,
688    });
689    cmds.push(CommandEntry {
690        id: "acp:dirs",
691        label: "ACP: list allowlisted directories (/acp dirs)",
692        category: "acp",
693        shortcut: None,
694        command: TuiCommand::AcpDirsList,
695    });
696    cmds.push(CommandEntry {
697        id: "acp:auth-methods",
698        label: "ACP: list advertised auth methods (/acp auth-methods)",
699        category: "acp",
700        shortcut: None,
701        command: TuiCommand::AcpAuthMethodsView,
702    });
703    cmds.push(CommandEntry {
704        id: "acp:status",
705        label: "ACP: show runtime status and feature flags (/acp status)",
706        category: "acp",
707        shortcut: None,
708        command: TuiCommand::AcpStatus,
709    });
710    cmds.push(CommandEntry {
711        id: "acp:subagent-spawn",
712        label: "ACP: spawn a sub-agent (/subagent spawn <cmd>)",
713        category: "acp",
714        shortcut: None,
715        command: TuiCommand::SubagentSpawn {
716            command: String::new(),
717        },
718    });
719    cmds
720}
721
722/// Compute a fuzzy match score between `query` and `target`.
723///
724/// Matches characters of `query` in order within `target`, penalising gaps
725/// between consecutive matches. Higher scores indicate better matches.
726///
727/// Returns `None` if `target` does not contain all characters of `query`.
728fn fuzzy_score(query: &str, target: &str) -> Option<isize> {
729    if query.is_empty() {
730        return Some(0);
731    }
732    let target_lower: Vec<char> = target.to_lowercase().chars().collect();
733    let query_chars: Vec<char> = query.to_lowercase().chars().collect();
734
735    let mut qi = 0usize;
736    let mut last_match = 0usize;
737    let mut gaps = 0isize;
738
739    for (ti, &tc) in target_lower.iter().enumerate() {
740        if qi < query_chars.len() && tc == query_chars[qi] {
741            if qi > 0 {
742                gaps += ti.cast_signed() - last_match.cast_signed() - 1;
743            }
744            last_match = ti;
745            qi += 1;
746        }
747    }
748
749    if qi == query_chars.len() {
750        // Higher is better: more matched chars, fewer gaps
751        Some(query_chars.len().cast_signed() * 10 - gaps)
752    } else {
753        None
754    }
755}
756
757/// Filter and rank all registered commands by fuzzy match against `query`.
758///
759/// Merges the core, daemon, and extra registries, scores each entry against
760/// both its `id` and `label`, and returns the results sorted by descending
761/// score. An empty query returns all commands in registration order.
762///
763/// # Examples
764///
765/// ```rust
766/// use zeph_tui::command::filter_commands;
767///
768/// // Exact prefix match
769/// let results = filter_commands("skill");
770/// assert!(!results.is_empty());
771/// assert_eq!(results[0].id, "skill:list");
772///
773/// // Empty query returns everything
774/// let all = filter_commands("");
775/// assert!(all.len() > 10);
776///
777/// // No match returns empty
778/// let none = filter_commands("xyzzy");
779/// assert!(none.is_empty());
780/// ```
781#[must_use]
782pub fn filter_commands(query: &str) -> Vec<&'static CommandEntry> {
783    let mut all: Vec<&'static CommandEntry> = command_registry().iter().collect();
784    all.extend(daemon_command_registry());
785    all.extend(extra_command_registry());
786
787    if query.is_empty() {
788        return all;
789    }
790
791    let mut scored: Vec<(&'static CommandEntry, isize)> = all
792        .into_iter()
793        .filter_map(|e| {
794            let id_score = fuzzy_score(query, e.id);
795            let label_score = fuzzy_score(query, e.label);
796            let best = match (id_score, label_score) {
797                (Some(a), Some(b)) => Some(a.max(b)),
798                (Some(a), None) => Some(a),
799                (None, Some(b)) => Some(b),
800                (None, None) => None,
801            };
802            best.map(|s| (e, s))
803        })
804        .collect();
805
806    scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
807    scored.into_iter().map(|(e, _)| e).collect()
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn registry_has_correct_count() {
816        assert_eq!(command_registry().len(), 20);
817    }
818
819    #[test]
820    fn extra_registry_has_correct_command_count() {
821        // 24 base (14 + 5 plan + 5 graph) + 5 experiment + 1 log:status + 1 config:migrate
822        // + 1 compaction:status + 1 guidelines:view + 1 tafc:status + 1 lsp:status
823        // + 1 forgetting-sweep + 3 acp + 1 sandbox:status (#3294) = 43
824        assert_eq!(extra_command_registry().len(), 43);
825    }
826
827    #[test]
828    fn filter_commands_includes_extra() {
829        let all = filter_commands("");
830        assert!(all.iter().any(|e| e.id == "view:filters"));
831        assert!(all.iter().any(|e| e.id == "ingest"));
832        assert!(all.iter().any(|e| e.id == "gateway:status"));
833        assert!(all.iter().any(|e| e.id == "scheduler:list"));
834        assert!(all.iter().any(|e| e.id == "security:events"));
835        assert!(all.iter().any(|e| e.id == "log:status"));
836    }
837
838    #[test]
839    fn filter_empty_query_returns_all() {
840        let results = filter_commands("");
841        assert_eq!(
842            results.len(),
843            command_registry().len()
844                + daemon_command_registry().len()
845                + extra_command_registry().len()
846        );
847    }
848
849    #[test]
850    fn filter_by_id_prefix() {
851        let results = filter_commands("skill");
852        assert!(!results.is_empty());
853        // skill:list must be the top-ranked result
854        assert_eq!(results[0].id, "skill:list");
855    }
856
857    #[test]
858    fn filter_by_label_substring() {
859        let results = filter_commands("memory");
860        assert!(!results.is_empty());
861        assert!(results.iter().any(|e| e.id == "memory:stats"));
862    }
863
864    #[test]
865    fn filter_case_insensitive() {
866        let results = filter_commands("view");
867        assert!(results.len() >= 4);
868    }
869
870    #[test]
871    fn filter_no_match_returns_empty() {
872        let results = filter_commands("xxxxxx");
873        assert!(results.is_empty());
874    }
875
876    #[test]
877    fn filter_partial_label_match() {
878        let results = filter_commands("cost");
879        assert!(!results.is_empty());
880        assert_eq!(results[0].id, "view:cost");
881    }
882
883    #[test]
884    fn filter_mcp_matches_id_and_label() {
885        let results = filter_commands("mcp");
886        assert!(results.iter().any(|e| e.id == "mcp:list"));
887    }
888
889    #[test]
890    fn fuzzy_ranks_skill_list_above_mcp_list_for_sl() {
891        let results = filter_commands("sl");
892        // skill:list should appear before mcp:list
893        let skill_pos = results.iter().position(|e| e.id == "skill:list");
894        let mcp_pos = results.iter().position(|e| e.id == "mcp:list");
895        assert!(skill_pos.is_some());
896        if let (Some(s), Some(m)) = (skill_pos, mcp_pos) {
897            assert!(
898                s <= m,
899                "skill:list should rank at least as high as mcp:list for 'sl'"
900            );
901        }
902    }
903
904    #[test]
905    fn new_commands_present() {
906        let all = filter_commands("");
907        assert!(all.iter().any(|e| e.id == "app:quit"));
908        assert!(all.iter().any(|e| e.id == "app:help"));
909        assert!(all.iter().any(|e| e.id == "session:new"));
910        assert!(all.iter().any(|e| e.id == "session:history"));
911        assert!(all.iter().any(|e| e.id == "session:next"));
912        assert!(all.iter().any(|e| e.id == "session:prev"));
913        assert!(all.iter().any(|e| e.id == "session:close"));
914    }
915
916    #[test]
917    fn shortcut_on_quit_and_help() {
918        let registry = command_registry();
919        let quit = registry.iter().find(|e| e.id == "app:quit").unwrap();
920        let help = registry.iter().find(|e| e.id == "app:help").unwrap();
921        assert_eq!(quit.shortcut, Some("q"));
922        assert_eq!(help.shortcut, Some("?"));
923    }
924
925    #[test]
926    fn filter_security_returns_security_events_entry() {
927        let results = filter_commands("security");
928        assert!(
929            results.iter().any(|e| e.id == "security:events"),
930            "security:events must appear when searching 'security'"
931        );
932    }
933
934    #[test]
935    fn filter_graph_returns_graph_entries() {
936        let results = filter_commands("graph");
937        assert!(results.iter().any(|e| e.id == "graph:stats"));
938        assert!(results.iter().any(|e| e.id == "graph:entities"));
939        assert!(results.iter().any(|e| e.id == "graph:facts"));
940        assert!(results.iter().any(|e| e.id == "graph:communities"));
941        assert!(results.iter().any(|e| e.id == "graph:backfill"));
942    }
943
944    #[test]
945    fn filter_experiment_returns_experiment_entries() {
946        let results = filter_commands("experiment");
947        assert!(results.iter().any(|e| e.id == "experiment:start"));
948        assert!(results.iter().any(|e| e.id == "experiment:stop"));
949        assert!(results.iter().any(|e| e.id == "experiment:status"));
950        assert!(results.iter().any(|e| e.id == "experiment:report"));
951        assert!(results.iter().any(|e| e.id == "experiment:best"));
952    }
953}