1#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum TuiCommand {
21 SkillList,
23 McpList,
24 MemoryStats,
25 ViewCost,
26 ViewTools,
27 ViewConfig,
28 ViewAutonomy,
29 ViewLatency,
30 Quit,
32 Help,
33 NewSession,
34 ToggleTheme,
35 SessionBrowser,
37 DaemonConnect,
39 DaemonDisconnect,
40 DaemonStatus,
41 ViewFilters,
43 Ingest,
45 GatewayStatus,
47 SchedulerList,
49 AgentList,
51 AgentStatus,
52 AgentCancelPrompt,
53 AgentSpawnPrompt,
54 RouterStats,
56 AgentsShow,
58 AgentsCreate,
59 AgentsEdit,
60 AgentsDelete,
61 SecurityEvents,
63 PlanStatus,
65 PlanConfirm,
66 PlanCancel,
67 PlanList,
68 PlanToggleView,
69 GraphStats,
71 GraphEntities,
72 GraphFactsPrompt,
73 GraphCommunities,
74 GraphBackfillPrompt,
75 ExperimentStart,
77 ExperimentStop,
78 ExperimentStatus,
79 ExperimentReport,
80 ExperimentBest,
81 LspStatus,
83 ViewLog,
85 MigrateConfig,
87 ServerCompactionStatus,
89 ViewGuidelines,
91 TafcStatus,
93 ForgettingSweep,
95 TrajectoryStats,
97 MemoryTreeStats,
99 TaskPanel,
101 PluginList,
103 PluginAdd,
104 PluginRemove,
105 SessionSwitchNext,
107 SessionSwitchPrev,
108 SessionClose,
109 PluginListOverlay,
111 AcpDirsList,
113 AcpAuthMethodsView,
114 AcpStatus,
115 SubagentSpawn {
117 command: String,
118 },
119 SandboxStatus,
121 CocoonStatus,
123 CocoonModels,
124 CopyLastAssistant,
126 CopyLastCodeBlock(usize),
129 FleetPanel,
131 DurablePanel,
133 Settings,
135 TranscriptSearch,
137 IntegrityStatusInfo,
139 WorktreeList,
141 WorktreeClean,
142 Undo,
144 Redo,
145 KnowledgeStatus,
147 KnowledgeRollbackPrompt,
148 KnowledgeIngestPrompt,
149 ListThemes,
152 SetTheme(String),
154 SetMotion(zeph_config::Motion),
157 SetMouse(bool),
160 ToggleMouse,
162 ToggleEqualizer,
164 SetPanelSizing(zeph_config::PanelSizingMode),
167 TogglePanelSizing,
169 SubagentSidebarDown,
172 SubagentSidebarUp,
174 SendClearQueue,
176 SendVerbatim(String),
184 PrefillVerbatim(String),
192}
193
194pub struct CommandEntry {
209 pub id: &'static str,
211 pub label: &'static str,
213 pub category: &'static str,
215 pub shortcut: Option<&'static str>,
217 pub command: TuiCommand,
219}
220
221#[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#[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#[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
917const ZEPH_COMMANDS_DEDUP: &[&str] = &[
936 "/skills", "/mcp", "/memory", "/guidelines", "/log", "/undo", "/redo", "/graph", "/lsp", "/scheduler", "/subagent", ];
948
949fn command_is_compiled_in_this_build(entry: &zeph_commands::CommandInfo) -> bool {
965 entry.feature_gate != Some("cocoon") || cfg!(feature = "cocoon")
966}
967
968#[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
1077fn normalize_separators(s: &str) -> String {
1087 s.chars()
1088 .map(|c| if matches!(c, ':' | '-') { ' ' } else { c })
1089 .collect()
1090}
1091
1092#[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 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 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 assert_eq!(command_registry().len(), 32);
1168 }
1169
1170 #[test]
1171 fn extra_registry_has_correct_command_count() {
1172 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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}