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