1#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TuiCommand {
20 SkillList,
22 McpList,
23 MemoryStats,
24 ViewCost,
25 ViewTools,
26 ViewConfig,
27 ViewAutonomy,
28 Quit,
30 Help,
31 NewSession,
32 ToggleTheme,
33 SessionBrowser,
35 DaemonConnect,
37 DaemonDisconnect,
38 DaemonStatus,
39 ViewFilters,
41 Ingest,
43 GatewayStatus,
45 SchedulerList,
47 AgentList,
49 AgentStatus,
50 AgentCancelPrompt,
51 AgentSpawnPrompt,
52 RouterStats,
54 AgentsShow,
56 AgentsCreate,
57 AgentsEdit,
58 AgentsDelete,
59 SecurityEvents,
61 PlanStatus,
63 PlanConfirm,
64 PlanCancel,
65 PlanList,
66 PlanToggleView,
67 GraphStats,
69 GraphEntities,
70 GraphFactsPrompt,
71 GraphCommunities,
72 GraphBackfillPrompt,
73 ExperimentStart,
75 ExperimentStop,
76 ExperimentStatus,
77 ExperimentReport,
78 ExperimentBest,
79 LspStatus,
81 ViewLog,
83 MigrateConfig,
85 ServerCompactionStatus,
87 ViewGuidelines,
89 TafcStatus,
91 ForgettingSweep,
93 TrajectoryStats,
95 MemoryTreeStats,
97 TaskPanel,
99 PluginList,
101 PluginAdd,
102 PluginRemove,
103 SessionSwitchNext,
105 SessionSwitchPrev,
106 SessionClose,
107 PluginListOverlay,
109 AcpDirsList,
111 AcpAuthMethodsView,
112 AcpStatus,
113 SubagentSpawn { command: String },
115 SandboxStatus,
117}
118
119pub struct CommandEntry {
134 pub id: &'static str,
136 pub label: &'static str,
138 pub category: &'static str,
140 pub shortcut: Option<&'static str>,
142 pub command: TuiCommand,
144}
145
146#[must_use]
164pub fn command_registry() -> &'static [CommandEntry] {
165 static COMMANDS: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
166 COMMANDS.get_or_init(build_core_commands)
167}
168
169fn build_view_commands() -> Vec<CommandEntry> {
170 vec![
171 CommandEntry {
172 id: "skill:list",
173 label: "List loaded skills",
174 category: "skill",
175 shortcut: None,
176 command: TuiCommand::SkillList,
177 },
178 CommandEntry {
179 id: "mcp:list",
180 label: "List MCP servers and tools",
181 category: "mcp",
182 shortcut: None,
183 command: TuiCommand::McpList,
184 },
185 CommandEntry {
186 id: "memory:stats",
187 label: "Show memory statistics",
188 category: "memory",
189 shortcut: None,
190 command: TuiCommand::MemoryStats,
191 },
192 CommandEntry {
193 id: "view:cost",
194 label: "Show cost breakdown",
195 category: "view",
196 shortcut: None,
197 command: TuiCommand::ViewCost,
198 },
199 CommandEntry {
200 id: "view:tools",
201 label: "List available tools",
202 category: "view",
203 shortcut: None,
204 command: TuiCommand::ViewTools,
205 },
206 CommandEntry {
207 id: "view:config",
208 label: "Show active configuration",
209 category: "view",
210 shortcut: None,
211 command: TuiCommand::ViewConfig,
212 },
213 CommandEntry {
214 id: "view:autonomy",
215 label: "Show autonomy/trust level",
216 category: "view",
217 shortcut: None,
218 command: TuiCommand::ViewAutonomy,
219 },
220 CommandEntry {
221 id: "tasks",
222 label: "Toggle task registry panel",
223 category: "view",
224 shortcut: None,
225 command: TuiCommand::TaskPanel,
226 },
227 ]
228}
229
230fn build_session_commands() -> Vec<CommandEntry> {
231 vec![
232 CommandEntry {
233 id: "session:new",
234 label: "Start new conversation",
235 category: "session",
236 shortcut: None,
237 command: TuiCommand::NewSession,
238 },
239 CommandEntry {
240 id: "session:history",
241 label: "Browse session history",
242 category: "session",
243 shortcut: Some("H"),
244 command: TuiCommand::SessionBrowser,
245 },
246 CommandEntry {
247 id: "session:next",
248 label: "Switch to next session (/session next)",
249 category: "session",
250 shortcut: None,
251 command: TuiCommand::SessionSwitchNext,
252 },
253 CommandEntry {
254 id: "session:prev",
255 label: "Switch to previous session (/session prev)",
256 category: "session",
257 shortcut: None,
258 command: TuiCommand::SessionSwitchPrev,
259 },
260 CommandEntry {
261 id: "session:close",
262 label: "Close current session (/session close)",
263 category: "session",
264 shortcut: None,
265 command: TuiCommand::SessionClose,
266 },
267 ]
268}
269
270fn build_app_commands() -> Vec<CommandEntry> {
271 vec![
272 CommandEntry {
273 id: "app:quit",
274 label: "Quit application",
275 category: "app",
276 shortcut: Some("q"),
277 command: TuiCommand::Quit,
278 },
279 CommandEntry {
280 id: "app:help",
281 label: "Show keybindings help",
282 category: "app",
283 shortcut: Some("?"),
284 command: TuiCommand::Help,
285 },
286 CommandEntry {
287 id: "app:theme",
288 label: "Toggle theme (dark/light)",
289 category: "app",
290 shortcut: None,
291 command: TuiCommand::ToggleTheme,
292 },
293 ]
294}
295
296fn build_plugin_commands() -> Vec<CommandEntry> {
297 vec![
298 CommandEntry {
299 id: "plugin:list",
300 label: "List installed plugins (/plugins list)",
301 category: "plugin",
302 shortcut: None,
303 command: TuiCommand::PluginList,
304 },
305 CommandEntry {
306 id: "plugin:add",
307 label: "Install a plugin (/plugins add <source>)",
308 category: "plugin",
309 shortcut: None,
310 command: TuiCommand::PluginAdd,
311 },
312 CommandEntry {
313 id: "plugin:remove",
314 label: "Remove an installed plugin (/plugins remove <name>)",
315 category: "plugin",
316 shortcut: None,
317 command: TuiCommand::PluginRemove,
318 },
319 CommandEntry {
320 id: "plugin:overlay",
321 label: "Plugin overlay status — source and skipped plugins (/plugins overlay)",
322 category: "plugin",
323 shortcut: None,
324 command: TuiCommand::PluginListOverlay,
325 },
326 ]
327}
328
329fn build_core_commands() -> Vec<CommandEntry> {
330 let mut cmds = build_view_commands();
331 cmds.extend(build_session_commands());
332 cmds.extend(build_app_commands());
333 cmds.extend(build_plugin_commands());
334 cmds
335}
336
337#[must_use]
350pub fn daemon_command_registry() -> &'static [CommandEntry] {
351 static DAEMON_COMMANDS: &[CommandEntry] = &[
352 CommandEntry {
353 id: "daemon:connect",
354 label: "Connect to remote daemon",
355 category: "daemon",
356 shortcut: None,
357 command: TuiCommand::DaemonConnect,
358 },
359 CommandEntry {
360 id: "daemon:disconnect",
361 label: "Disconnect from daemon",
362 category: "daemon",
363 shortcut: None,
364 command: TuiCommand::DaemonDisconnect,
365 },
366 CommandEntry {
367 id: "daemon:status",
368 label: "Show connection status",
369 category: "daemon",
370 shortcut: None,
371 command: TuiCommand::DaemonStatus,
372 },
373 ];
374 DAEMON_COMMANDS
375}
376
377#[must_use]
392pub fn extra_command_registry() -> &'static [CommandEntry] {
393 static EXTRA: std::sync::OnceLock<Vec<CommandEntry>> = std::sync::OnceLock::new();
394 EXTRA.get_or_init(build_extra_commands)
395}
396
397fn build_infra_commands() -> Vec<CommandEntry> {
398 vec![
399 CommandEntry {
400 id: "view:filters",
401 label: "Show output filter statistics",
402 category: "view",
403 shortcut: None,
404 command: TuiCommand::ViewFilters,
405 },
406 CommandEntry {
407 id: "ingest",
408 label: "Ingest document into memory (/ingest <path>)",
409 category: "memory",
410 shortcut: None,
411 command: TuiCommand::Ingest,
412 },
413 CommandEntry {
414 id: "gateway:status",
415 label: "Show gateway server status",
416 category: "gateway",
417 shortcut: None,
418 command: TuiCommand::GatewayStatus,
419 },
420 CommandEntry {
421 id: "scheduler:list",
422 label: "List scheduled tasks",
423 category: "scheduler",
424 shortcut: None,
425 command: TuiCommand::SchedulerList,
426 },
427 CommandEntry {
428 id: "router:stats",
429 label: "Show Thompson router alpha/beta per provider",
430 category: "router",
431 shortcut: None,
432 command: TuiCommand::RouterStats,
433 },
434 CommandEntry {
435 id: "security:events",
436 label: "Show security event history",
437 category: "security",
438 shortcut: None,
439 command: TuiCommand::SecurityEvents,
440 },
441 CommandEntry {
442 id: "sandbox:status",
443 label: "Show sandbox status: backend, denied_domains, fail_if_unavailable",
444 category: "security",
445 shortcut: None,
446 command: TuiCommand::SandboxStatus,
447 },
448 CommandEntry {
449 id: "log:status",
450 label: "Show log file path and recent entries (/log)",
451 category: "log",
452 shortcut: None,
453 command: TuiCommand::ViewLog,
454 },
455 CommandEntry {
456 id: "config:migrate",
457 label: "Show config migration diff (missing parameters)",
458 category: "config",
459 shortcut: None,
460 command: TuiCommand::MigrateConfig,
461 },
462 CommandEntry {
463 id: "compaction:status",
464 label: "Show server-side compaction status",
465 category: "context",
466 shortcut: None,
467 command: TuiCommand::ServerCompactionStatus,
468 },
469 CommandEntry {
470 id: "tafc:status",
471 label: "Show Think-Augmented Function Calling (TAFC) status (/tafc)",
472 category: "tools",
473 shortcut: None,
474 command: TuiCommand::TafcStatus,
475 },
476 CommandEntry {
477 id: "memory:forgetting-sweep",
478 label: "Run forgetting sweep once (/forgetting-sweep)",
479 category: "memory",
480 shortcut: None,
481 command: TuiCommand::ForgettingSweep,
482 },
483 CommandEntry {
484 id: "memory:trajectory",
485 label: "Show trajectory memory statistics (/memory trajectory)",
486 category: "memory",
487 shortcut: None,
488 command: TuiCommand::TrajectoryStats,
489 },
490 CommandEntry {
491 id: "memory:tree",
492 label: "Show memory tree statistics (/memory tree)",
493 category: "memory",
494 shortcut: None,
495 command: TuiCommand::MemoryTreeStats,
496 },
497 ]
498}
499
500fn build_agent_plan_commands() -> Vec<CommandEntry> {
501 vec![
502 CommandEntry {
503 id: "agent:list",
504 label: "List sub-agents (/agent list)",
505 category: "agent",
506 shortcut: None,
507 command: TuiCommand::AgentList,
508 },
509 CommandEntry {
510 id: "agent:status",
511 label: "Show sub-agent status (/agent status)",
512 category: "agent",
513 shortcut: None,
514 command: TuiCommand::AgentStatus,
515 },
516 CommandEntry {
517 id: "agent:cancel",
518 label: "Cancel a sub-agent (/agent cancel <id>)",
519 category: "agent",
520 shortcut: None,
521 command: TuiCommand::AgentCancelPrompt,
522 },
523 CommandEntry {
524 id: "agent:spawn",
525 label: "Spawn a sub-agent (/agent spawn <name>)",
526 category: "agent",
527 shortcut: None,
528 command: TuiCommand::AgentSpawnPrompt,
529 },
530 CommandEntry {
531 id: "agents:show",
532 label: "Show sub-agent definition details (/agents show <name>)",
533 category: "agents",
534 shortcut: None,
535 command: TuiCommand::AgentsShow,
536 },
537 CommandEntry {
538 id: "agents:create",
539 label: "Create a new sub-agent definition (/agents create <name>)",
540 category: "agents",
541 shortcut: None,
542 command: TuiCommand::AgentsCreate,
543 },
544 CommandEntry {
545 id: "agents:edit",
546 label: "Edit a sub-agent definition (/agents edit <name>)",
547 category: "agents",
548 shortcut: None,
549 command: TuiCommand::AgentsEdit,
550 },
551 CommandEntry {
552 id: "agents:delete",
553 label: "Delete a sub-agent definition (/agents delete <name>)",
554 category: "agents",
555 shortcut: None,
556 command: TuiCommand::AgentsDelete,
557 },
558 CommandEntry {
559 id: "plan:status",
560 label: "Show orchestration plan status (/plan status)",
561 category: "plan",
562 shortcut: None,
563 command: TuiCommand::PlanStatus,
564 },
565 CommandEntry {
566 id: "plan:confirm",
567 label: "Confirm and execute pending plan (/plan confirm)",
568 category: "plan",
569 shortcut: None,
570 command: TuiCommand::PlanConfirm,
571 },
572 CommandEntry {
573 id: "plan:cancel",
574 label: "Cancel current plan (/plan cancel)",
575 category: "plan",
576 shortcut: None,
577 command: TuiCommand::PlanCancel,
578 },
579 CommandEntry {
580 id: "plan:list",
581 label: "List recent plans (/plan list)",
582 category: "plan",
583 shortcut: None,
584 command: TuiCommand::PlanList,
585 },
586 CommandEntry {
587 id: "plan:toggle",
588 label: "Toggle plan view / subagents panel (p)",
589 category: "plan",
590 shortcut: Some("p"),
591 command: TuiCommand::PlanToggleView,
592 },
593 ]
594}
595
596fn build_graph_experiment_commands() -> Vec<CommandEntry> {
597 vec![
598 CommandEntry {
599 id: "graph:stats",
600 label: "Show graph memory statistics (/graph)",
601 category: "graph",
602 shortcut: None,
603 command: TuiCommand::GraphStats,
604 },
605 CommandEntry {
606 id: "graph:entities",
607 label: "List graph entities (/graph entities)",
608 category: "graph",
609 shortcut: None,
610 command: TuiCommand::GraphEntities,
611 },
612 CommandEntry {
613 id: "graph:facts",
614 label: "Show entity facts (/graph facts <name>)",
615 category: "graph",
616 shortcut: None,
617 command: TuiCommand::GraphFactsPrompt,
618 },
619 CommandEntry {
620 id: "graph:communities",
621 label: "List graph communities (/graph communities)",
622 category: "graph",
623 shortcut: None,
624 command: TuiCommand::GraphCommunities,
625 },
626 CommandEntry {
627 id: "graph:backfill",
628 label: "Backfill graph from existing messages (/graph backfill)",
629 category: "graph",
630 shortcut: None,
631 command: TuiCommand::GraphBackfillPrompt,
632 },
633 CommandEntry {
634 id: "experiment:start",
635 label: "Start experiment session (/experiment start [N])",
636 category: "experiment",
637 shortcut: None,
638 command: TuiCommand::ExperimentStart,
639 },
640 CommandEntry {
641 id: "experiment:stop",
642 label: "Stop running experiment (/experiment stop)",
643 category: "experiment",
644 shortcut: None,
645 command: TuiCommand::ExperimentStop,
646 },
647 CommandEntry {
648 id: "experiment:status",
649 label: "Show experiment status (/experiment status)",
650 category: "experiment",
651 shortcut: None,
652 command: TuiCommand::ExperimentStatus,
653 },
654 CommandEntry {
655 id: "experiment:report",
656 label: "Show experiment results (/experiment report)",
657 category: "experiment",
658 shortcut: None,
659 command: TuiCommand::ExperimentReport,
660 },
661 CommandEntry {
662 id: "experiment:best",
663 label: "Show best experiment result (/experiment best)",
664 category: "experiment",
665 shortcut: None,
666 command: TuiCommand::ExperimentBest,
667 },
668 CommandEntry {
669 id: "guidelines:view",
670 label: "Show compression guidelines (/guidelines)",
671 category: "memory",
672 shortcut: None,
673 command: TuiCommand::ViewGuidelines,
674 },
675 ]
676}
677
678fn build_extra_commands() -> Vec<CommandEntry> {
679 let mut cmds = build_infra_commands();
680 cmds.extend(build_agent_plan_commands());
681 cmds.extend(build_graph_experiment_commands());
682 cmds.push(CommandEntry {
683 id: "lsp:status",
684 label: "Show LSP context injection status (/lsp)",
685 category: "lsp",
686 shortcut: None,
687 command: TuiCommand::LspStatus,
688 });
689 cmds.push(CommandEntry {
690 id: "acp:dirs",
691 label: "ACP: list allowlisted directories (/acp dirs)",
692 category: "acp",
693 shortcut: None,
694 command: TuiCommand::AcpDirsList,
695 });
696 cmds.push(CommandEntry {
697 id: "acp:auth-methods",
698 label: "ACP: list advertised auth methods (/acp auth-methods)",
699 category: "acp",
700 shortcut: None,
701 command: TuiCommand::AcpAuthMethodsView,
702 });
703 cmds.push(CommandEntry {
704 id: "acp:status",
705 label: "ACP: show runtime status and feature flags (/acp status)",
706 category: "acp",
707 shortcut: None,
708 command: TuiCommand::AcpStatus,
709 });
710 cmds.push(CommandEntry {
711 id: "acp:subagent-spawn",
712 label: "ACP: spawn a sub-agent (/subagent spawn <cmd>)",
713 category: "acp",
714 shortcut: None,
715 command: TuiCommand::SubagentSpawn {
716 command: String::new(),
717 },
718 });
719 cmds
720}
721
722fn fuzzy_score(query: &str, target: &str) -> Option<isize> {
729 if query.is_empty() {
730 return Some(0);
731 }
732 let target_lower: Vec<char> = target.to_lowercase().chars().collect();
733 let query_chars: Vec<char> = query.to_lowercase().chars().collect();
734
735 let mut qi = 0usize;
736 let mut last_match = 0usize;
737 let mut gaps = 0isize;
738
739 for (ti, &tc) in target_lower.iter().enumerate() {
740 if qi < query_chars.len() && tc == query_chars[qi] {
741 if qi > 0 {
742 gaps += ti.cast_signed() - last_match.cast_signed() - 1;
743 }
744 last_match = ti;
745 qi += 1;
746 }
747 }
748
749 if qi == query_chars.len() {
750 Some(query_chars.len().cast_signed() * 10 - gaps)
752 } else {
753 None
754 }
755}
756
757#[must_use]
782pub fn filter_commands(query: &str) -> Vec<&'static CommandEntry> {
783 let mut all: Vec<&'static CommandEntry> = command_registry().iter().collect();
784 all.extend(daemon_command_registry());
785 all.extend(extra_command_registry());
786
787 if query.is_empty() {
788 return all;
789 }
790
791 let mut scored: Vec<(&'static CommandEntry, isize)> = all
792 .into_iter()
793 .filter_map(|e| {
794 let id_score = fuzzy_score(query, e.id);
795 let label_score = fuzzy_score(query, e.label);
796 let best = match (id_score, label_score) {
797 (Some(a), Some(b)) => Some(a.max(b)),
798 (Some(a), None) => Some(a),
799 (None, Some(b)) => Some(b),
800 (None, None) => None,
801 };
802 best.map(|s| (e, s))
803 })
804 .collect();
805
806 scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
807 scored.into_iter().map(|(e, _)| e).collect()
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813
814 #[test]
815 fn registry_has_correct_count() {
816 assert_eq!(command_registry().len(), 20);
817 }
818
819 #[test]
820 fn extra_registry_has_correct_command_count() {
821 assert_eq!(extra_command_registry().len(), 43);
825 }
826
827 #[test]
828 fn filter_commands_includes_extra() {
829 let all = filter_commands("");
830 assert!(all.iter().any(|e| e.id == "view:filters"));
831 assert!(all.iter().any(|e| e.id == "ingest"));
832 assert!(all.iter().any(|e| e.id == "gateway:status"));
833 assert!(all.iter().any(|e| e.id == "scheduler:list"));
834 assert!(all.iter().any(|e| e.id == "security:events"));
835 assert!(all.iter().any(|e| e.id == "log:status"));
836 }
837
838 #[test]
839 fn filter_empty_query_returns_all() {
840 let results = filter_commands("");
841 assert_eq!(
842 results.len(),
843 command_registry().len()
844 + daemon_command_registry().len()
845 + extra_command_registry().len()
846 );
847 }
848
849 #[test]
850 fn filter_by_id_prefix() {
851 let results = filter_commands("skill");
852 assert!(!results.is_empty());
853 assert_eq!(results[0].id, "skill:list");
855 }
856
857 #[test]
858 fn filter_by_label_substring() {
859 let results = filter_commands("memory");
860 assert!(!results.is_empty());
861 assert!(results.iter().any(|e| e.id == "memory:stats"));
862 }
863
864 #[test]
865 fn filter_case_insensitive() {
866 let results = filter_commands("view");
867 assert!(results.len() >= 4);
868 }
869
870 #[test]
871 fn filter_no_match_returns_empty() {
872 let results = filter_commands("xxxxxx");
873 assert!(results.is_empty());
874 }
875
876 #[test]
877 fn filter_partial_label_match() {
878 let results = filter_commands("cost");
879 assert!(!results.is_empty());
880 assert_eq!(results[0].id, "view:cost");
881 }
882
883 #[test]
884 fn filter_mcp_matches_id_and_label() {
885 let results = filter_commands("mcp");
886 assert!(results.iter().any(|e| e.id == "mcp:list"));
887 }
888
889 #[test]
890 fn fuzzy_ranks_skill_list_above_mcp_list_for_sl() {
891 let results = filter_commands("sl");
892 let skill_pos = results.iter().position(|e| e.id == "skill:list");
894 let mcp_pos = results.iter().position(|e| e.id == "mcp:list");
895 assert!(skill_pos.is_some());
896 if let (Some(s), Some(m)) = (skill_pos, mcp_pos) {
897 assert!(
898 s <= m,
899 "skill:list should rank at least as high as mcp:list for 'sl'"
900 );
901 }
902 }
903
904 #[test]
905 fn new_commands_present() {
906 let all = filter_commands("");
907 assert!(all.iter().any(|e| e.id == "app:quit"));
908 assert!(all.iter().any(|e| e.id == "app:help"));
909 assert!(all.iter().any(|e| e.id == "session:new"));
910 assert!(all.iter().any(|e| e.id == "session:history"));
911 assert!(all.iter().any(|e| e.id == "session:next"));
912 assert!(all.iter().any(|e| e.id == "session:prev"));
913 assert!(all.iter().any(|e| e.id == "session:close"));
914 }
915
916 #[test]
917 fn shortcut_on_quit_and_help() {
918 let registry = command_registry();
919 let quit = registry.iter().find(|e| e.id == "app:quit").unwrap();
920 let help = registry.iter().find(|e| e.id == "app:help").unwrap();
921 assert_eq!(quit.shortcut, Some("q"));
922 assert_eq!(help.shortcut, Some("?"));
923 }
924
925 #[test]
926 fn filter_security_returns_security_events_entry() {
927 let results = filter_commands("security");
928 assert!(
929 results.iter().any(|e| e.id == "security:events"),
930 "security:events must appear when searching 'security'"
931 );
932 }
933
934 #[test]
935 fn filter_graph_returns_graph_entries() {
936 let results = filter_commands("graph");
937 assert!(results.iter().any(|e| e.id == "graph:stats"));
938 assert!(results.iter().any(|e| e.id == "graph:entities"));
939 assert!(results.iter().any(|e| e.id == "graph:facts"));
940 assert!(results.iter().any(|e| e.id == "graph:communities"));
941 assert!(results.iter().any(|e| e.id == "graph:backfill"));
942 }
943
944 #[test]
945 fn filter_experiment_returns_experiment_entries() {
946 let results = filter_commands("experiment");
947 assert!(results.iter().any(|e| e.id == "experiment:start"));
948 assert!(results.iter().any(|e| e.id == "experiment:stop"));
949 assert!(results.iter().any(|e| e.id == "experiment:status"));
950 assert!(results.iter().any(|e| e.id == "experiment:report"));
951 assert!(results.iter().any(|e| e.id == "experiment:best"));
952 }
953}