返回 DeepSeek-TUI-2026
mod.rs
根目录 / crates / tui / src / commands / mod.rs
1 //! Slash command registry and dispatch system
2 //!
3 //! This module provides a modular command system inspired by Codex-rs.
4 //! Commands are organized by category and dispatched through a central registry.
5
6 mod attachment;
7 mod config;
8 mod core;
9 mod cycle;
10 mod debug;
11 mod goal;
12 mod hooks;
13 mod init;
14 mod jobs;
15 mod mcp;
16 mod memory;
17 mod network;
18 mod note;
19 mod provider;
20 mod queue;
21 mod rename;
22 mod restore;
23 mod review;
24 mod session;
25 pub mod share;
26 mod skills;
27 mod stash;
28 mod task;
29 mod user_commands;
30
31 use crate::localization::{Locale, MessageId, tr};
32 use crate::tui::app::{App, AppAction};
33
34 /// Result of executing a command
35 #[derive(Debug, Clone)]
36 pub struct CommandResult {
37 /// Optional message to display to the user
38 pub message: Option<String>,
39 /// Optional action for the app to take
40 pub action: Option<AppAction>,
41 /// Whether the command failed.
42 pub is_error: bool,
43 }
44
45 impl CommandResult {
46 /// Create an empty result (command succeeded with no output)
47 pub fn ok() -> Self {
48 Self {
49 message: None,
50 action: None,
51 is_error: false,
52 }
53 }
54
55 /// Create a result with just a message
56 pub fn message(msg: impl Into<String>) -> Self {
57 Self {
58 message: Some(msg.into()),
59 action: None,
60 is_error: false,
61 }
62 }
63
64 /// Create a result with an action
65 pub fn action(action: AppAction) -> Self {
66 Self {
67 message: None,
68 action: Some(action),
69 is_error: false,
70 }
71 }
72
73 /// Create a result with both message and action
74 #[allow(dead_code)]
75 pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self {
76 Self {
77 message: Some(msg.into()),
78 action: Some(action),
79 is_error: false,
80 }
81 }
82
83 /// Create an error message result
84 pub fn error(msg: impl Into<String>) -> Self {
85 Self {
86 message: Some(format!("Error: {}", msg.into())),
87 action: None,
88 is_error: true,
89 }
90 }
91 }
92
93 /// Command metadata for help and autocomplete.
94 ///
95 /// The English description lives in `localization::english` (private), keyed
96 /// by `description_id`. Callers resolve a localized description through
97 /// [`CommandInfo::description_for`] which delegates to
98 /// [`crate::localization::tr`].
99 #[derive(Debug, Clone, Copy)]
100 pub struct CommandInfo {
101 pub name: &'static str,
102 pub aliases: &'static [&'static str],
103 pub usage: &'static str,
104 pub description_id: MessageId,
105 }
106
107 impl CommandInfo {
108 pub fn requires_argument(&self) -> bool {
109 self.usage.contains('<') || self.usage.contains('[')
110 }
111
112 pub fn palette_command(&self) -> String {
113 if self.requires_argument() {
114 format!("/{} ", self.name)
115 } else {
116 format!("/{}", self.name)
117 }
118 }
119
120 pub fn description_for(&self, locale: Locale) -> &'static str {
121 tr(locale, self.description_id)
122 }
123
124 pub fn palette_description_for(&self, locale: Locale) -> String {
125 let desc = self.description_for(locale);
126 if self.aliases.is_empty() {
127 desc.to_string()
128 } else {
129 format!("{} aliases: {}", desc, self.aliases.join(", "))
130 }
131 }
132 }
133
134 /// All registered commands
135 pub const COMMANDS: &[CommandInfo] = &[
136 // Core commands
137 CommandInfo {
138 name: "help",
139 aliases: &["?"],
140 usage: "/help [command]",
141 description_id: MessageId::CmdHelpDescription,
142 },
143 CommandInfo {
144 name: "clear",
145 aliases: &[],
146 usage: "/clear",
147 description_id: MessageId::CmdClearDescription,
148 },
149 CommandInfo {
150 name: "exit",
151 aliases: &["quit", "q"],
152 usage: "/exit",
153 description_id: MessageId::CmdExitDescription,
154 },
155 CommandInfo {
156 name: "model",
157 aliases: &[],
158 usage: "/model [name]",
159 description_id: MessageId::CmdModelDescription,
160 },
161 CommandInfo {
162 name: "models",
163 aliases: &[],
164 usage: "/models",
165 description_id: MessageId::CmdModelsDescription,
166 },
167 CommandInfo {
168 name: "provider",
169 aliases: &[],
170 usage: "/provider [name]",
171 description_id: MessageId::CmdProviderDescription,
172 },
173 CommandInfo {
174 name: "queue",
175 aliases: &["queued"],
176 usage: "/queue [list|edit <n>|drop <n>|clear]",
177 description_id: MessageId::CmdQueueDescription,
178 },
179 CommandInfo {
180 name: "stash",
181 aliases: &["park"],
182 usage: "/stash [list|pop|clear]",
183 description_id: MessageId::CmdStashDescription,
184 },
185 CommandInfo {
186 name: "hooks",
187 aliases: &["hook"],
188 usage: "/hooks [list|events]",
189 description_id: MessageId::CmdHooksDescription,
190 },
191 CommandInfo {
192 name: "subagents",
193 aliases: &["agents"],
194 usage: "/subagents",
195 description_id: MessageId::CmdSubagentsDescription,
196 },
197 CommandInfo {
198 name: "links",
199 aliases: &["dashboard", "api"],
200 usage: "/links",
201 description_id: MessageId::CmdLinksDescription,
202 },
203 CommandInfo {
204 name: "home",
205 aliases: &["stats", "overview"],
206 usage: "/home",
207 description_id: MessageId::CmdHomeDescription,
208 },
209 CommandInfo {
210 name: "note",
211 aliases: &[],
212 usage: "/note <text>",
213 description_id: MessageId::CmdNoteDescription,
214 },
215 CommandInfo {
216 name: "memory",
217 aliases: &[],
218 usage: "/memory [show|path|clear|edit|help]",
219 description_id: MessageId::CmdMemoryDescription,
220 },
221 CommandInfo {
222 name: "attach",
223 aliases: &["image", "media"],
224 usage: "/attach <path>",
225 description_id: MessageId::CmdAttachDescription,
226 },
227 CommandInfo {
228 name: "task",
229 aliases: &["tasks"],
230 usage: "/task [add <prompt>|list|show <id>|cancel <id>]",
231 description_id: MessageId::CmdTaskDescription,
232 },
233 CommandInfo {
234 name: "jobs",
235 aliases: &["job"],
236 usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id>]",
237 description_id: MessageId::CmdJobsDescription,
238 },
239 CommandInfo {
240 name: "mcp",
241 aliases: &[],
242 usage: "/mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]",
243 description_id: MessageId::CmdMcpDescription,
244 },
245 CommandInfo {
246 name: "network",
247 aliases: &[],
248 usage: "/network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]",
249 description_id: MessageId::CmdNetworkDescription,
250 },
251 // Session commands
252 CommandInfo {
253 name: "rename",
254 aliases: &[],
255 usage: "/rename <new title>",
256 description_id: MessageId::CmdRenameDescription,
257 },
258 CommandInfo {
259 name: "save",
260 aliases: &[],
261 usage: "/save [path]",
262 description_id: MessageId::CmdSaveDescription,
263 },
264 CommandInfo {
265 name: "sessions",
266 aliases: &["resume"],
267 usage: "/sessions [show|prune <days>]",
268 description_id: MessageId::CmdSessionsDescription,
269 },
270 CommandInfo {
271 name: "load",
272 aliases: &[],
273 usage: "/load [path]",
274 description_id: MessageId::CmdLoadDescription,
275 },
276 CommandInfo {
277 name: "compact",
278 aliases: &[],
279 usage: "/compact",
280 description_id: MessageId::CmdCompactDescription,
281 },
282 CommandInfo {
283 name: "context",
284 aliases: &["ctx"],
285 usage: "/context",
286 description_id: MessageId::CmdContextDescription,
287 },
288 CommandInfo {
289 name: "cycles",
290 aliases: &[],
291 usage: "/cycles",
292 description_id: MessageId::CmdCyclesDescription,
293 },
294 CommandInfo {
295 name: "cycle",
296 aliases: &[],
297 usage: "/cycle <n>",
298 description_id: MessageId::CmdCycleDescription,
299 },
300 CommandInfo {
301 name: "recall",
302 aliases: &[],
303 usage: "/recall <query>",
304 description_id: MessageId::CmdRecallDescription,
305 },
306 CommandInfo {
307 name: "export",
308 aliases: &[],
309 usage: "/export [path]",
310 description_id: MessageId::CmdExportDescription,
311 },
312 // Config commands
313 CommandInfo {
314 name: "config",
315 aliases: &[],
316 usage: "/config",
317 description_id: MessageId::CmdConfigDescription,
318 },
319 CommandInfo {
320 name: "yolo",
321 aliases: &[],
322 usage: "/yolo",
323 description_id: MessageId::CmdYoloDescription,
324 },
325 CommandInfo {
326 name: "agent",
327 aliases: &[],
328 usage: "/agent",
329 description_id: MessageId::CmdAgentDescription,
330 },
331 CommandInfo {
332 name: "plan",
333 aliases: &[],
334 usage: "/plan",
335 description_id: MessageId::CmdPlanDescription,
336 },
337 CommandInfo {
338 name: "trust",
339 aliases: &[],
340 usage: "/trust [on|off|add <path>|remove <path>|list]",
341 description_id: MessageId::CmdTrustDescription,
342 },
343 CommandInfo {
344 name: "logout",
345 aliases: &[],
346 usage: "/logout",
347 description_id: MessageId::CmdLogoutDescription,
348 },
349 // Debug commands
350 CommandInfo {
351 name: "tokens",
352 aliases: &[],
353 usage: "/tokens",
354 description_id: MessageId::CmdTokensDescription,
355 },
356 CommandInfo {
357 name: "system",
358 aliases: &[],
359 usage: "/system",
360 description_id: MessageId::CmdSystemDescription,
361 },
362 CommandInfo {
363 name: "edit",
364 aliases: &[],
365 usage: "/edit",
366 description_id: MessageId::CmdEditDescription,
367 },
368 CommandInfo {
369 name: "diff",
370 aliases: &[],
371 usage: "/diff",
372 description_id: MessageId::CmdDiffDescription,
373 },
374 CommandInfo {
375 name: "undo",
376 aliases: &[],
377 usage: "/undo",
378 description_id: MessageId::CmdUndoDescription,
379 },
380 CommandInfo {
381 name: "retry",
382 aliases: &[],
383 usage: "/retry",
384 description_id: MessageId::CmdRetryDescription,
385 },
386 CommandInfo {
387 name: "init",
388 aliases: &[],
389 usage: "/init",
390 description_id: MessageId::CmdInitDescription,
391 },
392 CommandInfo {
393 name: "lsp",
394 aliases: &[],
395 usage: "/lsp [on|off|status]",
396 description_id: MessageId::CmdLspDescription,
397 },
398 CommandInfo {
399 name: "share",
400 aliases: &[],
401 usage: "/share",
402 description_id: MessageId::CmdShareDescription,
403 },
404 CommandInfo {
405 name: "goal",
406 aliases: &[],
407 usage: "/goal [objective] [budget: N]",
408 description_id: MessageId::CmdGoalDescription,
409 },
410 CommandInfo {
411 name: "settings",
412 aliases: &[],
413 usage: "/settings",
414 description_id: MessageId::CmdSettingsDescription,
415 },
416 CommandInfo {
417 name: "statusline",
418 aliases: &["status"],
419 usage: "/statusline",
420 description_id: MessageId::CmdStatuslineDescription,
421 },
422 // Skills commands
423 CommandInfo {
424 name: "skills",
425 aliases: &[],
426 usage: "/skills [--remote|sync]",
427 description_id: MessageId::CmdSkillsDescription,
428 },
429 CommandInfo {
430 name: "skill",
431 aliases: &[],
432 usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>",
433 description_id: MessageId::CmdSkillDescription,
434 },
435 CommandInfo {
436 name: "review",
437 aliases: &[],
438 usage: "/review <target>",
439 description_id: MessageId::CmdReviewDescription,
440 },
441 CommandInfo {
442 name: "restore",
443 aliases: &[],
444 usage: "/restore [N]",
445 description_id: MessageId::CmdRestoreDescription,
446 },
447 // RLM command
448 CommandInfo {
449 name: "rlm",
450 aliases: &["recursive"],
451 usage: "/rlm <prompt>",
452 description_id: MessageId::CmdRlmDescription,
453 },
454 // Debug/cost command
455 CommandInfo {
456 name: "cost",
457 aliases: &[],
458 usage: "/cost",
459 description_id: MessageId::CmdCostDescription,
460 },
461 // Profile switching (#390)
462 CommandInfo {
463 name: "profile",
464 aliases: &[],
465 usage: "/profile <name>",
466 description_id: MessageId::CmdHelpDescription, // reuse for now
467 },
468 // Cache telemetry (#263)
469 CommandInfo {
470 name: "cache",
471 aliases: &[],
472 usage: "/cache [count]",
473 description_id: MessageId::CmdCacheDescription,
474 },
475 ];
476
477 /// Execute a slash command
478 pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
479 let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect();
480 let command = parts[0].to_lowercase();
481 let command = command.strip_prefix('/').unwrap_or(&command);
482 let arg = parts.get(1).map(|s| s.trim());
483
484 // Check user-defined commands FIRST so they can override built-ins.
485 if let Some(result) = user_commands::try_dispatch_user_command(app, cmd.trim()) {
486 return result;
487 }
488
489 // Match command or alias
490 match command {
491 // Core commands
492 "help" | "?" => core::help(app, arg),
493 "clear" => core::clear(app),
494 "exit" | "quit" | "q" => core::exit(),
495 "model" => core::model(app, arg),
496 "models" => core::models(app),
497 "provider" => provider::provider(app, arg),
498 "queue" | "queued" => queue::queue(app, arg),
499 "stash" | "park" => stash::stash(app, arg),
500 "hooks" | "hook" => hooks::hooks(app, arg),
501 "subagents" | "agents" => core::subagents(app),
502 "links" | "dashboard" | "api" => core::deepseek_links(app),
503 "home" | "stats" | "overview" => core::home_dashboard(app),
504 "note" => note::note(app, arg),
505 "memory" => memory::memory(app, arg),
506 "attach" | "image" | "media" => attachment::attach(app, arg),
507 "task" | "tasks" => task::task(app, arg),
508 "jobs" | "job" => jobs::jobs(app, arg),
509 "mcp" => mcp::mcp(app, arg),
510 "network" => network::network(app, arg),
511
512 // Session commands
513 "rename" => rename::rename(app, arg),
514 "save" => session::save(app, arg),
515 "sessions" | "resume" => session::sessions(app, arg),
516 "load" => session::load(app, arg),
517 "compact" => session::compact(app),
518 "cycles" => cycle::list_cycles(app),
519 "cycle" => cycle::show_cycle(app, arg),
520 "recall" => cycle::recall_archive(app, arg),
521 "export" => session::export(app, arg),
522
523 // Config commands
524 "config" => config::config_command(app, arg),
525 "settings" => config::show_settings(app),
526 "statusline" | "status" => config::status_line(app),
527 "yolo" => config::yolo(app),
528 "agent" => config::agent_mode(app),
529 "plan" => config::plan_mode(app),
530 "trust" => config::trust(app, arg),
531 "logout" => config::logout(app),
532
533 // Debug commands
534 "tokens" => debug::tokens(app),
535 "cost" => debug::cost(app),
536 "cache" => debug::cache(app, arg),
537 "system" => debug::system_prompt(app),
538 "context" | "ctx" => debug::context(app),
539 "edit" => debug::edit(app),
540 "diff" => debug::diff(app),
541 "undo" => {
542 // Try surgical patch-undo first; fall back to conversation undo
543 // if no snapshots are available or if the snapshot undo couldn't
544 // find anything useful.
545 let result = debug::patch_undo(app);
546 if result.message.as_deref().is_none_or(|m| {
547 m.starts_with("No snapshots found")
548 || m.starts_with("No tool or pre-turn")
549 || m.starts_with("Snapshot repo")
550 }) {
551 debug::undo_conversation(app)
552 } else {
553 result
554 }
555 }
556 "retry" => debug::retry(app),
557
558 // Project commands
559 "init" => init::init(app),
560 "lsp" => config::lsp_command(app, arg),
561 "share" => share::share(app, arg),
562 "goal" => goal::goal(app, arg),
563
564 // Skills commands
565 "skills" => skills::list_skills(app, arg),
566 "skill" => skills::run_skill(app, arg),
567 "review" => review::review(app, arg),
568 "restore" => restore::restore(app, arg),
569
570 // Profile switch (#390)
571 "profile" => core::profile_switch(app, arg),
572
573 // RLM command
574 "rlm" | "recursive" => rlm(app, arg),
575
576 // Legacy command migrations (kept out of registry/autocomplete intentionally).
577 "set" => CommandResult::error(
578 "The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
579 ),
580 "normal" => config::normal_mode(app),
581 "deepseek" => CommandResult::error(
582 "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
583 ),
584
585 _ => {
586 // Third source: skills (lowest precedence after native and user-config).
587 // Try to run a skill whose name matches the command.
588 if skills::run_skill_by_name(app, command, arg).is_some() {
589 return skills::run_skill_by_name(app, command, arg).unwrap();
590 }
591 let suggestions = suggest_command_names(command, 3);
592 if suggestions.is_empty() {
593 CommandResult::error(format!(
594 "Unknown command: /{command}. Type /help for available commands."
595 ))
596 } else {
597 let list = suggestions
598 .into_iter()
599 .map(|name| format!("/{name}"))
600 .collect::<Vec<_>>()
601 .join(", ");
602 CommandResult::error(format!(
603 "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands."
604 ))
605 }
606 }
607 }
608 }
609
610 /// Update a configuration value programmatically (used by interactive UI views).
611 pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
612 config::set_config_value(app, key, value, persist)
613 }
614
615 /// Persist the user's chosen footer items to `~/.deepseek/config.toml` under
616 /// `tui.status_items`. See [`config::persist_status_items`] for details.
617 pub fn persist_status_items(
618 items: &[crate::config::StatusItem],
619 ) -> anyhow::Result<std::path::PathBuf> {
620 config::persist_status_items(items)
621 }
622
623 /// Persist a root-level string key in `config.toml`.
624 pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<std::path::PathBuf> {
625 config::persist_root_string_key(key, value)
626 }
627
628 /// Auto-select a model based on request complexity.
629 pub fn auto_model_heuristic(input: &str, current_model: &str) -> String {
630 config::auto_model_heuristic(input, current_model)
631 }
632
633 pub use config::{
634 AutoRouteRecommendation, AutoRouteSelection, normalize_auto_route_effort,
635 parse_auto_route_recommendation, resolve_auto_route_with_flash,
636 };
637
638 /// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from
639 /// Zhang et al. (arXiv:2512.24601).
640 ///
641 /// The user's prompt text is passed as the argument. It will be stored
642 /// in the REPL as the `PROMPT` variable. The root LLM will only see
643 /// metadata about the REPL state, never the prompt text directly.
644 pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult {
645 let prompt = match arg {
646 Some(p) if !p.trim().is_empty() => p.trim().to_string(),
647 _ => {
648 return CommandResult::error(
649 "Usage: /rlm <prompt>\n\n\
650 Process a prompt using a Recursive Language Model (RLM).\n\
651 The prompt is stored in a REPL and the model writes code\n\
652 to decompose and process it recursively."
653 .to_string(),
654 );
655 }
656 };
657
658 // Sanity-check: RLM is most useful for longer prompts.
659 if prompt.len() < 50 {
660 return CommandResult::message(
661 "Tip: RLM is designed for processing LONG prompts (>100 chars). \
662 For short queries, just type the message directly."
663 .to_string(),
664 );
665 }
666
667 let model = app.model.clone();
668 let child_model = "deepseek-v4-flash".to_string();
669 // Paper experiments use depth=1 (one level of `sub_rlm`); we default to
670 // depth=2 so the model can recurse twice if it chooses to.
671 let max_depth: u32 = 2;
672
673 CommandResult::with_message_and_action(
674 format!(
675 "Starting RLM turn for {} chars of prompt using {} (child={}, depth={})...",
676 prompt.len(),
677 model,
678 child_model,
679 max_depth,
680 ),
681 AppAction::Rlm {
682 prompt,
683 model,
684 child_model,
685 max_depth,
686 },
687 )
688 }
689
690 /// Get command info by name or alias
691 pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
692 let name = name.strip_prefix('/').unwrap_or(name);
693 COMMANDS
694 .iter()
695 .find(|cmd| cmd.name == name || cmd.aliases.contains(&name))
696 }
697
698 /// Get all command names matching a prefix, including both built-in
699 /// static commands and user-defined commands, formatted as `/name`.
700 pub fn all_command_names_matching(prefix: &str) -> Vec<String> {
701 let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
702 let mut result: Vec<String> = COMMANDS
703 .iter()
704 .filter(|cmd| {
705 cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
706 })
707 .map(|cmd| format!("/{}", cmd.name))
708 .collect();
709
710 // Add user-defined commands
711 result.extend(user_commands::user_commands_matching(&prefix));
712
713 result.sort();
714 result.dedup();
715 result
716 }
717
718 /// Get all commands matching a prefix (for autocomplete)
719 #[allow(dead_code)]
720 pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
721 let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
722 COMMANDS
723 .iter()
724 .filter(|cmd| {
725 cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
726 })
727 .collect()
728 }
729
730 fn edit_distance(a: &str, b: &str) -> usize {
731 if a == b {
732 return 0;
733 }
734 if a.is_empty() {
735 return b.chars().count();
736 }
737 if b.is_empty() {
738 return a.chars().count();
739 }
740
741 let b_chars: Vec<char> = b.chars().collect();
742 let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
743 let mut curr = vec![0usize; b_chars.len() + 1];
744
745 for (i, a_ch) in a.chars().enumerate() {
746 curr[0] = i + 1;
747 for (j, b_ch) in b_chars.iter().enumerate() {
748 let cost = if a_ch == *b_ch { 0 } else { 1 };
749 let delete = prev[j + 1] + 1;
750 let insert = curr[j] + 1;
751 let substitute = prev[j] + cost;
752 curr[j + 1] = delete.min(insert).min(substitute);
753 }
754 std::mem::swap(&mut prev, &mut curr);
755 }
756
757 prev[b_chars.len()]
758 }
759
760 fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
761 let query = input.trim().to_ascii_lowercase();
762 if query.is_empty() || limit == 0 {
763 return Vec::new();
764 }
765
766 let mut scored: Vec<(u8, usize, String)> = Vec::new();
767 for command in COMMANDS {
768 let mut best: Option<(u8, usize)> = None;
769 for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) {
770 let candidate = candidate.to_ascii_lowercase();
771 let prefix_match = candidate.starts_with(&query) || query.starts_with(&candidate);
772 let contains_match = candidate.contains(&query) || query.contains(&candidate);
773 let distance = edit_distance(&candidate, &query);
774 let close_typo = distance <= 2;
775 if !(prefix_match || contains_match || close_typo) {
776 continue;
777 }
778
779 let rank = if prefix_match {
780 0
781 } else if contains_match {
782 1
783 } else {
784 2
785 };
786
787 match best {
788 Some((best_rank, best_distance))
789 if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
790 _ => best = Some((rank, distance)),
791 }
792 }
793
794 if let Some((rank, distance)) = best {
795 scored.push((rank, distance, command.name.to_string()));
796 }
797 }
798
799 scored.sort_by(|a, b| {
800 a.0.cmp(&b.0)
801 .then_with(|| a.1.cmp(&b.1))
802 .then_with(|| a.2.cmp(&b.2))
803 });
804 scored
805 .into_iter()
806 .take(limit)
807 .map(|(_, _, name)| name)
808 .collect()
809 }
810
811 #[cfg(test)]
812 mod tests {
813 use super::*;
814 use crate::config::Config;
815 use crate::tui::app::{App, AppAction, TuiOptions};
816 use std::path::PathBuf;
817
818 fn create_test_app() -> App {
819 let options = TuiOptions {
820 model: "deepseek-v4-pro".to_string(),
821 workspace: PathBuf::from("."),
822 config_path: None,
823 config_profile: None,
824 allow_shell: false,
825 use_alt_screen: true,
826 use_mouse_capture: false,
827 use_bracketed_paste: true,
828 max_subagents: 1,
829 skills_dir: PathBuf::from("."),
830 memory_path: PathBuf::from("memory.md"),
831 notes_path: PathBuf::from("notes.txt"),
832 mcp_config_path: PathBuf::from("mcp.json"),
833 use_memory: false,
834 start_in_agent_mode: false,
835 skip_onboarding: true,
836 yolo: false,
837 resume_session_id: None,
838 initial_input: None,
839 };
840 App::new(options, &Config::default())
841 }
842
843 #[test]
844 fn command_registry_contains_config_and_links_but_not_set_or_deepseek() {
845 assert!(COMMANDS.iter().any(|cmd| cmd.name == "config"));
846 assert!(COMMANDS.iter().any(|cmd| cmd.name == "links"));
847 assert!(COMMANDS.iter().any(|cmd| cmd.name == "memory"));
848 assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set"));
849 assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek"));
850 }
851
852 #[test]
853 fn links_command_has_dashboard_and_api_aliases() {
854 let links = COMMANDS
855 .iter()
856 .find(|cmd| cmd.name == "links")
857 .expect("links command should exist");
858 assert_eq!(links.aliases, &["dashboard", "api"]);
859 }
860
861 #[test]
862 fn command_registry_has_unique_names_and_aliases() {
863 let mut names = std::collections::BTreeSet::new();
864 for command in COMMANDS {
865 assert!(
866 names.insert(command.name),
867 "duplicate command name /{}",
868 command.name
869 );
870 }
871
872 let mut aliases = std::collections::BTreeSet::new();
873 for command in COMMANDS {
874 for alias in command.aliases {
875 assert!(
876 !names.contains(alias),
877 "alias /{} collides with a command name",
878 alias
879 );
880 assert!(aliases.insert(*alias), "duplicate command alias /{alias}");
881 }
882 }
883 }
884
885 #[test]
886 fn context_command_opens_inspector_and_keeps_ctx_alias() {
887 let context = COMMANDS
888 .iter()
889 .find(|cmd| cmd.name == "context")
890 .expect("context command should exist");
891 assert_eq!(context.aliases, &["ctx"]);
892 assert!(context.description_for(Locale::En).contains("inspector"));
893
894 let mut app = create_test_app();
895 let result = execute("/ctx", &mut app);
896 assert!(matches!(
897 result.action,
898 Some(AppAction::OpenContextInspector)
899 ));
900 }
901
902 #[test]
903 fn execute_config_opens_config_view_action() {
904 let mut app = create_test_app();
905 let result = execute("/config", &mut app);
906 assert!(result.message.is_none());
907 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
908 }
909
910 #[test]
911 fn execute_links_and_aliases_return_links_message() {
912 let mut app = create_test_app();
913 for cmd in ["/links", "/dashboard", "/api"] {
914 let result = execute(cmd, &mut app);
915 let msg = result.message.expect("links commands should return text");
916 assert!(msg.contains("https://platform.deepseek.com"));
917 assert!(result.action.is_none());
918 }
919 }
920
921 #[test]
922 fn removed_set_and_deepseek_commands_show_migration_hints() {
923 let mut app = create_test_app();
924 let set_result = execute("/set model deepseek-v4-pro", &mut app);
925 let set_msg = set_result
926 .message
927 .expect("legacy command should return an error message");
928 assert!(set_msg.contains("The /set command was retired"));
929 assert!(set_msg.contains("/config"));
930 assert!(set_msg.contains("/settings"));
931 assert!(set_result.action.is_none());
932
933 let deepseek_result = execute("/deepseek", &mut app);
934 let deepseek_msg = deepseek_result
935 .message
936 .expect("legacy command should return an error message");
937 assert!(deepseek_msg.contains("The /deepseek command was renamed"));
938 assert!(deepseek_msg.contains("/links"));
939 assert!(deepseek_msg.contains("/dashboard"));
940 assert!(deepseek_msg.contains("/api"));
941 assert!(deepseek_result.action.is_none());
942 }
943
944 /// Build an App scoped to an isolated tempdir so dispatch-side-effects
945 /// (e.g. `/init` writing AGENTS.md, `/export` writing chat transcripts)
946 /// don't pollute the repo working tree when the smoke tests run.
947 fn create_isolated_test_app() -> (App, tempfile::TempDir) {
948 let tmpdir = tempfile::TempDir::new().expect("tempdir for smoke test");
949 let workspace = tmpdir.path().to_path_buf();
950 let options = TuiOptions {
951 model: "deepseek-v4-pro".to_string(),
952 workspace: workspace.clone(),
953 config_path: None,
954 config_profile: None,
955 allow_shell: false,
956 use_alt_screen: true,
957 use_mouse_capture: false,
958 use_bracketed_paste: true,
959 max_subagents: 1,
960 skills_dir: workspace.join("skills"),
961 memory_path: workspace.join("memory.md"),
962 notes_path: workspace.join("notes.txt"),
963 mcp_config_path: workspace.join("mcp.json"),
964 use_memory: false,
965 start_in_agent_mode: false,
966 skip_onboarding: true,
967 yolo: false,
968 resume_session_id: None,
969 initial_input: None,
970 };
971 let app = App::new(options, &Config::default());
972 (app, tmpdir)
973 }
974
975 /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler.
976 /// A dispatch miss surfaces as the fall-through `Unknown command:` error
977 /// message in `execute`. This catches the case where a new command is
978 /// added to `COMMANDS` (so it shows up in `/help` and the palette) but
979 /// the matching arm in `execute` is forgotten — the user would type the
980 /// command, see it autocomplete, and then get an unhelpful "did you
981 /// mean" suggestion. Also catches panics in handlers because the test
982 /// runner unwinds the panic and reports the offending command.
983 /// `/save` and `/export` default their output paths to `cwd`-relative
984 /// filenames when no arg is supplied, which would scribble files into
985 /// `crates/tui/` when CI runs from there. Pass an explicit tempdir-
986 /// relative path for those two so the dispatch test stays sandboxed.
987 fn invocation_for(command_name: &str, alias_or_name: &str, tmpdir: &std::path::Path) -> String {
988 match command_name {
989 "save" => format!("/{alias_or_name} {}", tmpdir.join("session.json").display()),
990 "export" => format!("/{alias_or_name} {}", tmpdir.join("chat.md").display()),
991 _ => format!("/{alias_or_name}"),
992 }
993 }
994
995 /// `/restore` is covered by its own dedicated tests in
996 /// `commands/restore.rs` that serialize on the global env mutex via
997 /// `scoped_home` (snapshot repo init shells out to git, which races
998 /// against parallel-running tests). Skip it here so this smoke test
999 /// stays parallel-safe.
1000 fn skip_in_dispatch_smoke(name: &str) -> bool {
1001 name == "restore"
1002 }
1003
1004 /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler.
1005 /// A dispatch miss surfaces as the fall-through `Unknown command:` error
1006 /// message in `execute`. This catches the case where a new command is
1007 /// added to `COMMANDS` (so it shows up in `/help` and the palette) but
1008 /// the matching arm in `execute` is forgotten — the user would type the
1009 /// command, see it autocomplete, and then get an unhelpful "did you
1010 /// mean" suggestion. Also catches panics in handlers because the test
1011 /// runner unwinds the panic and reports the offending command.
1012 #[test]
1013 fn every_registered_command_dispatches_to_a_handler() {
1014 for command in COMMANDS {
1015 if skip_in_dispatch_smoke(command.name) {
1016 continue;
1017 }
1018 let (mut app, tmpdir) = create_isolated_test_app();
1019 let invocation = invocation_for(command.name, command.name, tmpdir.path());
1020 let result = execute(&invocation, &mut app);
1021 if let Some(msg) = &result.message {
1022 assert!(
1023 !msg.contains("Unknown command"),
1024 "/{} fell through to the unknown-command branch: {msg}",
1025 command.name,
1026 );
1027 }
1028 }
1029 }
1030
1031 /// Same check, but for declared aliases — `/q` should not fall through
1032 /// just because the registry lists it as an alias of `/exit`.
1033 #[test]
1034 fn every_command_alias_dispatches_to_a_handler() {
1035 for command in COMMANDS {
1036 if skip_in_dispatch_smoke(command.name) {
1037 continue;
1038 }
1039 for alias in command.aliases {
1040 let (mut app, tmpdir) = create_isolated_test_app();
1041 let invocation = invocation_for(command.name, alias, tmpdir.path());
1042 let result = execute(&invocation, &mut app);
1043 if let Some(msg) = &result.message {
1044 assert!(
1045 !msg.contains("Unknown command"),
1046 "/{alias} (alias of /{}) fell through to unknown: {msg}",
1047 command.name,
1048 );
1049 }
1050 }
1051 }
1052 }
1053
1054 #[test]
1055 fn unknown_command_suggests_nearest_match() {
1056 let mut app = create_test_app();
1057 let result = execute("/modle", &mut app);
1058 let msg = result
1059 .message
1060 .expect("unknown command should return an error message");
1061 assert!(msg.contains("Unknown command: /modle"));
1062 assert!(msg.contains("Did you mean:"));
1063 assert!(msg.contains("/model"));
1064 }
1065
1066 #[test]
1067 fn unknown_command_without_close_match_keeps_help_guidance() {
1068 let mut app = create_test_app();
1069 let result = execute("/zzzzzz", &mut app);
1070 let msg = result
1071 .message
1072 .expect("unknown command should return an error message");
1073 assert!(msg.contains("Unknown command: /zzzzzz"));
1074 assert!(msg.contains("Type /help for available commands."));
1075 }
1076 }
1077
1077 lines RUST