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 SubagentSidebarDown,
167 SubagentSidebarUp,
169 SendClearQueue,
171 SendVerbatim(String),
179 PrefillVerbatim(String),
187}
188
189pub struct CommandEntry {
204 pub id: &'static str,
206 pub label: &'static str,
208 pub category: &'static str,
210 pub shortcut: Option<&'static str>,
212 pub command: TuiCommand,
214}
215
216#[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#[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#[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
905const ZEPH_COMMANDS_DEDUP: &[&str] = &[
924 "/skills", "/mcp", "/memory", "/guidelines", "/log", "/undo", "/redo", "/graph", "/lsp", "/scheduler", "/subagent", ];
936
937fn command_is_compiled_in_this_build(entry: &zeph_commands::CommandInfo) -> bool {
953 entry.feature_gate != Some("cocoon") || cfg!(feature = "cocoon")
954}
955
956#[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
1065fn fuzzy_chars_equivalent(a: char, b: char) -> bool {
1074 a == b || (matches!(a, ' ' | ':' | '-') && matches!(b, ' ' | ':' | '-'))
1075}
1076
1077fn 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 Some(query_chars.len().cast_signed() * 10 - gaps)
1109 } else {
1110 None
1111 }
1112}
1113
1114#[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 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 assert_eq!(command_registry().len(), 31);
1183 }
1184
1185 #[test]
1186 fn extra_registry_has_correct_command_count() {
1187 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}