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