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