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    ViewLatency,
30    // New action commands
31    Quit,
32    Help,
33    NewSession,
34    ToggleTheme,
35    // Session history browser (H keybind)
36    SessionBrowser,
37    // Daemon / remote connection commands
38    DaemonConnect,
39    DaemonDisconnect,
40    DaemonStatus,
41    // Filter inspection
42    ViewFilters,
43    // Document ingestion
44    Ingest,
45    // Gateway
46    GatewayStatus,
47    // Scheduler
48    SchedulerList,
49    // Sub-agents (runtime)
50    AgentList,
51    AgentStatus,
52    AgentCancelPrompt,
53    AgentSpawnPrompt,
54    // Router
55    RouterStats,
56    // Sub-agent definitions (CRUD)
57    AgentsShow,
58    AgentsCreate,
59    AgentsEdit,
60    AgentsDelete,
61    // Security
62    SecurityEvents,
63    // Plan / orchestration
64    PlanStatus,
65    PlanConfirm,
66    PlanCancel,
67    PlanList,
68    PlanToggleView,
69    // Graph memory
70    GraphStats,
71    GraphEntities,
72    GraphFactsPrompt,
73    GraphCommunities,
74    GraphBackfillPrompt,
75    // Experiments
76    ExperimentStart,
77    ExperimentStop,
78    ExperimentStatus,
79    ExperimentReport,
80    ExperimentBest,
81    // LSP context injection
82    LspStatus,
83    // Log file
84    ViewLog,
85    // Config migration
86    MigrateConfig,
87    // Server-side compaction
88    ServerCompactionStatus,
89    // Compression guidelines
90    ViewGuidelines,
91    // Think-Augmented Function Calling
92    TafcStatus,
93    // SleepGate forgetting sweep
94    ForgettingSweep,
95    // Trajectory-informed memory (#2498)
96    TrajectoryStats,
97    // TiMem memory tree (#2262)
98    MemoryTreeStats,
99    // Task registry panel (#2962)
100    TaskPanel,
101    // Plugin management (#2806)
102    PluginList,
103    PluginAdd,
104    PluginRemove,
105    // Multi-session management (#3130, phase-1)
106    SessionSwitchNext,
107    SessionSwitchPrev,
108    SessionClose,
109    // Plugin overlay status (#3147)
110    PluginListOverlay,
111    // ACP read-only inspection (#3270)
112    AcpDirsList,
113    AcpAuthMethodsView,
114    AcpStatus,
115    // ACP sub-agent delegation (#3272)
116    SubagentSpawn {
117        command: String,
118    },
119    // Sandbox egress status (#3294)
120    SandboxStatus,
121    // Cocoon sidecar inspection (#3673)
122    CocoonStatus,
123    CocoonModels,
124    // Clipboard (#3685)
125    CopyLastAssistant,
126    /// Copy the Nth visible code block from the last assistant message (1-indexed).
127    /// When `n` is 0, copies the last (most recent) block.
128    CopyLastCodeBlock(usize),
129    // Fleet session overview (#3884)
130    FleetPanel,
131    // Durable execution journal (spec-064, #4949)
132    DurablePanel,
133    // Read-only settings view: LLM providers, MCP servers, agent definitions (#6024)
134    Settings,
135    // Ctrl+F in-transcript search overlay (#6023)
136    TranscriptSearch,
137    // Vault-anchor / hash-chain integrity status (issue #6449)
138    IntegrityStatusInfo,
139    // Worktree subsystem (#4679)
140    WorktreeList,
141    WorktreeClean,
142    // Undo/redo checkpoint commands (#4990)
143    Undo,
144    Redo,
145    // Knowledge ingest management (#5019, #5020)
146    KnowledgeStatus,
147    KnowledgeRollbackPrompt,
148    KnowledgeIngestPrompt,
149    // Theme runtime switching (#5090)
150    /// List all available theme presets.
151    ListThemes,
152    /// Switch to the named theme preset or user file.
153    SetTheme(String),
154    // Motion control (#5096)
155    /// Set the TUI animation budget at runtime (`full`, `minimal`, or `off`).
156    SetMotion(zeph_config::Motion),
157    // Mouse mode (#5103)
158    /// Enable or disable opt-in mouse capture (`/mouse on|off`).
159    SetMouse(bool),
160    /// Toggle the current mouse capture state.
161    ToggleMouse,
162    /// Toggle the compact equalizer widget in the busy separator row.
163    ToggleEqualizer,
164    // Side-panel sizing (#6675)
165    /// Set the side-panel sizing strategy at runtime (`/panel_sizing auto|even`).
166    SetPanelSizing(zeph_config::PanelSizingMode),
167    /// Toggle between `auto` and `even` side-panel sizing.
168    TogglePanelSizing,
169    // SubAgent sidebar navigation (used by decode_normal_key → Action::Dispatch)
170    /// Move the subagent list selection down by one.
171    SubagentSidebarDown,
172    /// Move the subagent list selection up by one.
173    SubagentSidebarUp,
174    /// Send `/clear-queue` to the agent input channel (Ctrl+K in Insert mode).
175    SendClearQueue,
176    /// Send an arbitrary slash command's bare text verbatim to the agent input channel.
177    ///
178    /// Used for [`zeph_commands::COMMANDS`] entries that have no dedicated hand-authored
179    /// `TuiCommand` variant (#5875): every such command gets autocomplete for free instead
180    /// of requiring a matching enum variant and reducer arm to be added by hand. Only used
181    /// for commands whose bare (no-argument) form is a valid, useful default — see
182    /// [`crate::command::zeph_commands_entries`].
183    SendVerbatim(String),
184    /// Fill the input box with an arbitrary slash command's text without submitting it.
185    ///
186    /// Counterpart to [`TuiCommand::SendVerbatim`] for [`zeph_commands::COMMANDS`] entries
187    /// whose argument is required (e.g. `/image <path>`) — submitting the bare command would
188    /// just produce a usage error, so this instead prefills the input for the user to
189    /// complete, mirroring the existing `*Prompt` variants' `prefill_input` behavior (#5875
190    /// F1).
191    PrefillVerbatim(String),
192}
193
194/// Metadata for a single entry in the command palette.
195///
196/// Used for both display (label, category, shortcut hint) and fuzzy-matching
197/// (id + label are scored by [`filter_commands`]).
198///
199/// # Examples
200///
201/// ```rust
202/// use zeph_tui::command::{command_registry, CommandEntry};
203///
204/// let registry = command_registry();
205/// let quit = registry.iter().find(|e| e.id == "app:quit").unwrap();
206/// assert_eq!(quit.shortcut, Some("q"));
207/// ```
208pub struct CommandEntry {
209    /// Stable identifier used in fuzzy search and slash-command routing (e.g. `"skill:list"`).
210    pub id: &'static str,
211    /// Human-readable label shown in the command palette list.
212    pub label: &'static str,
213    /// Logical group for categorised display (e.g. `"memory"`, `"agent"`).
214    pub category: &'static str,
215    /// Optional keyboard shortcut hint (e.g. `"q"`, `"?"`).
216    pub shortcut: Option<&'static str>,
217    /// The [`TuiCommand`] dispatched when this entry is selected.
218    pub command: TuiCommand,
219}
220
221/// Returns the static registry of core TUI commands.
222///
223/// This includes navigation, session management, view toggles, and app-level
224/// actions. Extended commands (agent, plan, graph, experiment, infra) are in
225/// [`extra_command_registry`] and daemon commands in [`daemon_command_registry`].
226///
227/// Lazily initialised on first call and then shared for the process lifetime.
228///
229/// # Examples
230///
231/// ```rust
232/// use zeph_tui::command::command_registry;
233///
234/// let registry = command_registry();
235/// assert!(!registry.is_empty());
236/// assert!(registry.iter().any(|e| e.id == "app:quit"));
237/// ```
238#[must_use]
239pub fn command_registry() -> &'static [CommandEntry] {
240    static COMMANDS: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
241    COMMANDS.get_or_init(build_core_commands)
242}
243
244fn build_view_commands() -> Vec<CommandEntry> {
245    vec![
246        CommandEntry {
247            id: "skill:list",
248            label: "List loaded skills",
249            category: "skill",
250            shortcut: None,
251            command: TuiCommand::SkillList,
252        },
253        CommandEntry {
254            id: "mcp:list",
255            label: "List MCP servers and tools",
256            category: "mcp",
257            shortcut: None,
258            command: TuiCommand::McpList,
259        },
260        CommandEntry {
261            id: "memory:stats",
262            label: "Show memory statistics",
263            category: "memory",
264            shortcut: None,
265            command: TuiCommand::MemoryStats,
266        },
267        CommandEntry {
268            id: "view:cost",
269            label: "Show cost breakdown",
270            category: "view",
271            shortcut: None,
272            command: TuiCommand::ViewCost,
273        },
274        CommandEntry {
275            id: "view:tools",
276            label: "List available tools",
277            category: "view",
278            shortcut: None,
279            command: TuiCommand::ViewTools,
280        },
281        CommandEntry {
282            id: "view:config",
283            label: "Show active configuration",
284            category: "view",
285            shortcut: None,
286            command: TuiCommand::ViewConfig,
287        },
288        CommandEntry {
289            id: "view:autonomy",
290            label: "Show autonomy/trust level",
291            category: "view",
292            shortcut: None,
293            command: TuiCommand::ViewAutonomy,
294        },
295        CommandEntry {
296            id: "view:latency",
297            label: "Show classifier and turn-latency breakdown",
298            category: "view",
299            shortcut: None,
300            command: TuiCommand::ViewLatency,
301        },
302        CommandEntry {
303            id: "tasks",
304            label: "Toggle task registry panel",
305            category: "view",
306            shortcut: None,
307            command: TuiCommand::TaskPanel,
308        },
309        CommandEntry {
310            id: "fleet",
311            label: "Fleet: show agent sessions",
312            category: "view",
313            shortcut: Some("f"),
314            command: TuiCommand::FleetPanel,
315        },
316        CommandEntry {
317            id: "durable",
318            label: "Durable: show durable executions",
319            category: "view",
320            shortcut: Some("D"),
321            command: TuiCommand::DurablePanel,
322        },
323        CommandEntry {
324            id: "settings",
325            label: "Settings: browse providers, MCP servers, and agents",
326            category: "view",
327            shortcut: Some("S"),
328            command: TuiCommand::Settings,
329        },
330        CommandEntry {
331            id: "search:transcript",
332            label: "Find in conversation (Ctrl+F)",
333            category: "view",
334            shortcut: Some("Ctrl+F"),
335            command: TuiCommand::TranscriptSearch,
336        },
337        CommandEntry {
338            id: "integrity:status",
339            label: "Integrity: transcript/session tamper-evidence status",
340            category: "view",
341            shortcut: None,
342            command: TuiCommand::IntegrityStatusInfo,
343        },
344    ]
345}
346
347fn build_session_commands() -> Vec<CommandEntry> {
348    vec![
349        CommandEntry {
350            id: "session:new",
351            label: "Start new conversation",
352            category: "session",
353            shortcut: None,
354            command: TuiCommand::NewSession,
355        },
356        CommandEntry {
357            id: "session:history",
358            label: "Browse session history",
359            category: "session",
360            shortcut: Some("H"),
361            command: TuiCommand::SessionBrowser,
362        },
363        CommandEntry {
364            id: "session:next",
365            label: "Switch to next session (/session next)",
366            category: "session",
367            shortcut: None,
368            command: TuiCommand::SessionSwitchNext,
369        },
370        CommandEntry {
371            id: "session:prev",
372            label: "Switch to previous session (/session prev)",
373            category: "session",
374            shortcut: None,
375            command: TuiCommand::SessionSwitchPrev,
376        },
377        CommandEntry {
378            id: "session:close",
379            label: "Close current session (/session close)",
380            category: "session",
381            shortcut: None,
382            command: TuiCommand::SessionClose,
383        },
384        CommandEntry {
385            id: "session:undo",
386            label: "Undo last shell checkpoint (/undo)",
387            category: "session",
388            shortcut: None,
389            command: TuiCommand::Undo,
390        },
391        CommandEntry {
392            id: "session:redo",
393            label: "Re-apply last undone checkpoint (/redo)",
394            category: "session",
395            shortcut: None,
396            command: TuiCommand::Redo,
397        },
398    ]
399}
400
401fn build_app_commands() -> Vec<CommandEntry> {
402    vec![
403        CommandEntry {
404            id: "app:quit",
405            label: "Quit application",
406            category: "app",
407            shortcut: Some("q"),
408            command: TuiCommand::Quit,
409        },
410        CommandEntry {
411            id: "app:help",
412            label: "Show keybindings help",
413            category: "app",
414            shortcut: Some("?"),
415            command: TuiCommand::Help,
416        },
417        CommandEntry {
418            id: "app:theme",
419            label: "Cycle theme (zephyr → zephyr-light → high-contrast)",
420            category: "app",
421            shortcut: None,
422            command: TuiCommand::ToggleTheme,
423        },
424        CommandEntry {
425            id: "app:theme-list",
426            label: "List available themes (/theme)",
427            category: "app",
428            shortcut: None,
429            command: TuiCommand::ListThemes,
430        },
431        CommandEntry {
432            id: "app:mouse",
433            label: "Toggle mouse mode (wheel scroll, click focus)",
434            category: "app",
435            shortcut: None,
436            command: TuiCommand::ToggleMouse,
437        },
438        CommandEntry {
439            id: "app:equalizer",
440            label: "Toggle equalizer (compact VU-meter in busy separator)",
441            category: "app",
442            shortcut: None,
443            command: TuiCommand::ToggleEqualizer,
444        },
445        CommandEntry {
446            id: "app:panel-sizing",
447            label: "Toggle side-panel sizing (auto: content-sized / even: equal share)",
448            category: "app",
449            shortcut: None,
450            command: TuiCommand::TogglePanelSizing,
451        },
452    ]
453}
454
455fn build_plugin_commands() -> Vec<CommandEntry> {
456    vec![
457        CommandEntry {
458            id: "plugin:list",
459            label: "List installed plugins (/plugins list)",
460            category: "plugin",
461            shortcut: None,
462            command: TuiCommand::PluginList,
463        },
464        CommandEntry {
465            id: "plugin:add",
466            label: "Install a plugin (/plugins add <source>)",
467            category: "plugin",
468            shortcut: None,
469            command: TuiCommand::PluginAdd,
470        },
471        CommandEntry {
472            id: "plugin:remove",
473            label: "Remove an installed plugin (/plugins remove <name>)",
474            category: "plugin",
475            shortcut: None,
476            command: TuiCommand::PluginRemove,
477        },
478        CommandEntry {
479            id: "plugin:overlay",
480            label: "Plugin overlay status — source and skipped plugins (/plugins overlay)",
481            category: "plugin",
482            shortcut: None,
483            command: TuiCommand::PluginListOverlay,
484        },
485    ]
486}
487
488fn build_core_commands() -> Vec<CommandEntry> {
489    let mut cmds = build_view_commands();
490    cmds.extend(build_session_commands());
491    cmds.extend(build_app_commands());
492    cmds.extend(build_plugin_commands());
493    cmds
494}
495
496/// Returns the static registry of daemon / remote-connection commands.
497///
498/// These commands manage connectivity to a background Zeph daemon process.
499///
500/// # Examples
501///
502/// ```rust
503/// use zeph_tui::command::daemon_command_registry;
504///
505/// let registry = daemon_command_registry();
506/// assert!(registry.iter().any(|e| e.id == "daemon:connect"));
507/// ```
508#[must_use]
509pub fn daemon_command_registry() -> &'static [CommandEntry] {
510    static DAEMON_COMMANDS: &[CommandEntry] = &[
511        CommandEntry {
512            id: "daemon:connect",
513            label: "Connect to remote daemon",
514            category: "daemon",
515            shortcut: None,
516            command: TuiCommand::DaemonConnect,
517        },
518        CommandEntry {
519            id: "daemon:disconnect",
520            label: "Disconnect from daemon",
521            category: "daemon",
522            shortcut: None,
523            command: TuiCommand::DaemonDisconnect,
524        },
525        CommandEntry {
526            id: "daemon:status",
527            label: "Show connection status",
528            category: "daemon",
529            shortcut: None,
530            command: TuiCommand::DaemonStatus,
531        },
532    ];
533    DAEMON_COMMANDS
534}
535
536/// Returns the extended command registry (infrastructure, agent, plan, graph, experiment).
537///
538/// Lazily initialised on first call and then shared for the process lifetime.
539/// Prefer [`filter_commands`] when you need a merged, fuzzy-filtered view.
540///
541/// # Examples
542///
543/// ```rust
544/// use zeph_tui::command::extra_command_registry;
545///
546/// let registry = extra_command_registry();
547/// assert!(registry.iter().any(|e| e.id == "graph:stats"));
548/// assert!(registry.iter().any(|e| e.id == "experiment:start"));
549/// ```
550#[must_use]
551pub fn extra_command_registry() -> &'static [CommandEntry] {
552    static EXTRA: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
553    EXTRA.get_or_init(build_extra_commands)
554}
555
556#[allow(clippy::too_many_lines)]
557fn build_infra_commands() -> Vec<CommandEntry> {
558    vec![
559        CommandEntry {
560            id: "view:filters",
561            label: "Show output filter statistics",
562            category: "view",
563            shortcut: None,
564            command: TuiCommand::ViewFilters,
565        },
566        CommandEntry {
567            id: "ingest",
568            label: "Ingest document into memory (/ingest <path>)",
569            category: "memory",
570            shortcut: None,
571            command: TuiCommand::Ingest,
572        },
573        CommandEntry {
574            id: "gateway:status",
575            label: "Show gateway server status",
576            category: "gateway",
577            shortcut: None,
578            command: TuiCommand::GatewayStatus,
579        },
580        CommandEntry {
581            id: "scheduler:list",
582            label: "List scheduled tasks",
583            category: "scheduler",
584            shortcut: None,
585            command: TuiCommand::SchedulerList,
586        },
587        CommandEntry {
588            id: "router:stats",
589            label: "Show Thompson router alpha/beta per provider",
590            category: "router",
591            shortcut: None,
592            command: TuiCommand::RouterStats,
593        },
594        CommandEntry {
595            id: "security:events",
596            label: "Show security event history",
597            category: "security",
598            shortcut: None,
599            command: TuiCommand::SecurityEvents,
600        },
601        CommandEntry {
602            id: "sandbox:status",
603            label: "Show sandbox status: backend, denied_domains, fail_if_unavailable",
604            category: "security",
605            shortcut: None,
606            command: TuiCommand::SandboxStatus,
607        },
608        CommandEntry {
609            id: "log:status",
610            label: "Show log file path and recent entries (/log)",
611            category: "log",
612            shortcut: None,
613            command: TuiCommand::ViewLog,
614        },
615        CommandEntry {
616            id: "config:migrate",
617            label: "Show config migration diff (missing parameters)",
618            category: "config",
619            shortcut: None,
620            command: TuiCommand::MigrateConfig,
621        },
622        CommandEntry {
623            id: "compaction:status",
624            label: "Show server-side compaction status",
625            category: "context",
626            shortcut: None,
627            command: TuiCommand::ServerCompactionStatus,
628        },
629        CommandEntry {
630            id: "tafc:status",
631            label: "Show Think-Augmented Function Calling (TAFC) status (/tafc)",
632            category: "tools",
633            shortcut: None,
634            command: TuiCommand::TafcStatus,
635        },
636        CommandEntry {
637            id: "memory:forgetting-sweep",
638            label: "Run forgetting sweep once (/forgetting-sweep)",
639            category: "memory",
640            shortcut: None,
641            command: TuiCommand::ForgettingSweep,
642        },
643        CommandEntry {
644            id: "memory:trajectory",
645            label: "Show trajectory memory statistics (/memory trajectory)",
646            category: "memory",
647            shortcut: None,
648            command: TuiCommand::TrajectoryStats,
649        },
650        CommandEntry {
651            id: "memory:tree",
652            label: "Show memory tree statistics (/memory tree)",
653            category: "memory",
654            shortcut: None,
655            command: TuiCommand::MemoryTreeStats,
656        },
657        CommandEntry {
658            id: "worktree:list",
659            label: "List active and stale git worktrees (/worktree list)",
660            category: "worktree",
661            shortcut: None,
662            command: TuiCommand::WorktreeList,
663        },
664        CommandEntry {
665            id: "worktree:clean",
666            label: "Remove all stale git worktrees (/worktree clean)",
667            category: "worktree",
668            shortcut: None,
669            command: TuiCommand::WorktreeClean,
670        },
671    ]
672}
673
674fn build_agent_plan_commands() -> Vec<CommandEntry> {
675    vec![
676        CommandEntry {
677            id: "agent:list",
678            label: "List sub-agents (/agent list)",
679            category: "agent",
680            shortcut: None,
681            command: TuiCommand::AgentList,
682        },
683        CommandEntry {
684            id: "agent:status",
685            label: "Show sub-agent status (/agent status)",
686            category: "agent",
687            shortcut: None,
688            command: TuiCommand::AgentStatus,
689        },
690        CommandEntry {
691            id: "agent:cancel",
692            label: "Cancel a sub-agent (/agent cancel <id>)",
693            category: "agent",
694            shortcut: None,
695            command: TuiCommand::AgentCancelPrompt,
696        },
697        CommandEntry {
698            id: "agent:spawn",
699            label: "Spawn a sub-agent (/agent spawn <name>)",
700            category: "agent",
701            shortcut: None,
702            command: TuiCommand::AgentSpawnPrompt,
703        },
704        CommandEntry {
705            id: "agents:show",
706            label: "Show sub-agent definition details (/agents show <name>)",
707            category: "agents",
708            shortcut: None,
709            command: TuiCommand::AgentsShow,
710        },
711        CommandEntry {
712            id: "agents:create",
713            label: "Create a new sub-agent definition (/agents create <name>)",
714            category: "agents",
715            shortcut: None,
716            command: TuiCommand::AgentsCreate,
717        },
718        CommandEntry {
719            id: "agents:edit",
720            label: "Edit a sub-agent definition (/agents edit <name>)",
721            category: "agents",
722            shortcut: None,
723            command: TuiCommand::AgentsEdit,
724        },
725        CommandEntry {
726            id: "agents:delete",
727            label: "Delete a sub-agent definition (/agents delete <name>)",
728            category: "agents",
729            shortcut: None,
730            command: TuiCommand::AgentsDelete,
731        },
732        CommandEntry {
733            id: "plan:status",
734            label: "Show orchestration plan status (/plan status)",
735            category: "plan",
736            shortcut: None,
737            command: TuiCommand::PlanStatus,
738        },
739        CommandEntry {
740            id: "plan:confirm",
741            label: "Confirm and execute pending plan (/plan confirm)",
742            category: "plan",
743            shortcut: None,
744            command: TuiCommand::PlanConfirm,
745        },
746        CommandEntry {
747            id: "plan:cancel",
748            label: "Cancel current plan (/plan cancel)",
749            category: "plan",
750            shortcut: None,
751            command: TuiCommand::PlanCancel,
752        },
753        CommandEntry {
754            id: "plan:list",
755            label: "List recent plans (/plan list)",
756            category: "plan",
757            shortcut: None,
758            command: TuiCommand::PlanList,
759        },
760        CommandEntry {
761            id: "plan:toggle",
762            label: "Toggle plan view / subagents panel (p)",
763            category: "plan",
764            shortcut: Some("p"),
765            command: TuiCommand::PlanToggleView,
766        },
767    ]
768}
769
770fn build_graph_experiment_commands() -> Vec<CommandEntry> {
771    vec![
772        CommandEntry {
773            id: "graph:stats",
774            label: "Show graph memory statistics (/graph)",
775            category: "graph",
776            shortcut: None,
777            command: TuiCommand::GraphStats,
778        },
779        CommandEntry {
780            id: "graph:entities",
781            label: "List graph entities (/graph entities)",
782            category: "graph",
783            shortcut: None,
784            command: TuiCommand::GraphEntities,
785        },
786        CommandEntry {
787            id: "graph:facts",
788            label: "Show entity facts (/graph facts <name>)",
789            category: "graph",
790            shortcut: None,
791            command: TuiCommand::GraphFactsPrompt,
792        },
793        CommandEntry {
794            id: "graph:communities",
795            label: "List graph communities (/graph communities)",
796            category: "graph",
797            shortcut: None,
798            command: TuiCommand::GraphCommunities,
799        },
800        CommandEntry {
801            id: "graph:backfill",
802            label: "Backfill graph from existing messages (/graph backfill)",
803            category: "graph",
804            shortcut: None,
805            command: TuiCommand::GraphBackfillPrompt,
806        },
807        CommandEntry {
808            id: "experiment:start",
809            label: "Start experiment session (/experiment start [N])",
810            category: "experiment",
811            shortcut: None,
812            command: TuiCommand::ExperimentStart,
813        },
814        CommandEntry {
815            id: "experiment:stop",
816            label: "Stop running experiment (/experiment stop)",
817            category: "experiment",
818            shortcut: None,
819            command: TuiCommand::ExperimentStop,
820        },
821        CommandEntry {
822            id: "experiment:status",
823            label: "Show experiment status (/experiment status)",
824            category: "experiment",
825            shortcut: None,
826            command: TuiCommand::ExperimentStatus,
827        },
828        CommandEntry {
829            id: "experiment:report",
830            label: "Show experiment results (/experiment report)",
831            category: "experiment",
832            shortcut: None,
833            command: TuiCommand::ExperimentReport,
834        },
835        CommandEntry {
836            id: "experiment:best",
837            label: "Show best experiment result (/experiment best)",
838            category: "experiment",
839            shortcut: None,
840            command: TuiCommand::ExperimentBest,
841        },
842        CommandEntry {
843            id: "guidelines:view",
844            label: "Show compression guidelines (/guidelines)",
845            category: "memory",
846            shortcut: None,
847            command: TuiCommand::ViewGuidelines,
848        },
849    ]
850}
851
852#[cfg(feature = "cocoon")]
853fn build_cocoon_commands() -> Vec<CommandEntry> {
854    vec![
855        CommandEntry {
856            id: "cocoon:status",
857            label: "Show Cocoon sidecar status (/cocoon status)",
858            category: "cocoon",
859            shortcut: None,
860            command: TuiCommand::CocoonStatus,
861        },
862        CommandEntry {
863            id: "cocoon:models",
864            label: "List Cocoon models (/cocoon models)",
865            category: "cocoon",
866            shortcut: None,
867            command: TuiCommand::CocoonModels,
868        },
869    ]
870}
871
872fn build_clipboard_commands() -> Vec<CommandEntry> {
873    vec![
874        CommandEntry {
875            id: "clipboard:copy",
876            label: "Copy last assistant reply to clipboard (/copy)",
877            category: "clipboard",
878            shortcut: Some("Ctrl+O"),
879            command: TuiCommand::CopyLastAssistant,
880        },
881        CommandEntry {
882            id: "clipboard:copyblock",
883            label: "Copy last code block from assistant reply to clipboard (/copyblock)",
884            category: "clipboard",
885            shortcut: Some("Ctrl+Y"),
886            command: TuiCommand::CopyLastCodeBlock(0),
887        },
888    ]
889}
890
891fn build_knowledge_commands() -> Vec<CommandEntry> {
892    vec![
893        CommandEntry {
894            id: "knowledge:status",
895            label: "Knowledge: show ingest ledger status (/knowledge status)",
896            category: "knowledge",
897            shortcut: None,
898            command: TuiCommand::KnowledgeStatus,
899        },
900        CommandEntry {
901            id: "knowledge:rollback",
902            label: "Knowledge: roll back an import batch (/knowledge rollback <batch>)",
903            category: "knowledge",
904            shortcut: None,
905            command: TuiCommand::KnowledgeRollbackPrompt,
906        },
907        CommandEntry {
908            id: "knowledge:ingest",
909            label: "Knowledge: ingest project artifacts (CLI command)",
910            category: "knowledge",
911            shortcut: None,
912            command: TuiCommand::KnowledgeIngestPrompt,
913        },
914    ]
915}
916
917/// Top-level command names already covered — exactly, with no arguments — by an existing
918/// hand-authored [`TuiCommand`] entry in [`command_registry`], [`daemon_command_registry`],
919/// or [`extra_command_registry`].
920///
921/// Excluded from [`zeph_commands_entries`] so the merged autocomplete list never shows the
922/// same bare invocation twice (#5875). Each comment names the existing entry that already
923/// performs the identical bare command (either by sending the same text, or — for the
924/// locally-rendered view commands — by displaying the same information without a round
925/// trip through the agent).
926///
927/// Every name here must have a real matching `id` in [`command_registry`],
928/// [`daemon_command_registry`], or [`extra_command_registry`] — see the
929/// `zeph_commands_dedup_entries_have_a_real_hand_authored_replacement` test, which fails
930/// loudly if a covering entry is ever renamed or removed without updating this list (#5875
931/// F3). `/clear-queue` was deliberately **not** added here even though a `SendClearQueue`
932/// `TuiCommand` variant exists — that variant is reachable only via the Ctrl+K keybinding
933/// (`crates/zeph-tui/src/app/keys.rs`), not through any `CommandEntry` in the three
934/// registries above, so there is nothing to actually deduplicate against.
935const ZEPH_COMMANDS_DEDUP: &[&str] = &[
936    "/skills",     // skill:list
937    "/mcp",        // mcp:list
938    "/memory",     // memory:stats
939    "/guidelines", // guidelines:view
940    "/log",        // log:status
941    "/undo",       // session:undo
942    "/redo",       // session:redo
943    "/graph",      // graph:stats
944    "/lsp",        // lsp:status
945    "/scheduler",  // scheduler:list
946    "/subagent",   // acp:subagent-spawn (already prefills "/subagent spawn " when empty)
947];
948
949/// Returns `false` only for entries whose `feature_gate` names a Cargo feature that is
950/// unified, via the root binary crate, with this crate's own feature of the same name — and
951/// that feature is disabled in this build.
952///
953/// Every `feature_gate` value in [`zeph_commands::COMMANDS`] is otherwise purely descriptive
954/// (rendered as `[requires: X]` in `/help` text): the underlying `CommandHandler` is
955/// unconditionally registered in `Agent::run` regardless of any Cargo feature with a
956/// matching name (most such names — `"acp"`, `"guardrail"`, `"scheduler"`, `"session"`,
957/// etc. — do not even exist as Cargo features on the relevant crates). `"cocoon"` is the one
958/// exception: `CocoonCommand`'s registration in `crates/zeph-core/src/agent/slash_commands.rs`
959/// really is `#[cfg(feature = "cocoon")]`-gated, and the root `Cargo.toml`'s `cocoon` feature
960/// unifies `zeph-core/cocoon` with this crate's own `cocoon` feature (which already gates
961/// `build_cocoon_commands`), so checking it here faithfully predicts whether `CocoonCommand`
962/// exists in this exact build (#5875 F2) — without this check, a `cocoon`-feature-off build
963/// would still show `/cocoon` in autocomplete and fail when submitted.
964fn command_is_compiled_in_this_build(entry: &zeph_commands::CommandInfo) -> bool {
965    entry.feature_gate != Some("cocoon") || cfg!(feature = "cocoon")
966}
967
968/// Returns the [`CommandEntry`] projection of every [`zeph_commands::COMMANDS`] entry that
969/// has no dedicated hand-authored `TuiCommand` (see `ZEPH_COMMANDS_DEDUP`).
970///
971/// `zeph_commands::COMMANDS` is the canonical, always-up-to-date list of channel-agnostic
972/// `AgentAccess` slash commands (`/model`, `/provider`, `/skill`, `/policy`, etc.) — the
973/// same list `/help` renders from. Projecting it here means a new command registered there
974/// automatically gets TUI autocomplete, instead of requiring a second, hand-authored
975/// `TuiCommand` variant and registry entry that can silently drift out of sync (#5875).
976///
977/// Commands whose `args` hint signals a *required* argument (e.g. `/image <path>`,
978/// `/feedback <skill> <message>`) dispatch [`TuiCommand::PrefillVerbatim`] instead — this
979/// fills the input box with the bare command plus a trailing space for the user to complete,
980/// the same behavior every existing hand-authored `*Prompt` `TuiCommand` variant uses (see
981/// `execute_command` in `app/keys.rs`), rather than submitting an incomplete command that the
982/// handler would just reject. Every other entry dispatches [`TuiCommand::SendVerbatim`] with
983/// the command's bare name (no arguments) — verified against each handler's actual empty-args
984/// behavior (not just the `args` hint text, which is documentation-only and not always
985/// bracket-consistent — e.g. `/goal` and `/worktree` both default sensibly on empty args
986/// despite their hint text not being `[`-wrapped).
987///
988/// Entries whose `feature_gate` corresponds to a real, compile-time-relevant Cargo feature
989/// are excluded when that feature is off in this build (see `command_is_compiled_in_this_build`)
990/// — otherwise a feature-gated command that was never actually registered would still appear
991/// in autocomplete and fail when submitted (#5875 F2).
992///
993/// Lazily initialised and shared for the process lifetime, like [`command_registry`] and
994/// [`extra_command_registry`].
995///
996/// # Examples
997///
998/// ```rust
999/// use zeph_tui::command::zeph_commands_entries;
1000///
1001/// let entries = zeph_commands_entries();
1002/// assert!(entries.iter().any(|e| e.id == "/model"));
1003/// // Commands already covered by a hand-authored entry are not duplicated.
1004/// assert!(!entries.iter().any(|e| e.id == "/graph"));
1005/// ```
1006#[must_use]
1007pub fn zeph_commands_entries() -> &'static [CommandEntry] {
1008    static ENTRIES: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
1009    ENTRIES.get_or_init(|| {
1010        zeph_commands::COMMANDS
1011            .iter()
1012            .filter(|c| !ZEPH_COMMANDS_DEDUP.contains(&c.name))
1013            .filter(|c| command_is_compiled_in_this_build(c))
1014            .map(|c| CommandEntry {
1015                id: c.name,
1016                label: c.description,
1017                category: c.category.as_str(),
1018                shortcut: None,
1019                command: if c.args.starts_with('<') {
1020                    TuiCommand::PrefillVerbatim(format!("{} ", c.name))
1021                } else {
1022                    TuiCommand::SendVerbatim(c.name.to_owned())
1023                },
1024            })
1025            .collect()
1026    })
1027}
1028
1029fn build_extra_commands() -> Vec<CommandEntry> {
1030    let mut cmds = build_infra_commands();
1031    cmds.extend(build_agent_plan_commands());
1032    cmds.extend(build_graph_experiment_commands());
1033    cmds.push(CommandEntry {
1034        id: "lsp:status",
1035        label: "Show LSP context injection status (/lsp)",
1036        category: "lsp",
1037        shortcut: None,
1038        command: TuiCommand::LspStatus,
1039    });
1040    cmds.push(CommandEntry {
1041        id: "acp:dirs",
1042        label: "ACP: list allowlisted directories (/acp dirs)",
1043        category: "acp",
1044        shortcut: None,
1045        command: TuiCommand::AcpDirsList,
1046    });
1047    cmds.push(CommandEntry {
1048        id: "acp:auth-methods",
1049        label: "ACP: list advertised auth methods (/acp auth-methods)",
1050        category: "acp",
1051        shortcut: None,
1052        command: TuiCommand::AcpAuthMethodsView,
1053    });
1054    cmds.push(CommandEntry {
1055        id: "acp:status",
1056        label: "ACP: show runtime status and feature flags (/acp status)",
1057        category: "acp",
1058        shortcut: None,
1059        command: TuiCommand::AcpStatus,
1060    });
1061    cmds.push(CommandEntry {
1062        id: "acp:subagent-spawn",
1063        label: "ACP: spawn a sub-agent (/subagent spawn <cmd>)",
1064        category: "acp",
1065        shortcut: None,
1066        command: TuiCommand::SubagentSpawn {
1067            command: String::new(),
1068        },
1069    });
1070    #[cfg(feature = "cocoon")]
1071    cmds.extend(build_cocoon_commands());
1072    cmds.extend(build_clipboard_commands());
1073    cmds.extend(build_knowledge_commands());
1074    cmds
1075}
1076
1077/// Replaces `:` and `-` with a plain space.
1078///
1079/// Command ids use `:` or `-` as word separators (e.g. `session:new`,
1080/// `app:theme-list`) while users naturally type a space in their place (e.g.
1081/// "session new", "theme list"). Normalizing both the query and the matched
1082/// text through this function before handing them to `nucleo_matcher` makes
1083/// the three separators interchangeable on either side — otherwise a query
1084/// character that doesn't literally occur in the target (e.g. a `:` typed
1085/// against a `-`-separated id) fails the whole atom.
1086fn normalize_separators(s: &str) -> String {
1087    s.chars()
1088        .map(|c| if matches!(c, ':' | '-') { ' ' } else { c })
1089        .collect()
1090}
1091
1092/// Filter and rank all registered commands by fuzzy match against `query`.
1093///
1094/// Merges the core, daemon, and extra registries, scores each entry against
1095/// both its `id` and `label` via `nucleo_matcher` (`crate::fuzzy` — the same
1096/// engine and case/normalization settings as the `@` mention picker, but
1097/// with `prefer_prefix` enabled since this is short-string autocompletion,
1098/// not substring search over file paths), and returns the results sorted by
1099/// descending score. An empty query returns all commands in registration
1100/// order.
1101///
1102/// # Examples
1103///
1104/// ```rust
1105/// use zeph_tui::command::filter_commands;
1106///
1107/// // Exact prefix match
1108/// let results = filter_commands("skill");
1109/// assert!(!results.is_empty());
1110/// assert_eq!(results[0].id, "skill:list");
1111///
1112/// // Empty query returns everything
1113/// let all = filter_commands("");
1114/// assert!(all.len() > 10);
1115///
1116/// // No match returns empty
1117/// let none = filter_commands("xyzzy");
1118/// assert!(none.is_empty());
1119/// ```
1120#[must_use]
1121pub fn filter_commands(query: &str) -> Vec<&'static CommandEntry> {
1122    use nucleo_matcher::Utf32Str;
1123
1124    let mut all: Vec<&'static CommandEntry> = command_registry().iter().collect();
1125    all.extend(daemon_command_registry());
1126    all.extend(extra_command_registry());
1127    all.extend(zeph_commands_entries());
1128
1129    if query.is_empty() {
1130        return all;
1131    }
1132
1133    let normalized_query = normalize_separators(query);
1134    let pattern = crate::fuzzy::pattern(&normalized_query);
1135    // `prefer_prefix: true` — the user is expected to type the whole id/label
1136    // here (a short autocompletion target), unlike the `@` mention picker's
1137    // arbitrary substring search over file paths (see `crate::fuzzy`).
1138    let mut matcher = crate::fuzzy::matcher(true);
1139
1140    let mut scored: Vec<(&'static CommandEntry, u32)> = all
1141        .into_iter()
1142        .filter_map(|e| {
1143            let id_norm = normalize_separators(e.id);
1144            let label_norm = normalize_separators(e.label);
1145            let mut buf = Vec::new();
1146            let id_score = pattern.score(Utf32Str::new(&id_norm, &mut buf), &mut matcher);
1147            let mut buf = Vec::new();
1148            let label_score = pattern.score(Utf32Str::new(&label_norm, &mut buf), &mut matcher);
1149            // `Option<u32>: Ord` already orders `None < Some(_)` and compares inner
1150            // values, so `.max()` is exactly the "best of either match, or neither" logic.
1151            id_score.max(label_score).map(|s| (e, s))
1152        })
1153        .collect();
1154
1155    scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
1156    scored.into_iter().map(|(e, _)| e).collect()
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    #[test]
1164    fn registry_has_correct_count() {
1165        // +1 view:latency (#6059); +2 settings + search:transcript (#6024/#6023);
1166        // +1 integrity:status (#6449); +1 app:panel-sizing (#6675)
1167        assert_eq!(command_registry().len(), 32);
1168    }
1169
1170    #[test]
1171    fn extra_registry_has_correct_command_count() {
1172        // 24 base (14 + 5 plan + 5 graph) + 5 experiment + 1 log:status + 1 config:migrate
1173        // + 1 compaction:status + 1 guidelines:view + 1 tafc:status + 1 lsp:status
1174        // + 1 forgetting-sweep + 3 acp + 1 sandbox:status (#3294) = 43
1175        // + 2 cocoon (#3673) when feature = "cocoon"
1176        // + 2 clipboard (#3685, #5098)
1177        // + 2 worktree (#4679)
1178        // + 3 knowledge (#5019, #5020)
1179        let expected = 50 + if cfg!(feature = "cocoon") { 2 } else { 0 };
1180        assert_eq!(extra_command_registry().len(), expected);
1181    }
1182
1183    #[cfg(feature = "cocoon")]
1184    #[test]
1185    fn filter_cocoon_returns_cocoon_entries() {
1186        let results = filter_commands("cocoon");
1187        assert!(results.iter().any(|e| e.id == "cocoon:status"));
1188        assert!(results.iter().any(|e| e.id == "cocoon:models"));
1189    }
1190
1191    #[test]
1192    fn filter_commands_includes_extra() {
1193        let all = filter_commands("");
1194        assert!(all.iter().any(|e| e.id == "view:filters"));
1195        assert!(all.iter().any(|e| e.id == "ingest"));
1196        assert!(all.iter().any(|e| e.id == "gateway:status"));
1197        assert!(all.iter().any(|e| e.id == "scheduler:list"));
1198        assert!(all.iter().any(|e| e.id == "security:events"));
1199        assert!(all.iter().any(|e| e.id == "log:status"));
1200    }
1201
1202    #[test]
1203    fn filter_empty_query_returns_all() {
1204        let results = filter_commands("");
1205        assert_eq!(
1206            results.len(),
1207            command_registry().len()
1208                + daemon_command_registry().len()
1209                + extra_command_registry().len()
1210                + zeph_commands_entries().len()
1211        );
1212    }
1213
1214    #[test]
1215    fn filter_by_id_prefix() {
1216        let results = filter_commands("skill");
1217        assert!(!results.is_empty());
1218        // skill:list must be the top-ranked result
1219        assert_eq!(results[0].id, "skill:list");
1220    }
1221
1222    #[test]
1223    fn filter_by_label_substring() {
1224        let results = filter_commands("memory");
1225        assert!(!results.is_empty());
1226        assert!(results.iter().any(|e| e.id == "memory:stats"));
1227    }
1228
1229    #[test]
1230    fn filter_case_insensitive() {
1231        let results = filter_commands("view");
1232        assert!(results.len() >= 4);
1233    }
1234
1235    #[test]
1236    fn filter_no_match_returns_empty() {
1237        let results = filter_commands("xxxxxx");
1238        assert!(results.is_empty());
1239    }
1240
1241    #[test]
1242    fn filter_partial_label_match() {
1243        let results = filter_commands("cost");
1244        assert!(!results.is_empty());
1245        assert_eq!(results[0].id, "view:cost");
1246    }
1247
1248    #[test]
1249    fn filter_mcp_matches_id_and_label() {
1250        let results = filter_commands("mcp");
1251        assert!(results.iter().any(|e| e.id == "mcp:list"));
1252    }
1253
1254    #[test]
1255    fn fuzzy_ranks_skill_list_above_mcp_list_for_sl() {
1256        let results = filter_commands("sl");
1257        // skill:list should appear before mcp:list
1258        let skill_pos = results.iter().position(|e| e.id == "skill:list");
1259        let mcp_pos = results.iter().position(|e| e.id == "mcp:list");
1260        assert!(skill_pos.is_some());
1261        if let (Some(s), Some(m)) = (skill_pos, mcp_pos) {
1262            assert!(
1263                s <= m,
1264                "skill:list should rank at least as high as mcp:list for 'sl'"
1265            );
1266        }
1267    }
1268
1269    // Ranking pins for the nucleo_matcher unification (#6650): lock in the
1270    // current top-ranked result for representative queries so a future
1271    // `nucleo-matcher` version bump or registry change is a deliberate,
1272    // reviewed change rather than a silent ranking drift.
1273
1274    #[test]
1275    fn ranking_pin_mem_st() {
1276        let results = filter_commands("mem st");
1277        assert_eq!(results.first().map(|e| e.id), Some("memory:stats"));
1278    }
1279
1280    #[test]
1281    fn ranking_pin_agents_list() {
1282        let results = filter_commands("agents-list");
1283        assert_eq!(results.first().map(|e| e.id), Some("agent:list"));
1284    }
1285
1286    #[test]
1287    fn ranking_pin_sess() {
1288        // With `Config::DEFAULT` (no haystack-length normalization), "sess"
1289        // was a genuine score tie between "session:new"'s id and "fleet"'s
1290        // label ("Fleet: show agent sessions"), broken by declaration order
1291        // in "fleet"'s favor — a systematic regression versus the old
1292        // scorer for the entire `session*` prefix family, not a one-off
1293        // (critic finding S3). `prefer_prefix: true` (`crate::fuzzy`, this
1294        // surface only — see its doc comment) breaks the tie correctly:
1295        // "session:new" 122 vs "fleet" 114.
1296        let results = filter_commands("sess");
1297        assert_eq!(results.first().map(|e| e.id), Some("session:new"));
1298        assert!(results.iter().any(|e| e.id == "session:history"));
1299        assert!(results.iter().any(|e| e.id == "fleet"));
1300    }
1301
1302    #[test]
1303    fn new_commands_present() {
1304        let all = filter_commands("");
1305        assert!(all.iter().any(|e| e.id == "app:quit"));
1306        assert!(all.iter().any(|e| e.id == "app:help"));
1307        assert!(all.iter().any(|e| e.id == "session:new"));
1308        assert!(all.iter().any(|e| e.id == "session:history"));
1309        assert!(all.iter().any(|e| e.id == "session:next"));
1310        assert!(all.iter().any(|e| e.id == "session:prev"));
1311        assert!(all.iter().any(|e| e.id == "session:close"));
1312    }
1313
1314    #[test]
1315    fn filter_space_query_matches_colon_separated_id() {
1316        // Typing the natural "session new" (space instead of colon) must
1317        // still match the "session:new" command id (#5790).
1318        let results = filter_commands("session new");
1319        assert!(
1320            results.iter().any(|e| e.id == "session:new"),
1321            "session:new must match query with a literal space"
1322        );
1323
1324        let results = filter_commands("skill list");
1325        assert!(
1326            results.iter().any(|e| e.id == "skill:list"),
1327            "skill:list must match query with a literal space"
1328        );
1329    }
1330
1331    #[test]
1332    fn filter_colon_query_still_matches_colon_id() {
1333        // A literal ':' in the query (typed verbatim, e.g. "session:new") must
1334        // keep matching its own id exactly as before the space/colon
1335        // equivalence fix (#5790).
1336        let results = filter_commands("session:new");
1337        assert_eq!(
1338            results.first().map(|e| e.id),
1339            Some("session:new"),
1340            "literal colon query must still rank its own id first"
1341        );
1342    }
1343
1344    #[test]
1345    fn filter_multiword_label_query_matches_session_next_not_regressed() {
1346        // "session:next" has no literal colon-for-space substitution needed:
1347        // its label "Switch to next session (/session next)" already
1348        // contains "session next" as a literal substring. Confirm the
1349        // space/colon equivalence fix does not regress this pre-existing
1350        // label-based match.
1351        let results = filter_commands("session next");
1352        assert!(
1353            results.iter().any(|e| e.id == "session:next"),
1354            "session:next must still match its label substring 'session next'"
1355        );
1356    }
1357
1358    #[test]
1359    fn filter_repeated_or_boundary_whitespace_normalized() {
1360        // `filter_commands` trims and collapses whitespace before scoring, so
1361        // a stray double space or leading/trailing space (trivially plausible
1362        // typos) no longer desyncs the query's separator count from the
1363        // target's and reproduces the #5790 symptom (autocomplete popup finds
1364        // nothing, raw text falls through as a chat message).
1365        assert!(
1366            filter_commands("session  new")
1367                .iter()
1368                .any(|e| e.id == "session:new"),
1369            "double space must still match session:new"
1370        );
1371        assert!(
1372            filter_commands("session new ")
1373                .iter()
1374                .any(|e| e.id == "session:new"),
1375            "trailing space must still match session:new"
1376        );
1377        assert!(
1378            filter_commands(" session new")
1379                .iter()
1380                .any(|e| e.id == "session:new"),
1381            "leading space must still match session:new"
1382        );
1383    }
1384
1385    #[test]
1386    fn filter_hyphenated_id_matches_space_query() {
1387        // Hyphen-separated ids (e.g. app:theme-list) exhibit the same #5790
1388        // symptom as colon-separated ones: a query typed with a space in
1389        // place of the hyphen must still match.
1390        let results = filter_commands("theme list");
1391        assert!(
1392            results.iter().any(|e| e.id == "app:theme-list"),
1393            "app:theme-list must match query with a literal space in place of the hyphen"
1394        );
1395    }
1396
1397    #[test]
1398    fn shortcut_on_quit_and_help() {
1399        let registry = command_registry();
1400        let quit = registry.iter().find(|e| e.id == "app:quit").unwrap();
1401        let help = registry.iter().find(|e| e.id == "app:help").unwrap();
1402        assert_eq!(quit.shortcut, Some("q"));
1403        assert_eq!(help.shortcut, Some("?"));
1404    }
1405
1406    #[test]
1407    fn zeph_commands_entries_includes_previously_invisible_commands() {
1408        // #5875: these AgentAccess-routed commands were dispatchable when typed in full but
1409        // never appeared in TUI autocomplete because zeph-tui's registry never sourced from
1410        // zeph_commands::COMMANDS.
1411        let entries = zeph_commands_entries();
1412        for name in [
1413            "/model",
1414            "/provider",
1415            "/skill",
1416            "/policy",
1417            "/think-tokens",
1418            "/reasoning-effort",
1419            "/status",
1420            "/conv",
1421        ] {
1422            assert!(
1423                entries.iter().any(|e| e.id == name),
1424                "{name} must appear in zeph_commands_entries()"
1425            );
1426        }
1427    }
1428
1429    #[test]
1430    fn zeph_commands_entries_excludes_dedup_list() {
1431        let entries = zeph_commands_entries();
1432        for name in ZEPH_COMMANDS_DEDUP {
1433            assert!(
1434                !entries.iter().any(|e| &e.id == name),
1435                "{name} is already covered by a hand-authored TuiCommand and must not be \
1436                 duplicated in zeph_commands_entries()"
1437            );
1438        }
1439    }
1440
1441    #[test]
1442    fn zeph_commands_entries_includes_clear_queue_not_a_real_duplicate() {
1443        // #5875 F3 fix: /clear-queue was incorrectly in ZEPH_COMMANDS_DEDUP — the only
1444        // matching TuiCommand (SendClearQueue) is reachable exclusively via the Ctrl+K
1445        // keybinding, with no CommandEntry in any of the three hand-authored registries, so
1446        // there was nothing to actually deduplicate against. It must appear here.
1447        let entries = zeph_commands_entries();
1448        assert!(entries.iter().any(|e| e.id == "/clear-queue"));
1449    }
1450
1451    #[test]
1452    fn zeph_commands_dedup_entries_have_a_real_hand_authored_replacement() {
1453        // #5875 F3: guards against a dedup'd name silently becoming an orphan (e.g. if the
1454        // hand-authored entry it claims to duplicate is later renamed or removed).
1455        let mut hand_authored: Vec<&'static CommandEntry> = command_registry().iter().collect();
1456        hand_authored.extend(daemon_command_registry());
1457        hand_authored.extend(extra_command_registry());
1458
1459        let expected: &[(&str, &str)] = &[
1460            ("/skills", "skill:list"),
1461            ("/mcp", "mcp:list"),
1462            ("/memory", "memory:stats"),
1463            ("/guidelines", "guidelines:view"),
1464            ("/log", "log:status"),
1465            ("/undo", "session:undo"),
1466            ("/redo", "session:redo"),
1467            ("/graph", "graph:stats"),
1468            ("/lsp", "lsp:status"),
1469            ("/scheduler", "scheduler:list"),
1470            ("/subagent", "acp:subagent-spawn"),
1471        ];
1472        assert_eq!(
1473            expected.len(),
1474            ZEPH_COMMANDS_DEDUP.len(),
1475            "this test's `expected` table has drifted out of sync with ZEPH_COMMANDS_DEDUP — \
1476             update both together"
1477        );
1478        for (dedup_name, expected_hand_id) in expected {
1479            assert!(
1480                ZEPH_COMMANDS_DEDUP.contains(dedup_name),
1481                "test out of sync: {dedup_name} is not in ZEPH_COMMANDS_DEDUP"
1482            );
1483            assert!(
1484                hand_authored.iter().any(|e| &e.id == expected_hand_id),
1485                "{dedup_name} is in ZEPH_COMMANDS_DEDUP claiming to be covered by \
1486                 {expected_hand_id}, but no such hand-authored CommandEntry exists — either \
1487                 restore equivalent coverage or remove {dedup_name} from ZEPH_COMMANDS_DEDUP \
1488                 so it reappears in autocomplete"
1489            );
1490        }
1491    }
1492
1493    #[test]
1494    fn zeph_commands_entries_prefills_mandatory_arg_commands() {
1495        // #5875 F1: commands whose bare form would just produce a usage error must prefill
1496        // the input for the user to complete, not submit immediately.
1497        let entries = zeph_commands_entries();
1498        for name in [
1499            "/image",
1500            "/feedback",
1501            "/skill",
1502            "/skill create",
1503            "/dump-format",
1504            "/loop",
1505        ] {
1506            let entry = entries
1507                .iter()
1508                .find(|e| e.id == name)
1509                .unwrap_or_else(|| panic!("{name} must appear in zeph_commands_entries()"));
1510            assert!(
1511                matches!(entry.command, TuiCommand::PrefillVerbatim(_)),
1512                "{name} requires an argument and must prefill rather than submit bare"
1513            );
1514        }
1515    }
1516
1517    #[test]
1518    fn zeph_commands_entries_sends_safe_bare_commands_immediately() {
1519        // #5875 F1: commands whose bare (no-arg) form is a valid, useful default (verified
1520        // against each handler's real empty-args behavior, not just the `args` hint text —
1521        // `/goal` and `/worktree` both default sensibly despite non-bracket-wrapped hints)
1522        // must still submit immediately.
1523        let entries = zeph_commands_entries();
1524        for name in ["/model", "/status", "/goal", "/worktree", "/conv"] {
1525            let entry = entries
1526                .iter()
1527                .find(|e| e.id == name)
1528                .unwrap_or_else(|| panic!("{name} must appear in zeph_commands_entries()"));
1529            assert!(
1530                matches!(entry.command, TuiCommand::SendVerbatim(_)),
1531                "{name} has a safe bare default and should submit immediately"
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    #[cfg(feature = "cocoon")]
1538    fn zeph_commands_entries_includes_cocoon_when_feature_enabled() {
1539        // #5875 F2.
1540        let entries = zeph_commands_entries();
1541        assert!(entries.iter().any(|e| e.id == "/cocoon"));
1542    }
1543
1544    #[test]
1545    #[cfg(not(feature = "cocoon"))]
1546    fn zeph_commands_entries_excludes_cocoon_when_feature_disabled() {
1547        // #5875 F2: without this, a cocoon-feature-off build would still show /cocoon in
1548        // autocomplete and fail when submitted, since CocoonCommand is never registered.
1549        let entries = zeph_commands_entries();
1550        assert!(!entries.iter().any(|e| e.id == "/cocoon"));
1551    }
1552
1553    #[test]
1554    fn filter_commands_merges_zeph_commands_entries() {
1555        let results = filter_commands("model");
1556        assert!(results.iter().any(|e| e.id == "/model"));
1557    }
1558
1559    #[test]
1560    fn no_duplicate_ids_across_merged_registries() {
1561        let all = filter_commands("");
1562        let mut seen = std::collections::HashSet::new();
1563        for entry in &all {
1564            assert!(
1565                seen.insert(entry.id),
1566                "duplicate command id in merged autocomplete list: {}",
1567                entry.id
1568            );
1569        }
1570    }
1571
1572    #[test]
1573    fn filter_security_returns_security_events_entry() {
1574        let results = filter_commands("security");
1575        assert!(
1576            results.iter().any(|e| e.id == "security:events"),
1577            "security:events must appear when searching 'security'"
1578        );
1579    }
1580
1581    #[test]
1582    fn filter_graph_returns_graph_entries() {
1583        let results = filter_commands("graph");
1584        assert!(results.iter().any(|e| e.id == "graph:stats"));
1585        assert!(results.iter().any(|e| e.id == "graph:entities"));
1586        assert!(results.iter().any(|e| e.id == "graph:facts"));
1587        assert!(results.iter().any(|e| e.id == "graph:communities"));
1588        assert!(results.iter().any(|e| e.id == "graph:backfill"));
1589    }
1590
1591    #[test]
1592    fn filter_experiment_returns_experiment_entries() {
1593        let results = filter_commands("experiment");
1594        assert!(results.iter().any(|e| e.id == "experiment:start"));
1595        assert!(results.iter().any(|e| e.id == "experiment:stop"));
1596        assert!(results.iter().any(|e| e.id == "experiment:status"));
1597        assert!(results.iter().any(|e| e.id == "experiment:report"));
1598        assert!(results.iter().any(|e| e.id == "experiment:best"));
1599    }
1600
1601    #[test]
1602    fn filter_clipboard_returns_copy_entry() {
1603        let results = filter_commands("copy");
1604        assert!(
1605            results.iter().any(|e| e.id == "clipboard:copy"),
1606            "clipboard:copy must appear when searching 'copy'"
1607        );
1608    }
1609
1610    #[test]
1611    fn clipboard_copy_command_is_copy_last_assistant() {
1612        let all = filter_commands("");
1613        let entry = all.iter().find(|e| e.id == "clipboard:copy").unwrap();
1614        assert_eq!(entry.command, TuiCommand::CopyLastAssistant);
1615        assert_eq!(entry.shortcut, Some("Ctrl+O"));
1616    }
1617}