返回 CodeWhale
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 strategy
5 //! registry. Built-in handlers live in group-owned areas under [`groups`]; this
6 //! module keeps registry construction, user-command precedence, and the
7 //! fall-through behaviour.
8
9 mod groups;
10 pub mod traits;
11 pub mod user_commands;
12 pub mod user_registry;
13
14 #[cfg(test)]
15 #[path = "epic_dispatch_acceptance.rs"]
16 mod epic_dispatch_acceptance;
17
18 use std::sync::OnceLock;
19
20 pub use traits::CommandInfo;
21
22 // Long-standing public paths that predate the group layout.
23 pub use groups::project::share;
24 #[cfg(test)]
25 pub(crate) use groups::session::rename_with_manager as rename_session_with_manager;
26
27 // Voice capture plumbing shared with the hotbar and the UI event loop.
28 pub use groups::core::voice;
29
30 use crate::tui::app::{App, AppAction};
31
32 /// Result of executing a command
33 #[derive(Debug, Clone)]
34 pub struct CommandResult {
35 /// Optional message to display to the user
36 pub message: Option<String>,
37 /// Optional action for the app to take
38 pub action: Option<AppAction>,
39 /// Whether the command failed.
40 pub is_error: bool,
41 }
42
43 impl CommandResult {
44 /// Create an empty result (command succeeded with no output)
45 pub fn ok() -> Self {
46 Self {
47 message: None,
48 action: None,
49 is_error: false,
50 }
51 }
52
53 /// Create a result with just a message
54 pub fn message(msg: impl Into<String>) -> Self {
55 Self {
56 message: Some(msg.into()),
57 action: None,
58 is_error: false,
59 }
60 }
61
62 /// Create a result with an action
63 pub fn action(action: AppAction) -> Self {
64 Self {
65 message: None,
66 action: Some(action),
67 is_error: false,
68 }
69 }
70
71 /// Create a result with both message and action
72 pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self {
73 Self {
74 message: Some(msg.into()),
75 action: Some(action),
76 is_error: false,
77 }
78 }
79
80 /// Create an error message result
81 pub fn error(msg: impl Into<String>) -> Self {
82 Self {
83 message: Some(format!("Error: {}", msg.into())),
84 action: None,
85 is_error: true,
86 }
87 }
88 }
89
90 static REGISTRY: OnceLock<traits::CommandRegistry> = OnceLock::new();
91
92 fn build_registry() -> traits::CommandRegistry {
93 let mut registry = traits::CommandRegistry::empty();
94 for &group in groups::all_command_groups() {
95 registry.register_group(group);
96 }
97 registry
98 }
99
100 pub fn registry() -> &'static traits::CommandRegistry {
101 REGISTRY.get_or_init(build_registry)
102 }
103
104 pub fn command_infos() -> Vec<&'static CommandInfo> {
105 registry().infos()
106 }
107
108 pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
109 registry().get_info(name)
110 }
111
112 /// Execute a slash command
113 pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
114 // Keep the command's raw remainder available for commands whose payload is
115 // byte-sensitive. Most slash commands intentionally receive a normalized
116 // argument below; `/preview-request --prompt`, however, must describe the
117 // exact prompt the send path would receive, including trailing whitespace
118 // and newlines.
119 let dispatch_input = cmd.trim_start();
120 let command_token_end = dispatch_input
121 .find(char::is_whitespace)
122 .unwrap_or(dispatch_input.len());
123 let raw_remainder = &dispatch_input[command_token_end..];
124 let trimmed = cmd.trim();
125
126 // `$skillname` is a backward-compatible alias for `/skill skillname`.
127 // Resolve it early so skills can be loaded with the `$` prefix.
128 if let Some(skill_input) = trimmed.strip_prefix('$') {
129 let skill_input = skill_input.trim_start();
130 if skill_input.is_empty() {
131 return CommandResult::error(
132 "Type a skill name after $. For example: $getting-started",
133 );
134 }
135 let parts: Vec<&str> = skill_input.splitn(2, char::is_whitespace).collect();
136 let skill_name = parts.first().copied().unwrap_or("");
137 let arg = parts
138 .get(1)
139 .map(|value| value.trim())
140 .filter(|value| !value.is_empty());
141 if let Some(result) = groups::skills::run_skill_by_name(app, skill_name, arg) {
142 return result;
143 }
144 return CommandResult::error(format!(
145 "Unknown skill: ${skill_name}. Type /skills to see installed skills."
146 ));
147 }
148
149 let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
150 let command = parts
151 .first()
152 .copied()
153 .unwrap_or_default()
154 .trim_start_matches('/')
155 .to_ascii_lowercase();
156 let arg = parts
157 .get(1)
158 .map(|value| value.trim())
159 .filter(|value| !value.is_empty());
160
161 // Check user-defined commands FIRST so they can override built-ins.
162 if let Some(result) = user_registry::try_dispatch(app, trimmed) {
163 return result;
164 }
165
166 // Permanent backward-compatible mode aliases. They select a fixed mode
167 // rather than the canonical `/mode` behavior, so they still dispatch
168 // before registry lookup. Ordinary compatibility aliases belong in their
169 // command's `CommandInfo` metadata.
170 match command.as_str() {
171 "jihua" => {
172 return groups::config::dispatch(app, "jihua", arg).unwrap_or_else(|| {
173 CommandResult::error("The /jihua alias could not be dispatched.")
174 });
175 }
176 "zidong" => {
177 return groups::config::dispatch(app, "zidong", arg).unwrap_or_else(|| {
178 CommandResult::error("The /zidong alias could not be dispatched.")
179 });
180 }
181 _ => {}
182 }
183
184 if let Some(command_object) = registry().get(command.as_str()) {
185 let command_arg = if command_object.info().name == "preview-request" {
186 Some(raw_remainder)
187 } else {
188 arg
189 };
190 return command_object.execute(app, command_arg);
191 }
192
193 match command.as_str() {
194 // Permanent legacy migration hints. These are deliberately excluded
195 // from registry/autocomplete and only appear when users type old names.
196 "set" => CommandResult::error(
197 "The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
198 ),
199 "deepseek" => CommandResult::error(
200 "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
201 ),
202 "doctor" => CommandResult::error(
203 "The /doctor command is a CLI diagnostic. Run `codewhale doctor` or `codewhale doctor --json`; use `/setup` in the TUI for readiness and verification.",
204 ),
205
206 _ => {
207 // Third source: skills (lowest precedence after native and user-config).
208 // Try to run a skill whose name matches the command.
209 if let Some(result) = groups::skills::run_skill_by_name(app, command.as_str(), arg) {
210 return result;
211 }
212 let suggestions =
213 user_registry::with_registry_for_workspace(Some(&app.workspace), |user_commands| {
214 suggest_command_names(command.as_str(), 3, user_commands)
215 });
216 if suggestions.is_empty() {
217 CommandResult::error(format!(
218 "Unknown command: /{command}. Type /help for available commands."
219 ))
220 } else {
221 let list = suggestions
222 .into_iter()
223 .map(|name| format!("/{name}"))
224 .collect::<Vec<_>>()
225 .join(", ");
226 CommandResult::error(format!(
227 "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands."
228 ))
229 }
230 }
231 }
232 }
233
234 /// Update a configuration value programmatically (used by interactive UI views).
235 pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
236 groups::config::config::set_config_value(app, key, value, persist)
237 }
238
239 pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String {
240 groups::config::config::switch_mode(app, mode)
241 }
242
243 fn edit_distance(a: &str, b: &str) -> usize {
244 if a == b {
245 return 0;
246 }
247 if a.is_empty() {
248 return b.chars().count();
249 }
250 if b.is_empty() {
251 return a.chars().count();
252 }
253
254 let b_chars: Vec<char> = b.chars().collect();
255 let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
256 let mut current = vec![0usize; b_chars.len() + 1];
257
258 for (i, a_ch) in a.chars().enumerate() {
259 current[0] = i + 1;
260 for (j, b_ch) in b_chars.iter().enumerate() {
261 let cost = if a_ch == *b_ch { 0 } else { 1 };
262 let delete = previous[j + 1] + 1;
263 let insert = current[j] + 1;
264 let substitute = previous[j] + cost;
265 current[j + 1] = delete.min(insert).min(substitute);
266 }
267 std::mem::swap(&mut previous, &mut current);
268 }
269
270 previous[b_chars.len()]
271 }
272
273 fn best_suggestion_score<'a>(
274 query: &str,
275 candidates: impl IntoIterator<Item = &'a str>,
276 ) -> Option<(u8, usize)> {
277 let mut best: Option<(u8, usize)> = None;
278 for candidate in candidates {
279 let prefix_match = candidate.starts_with(query) || query.starts_with(candidate);
280 let contains_match = candidate.contains(query) || query.contains(candidate);
281 let distance = edit_distance(candidate, query);
282 let close_typo = distance <= 2;
283 if !(prefix_match || contains_match || close_typo) {
284 continue;
285 }
286
287 let rank = if prefix_match {
288 0
289 } else if contains_match {
290 1
291 } else {
292 2
293 };
294
295 match best {
296 Some((best_rank, best_distance))
297 if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
298 _ => best = Some((rank, distance)),
299 }
300 }
301 best
302 }
303
304 fn suggest_command_names(
305 input: &str,
306 limit: usize,
307 user_commands: &user_registry::UserCommandRegistry,
308 ) -> Vec<String> {
309 let query = input.trim().to_ascii_lowercase();
310 if query.is_empty() || limit == 0 {
311 return Vec::new();
312 }
313
314 let mut scored: Vec<(u8, usize, String)> = Vec::new();
315 for command in registry().infos() {
316 // A user command can shadow a built-in canonical name or just one of
317 // its aliases. Score only the built-in spellings that still dispatch
318 // to the built-in so suggestions never advertise different behavior.
319 if user_commands.get(command.name).is_some() {
320 continue;
321 }
322 let candidates = std::iter::once(command.name).chain(
323 command
324 .aliases
325 .iter()
326 .copied()
327 .filter(|alias| user_commands.get(alias).is_none()),
328 );
329 if let Some((rank, distance)) = best_suggestion_score(&query, candidates) {
330 scored.push((rank, distance, command.name.to_string()));
331 }
332 }
333
334 for command in user_commands.iter().filter(|command| !command.hidden) {
335 let candidates = std::iter::once(command.name.as_str()).chain(
336 command.aliases.iter().map(String::as_str).filter(|alias| {
337 user_commands
338 .get(alias)
339 .is_some_and(|resolved| resolved.name == command.name)
340 }),
341 );
342 if let Some((rank, distance)) = best_suggestion_score(&query, candidates) {
343 scored.push((rank, distance, command.name.clone()));
344 }
345 }
346
347 scored.sort_by(|a, b| {
348 a.0.cmp(&b.0)
349 .then_with(|| a.1.cmp(&b.1))
350 .then_with(|| a.2.cmp(&b.2))
351 });
352 scored
353 .into_iter()
354 .take(limit)
355 .map(|(_, _, name)| name)
356 .collect()
357 }
358
359 #[cfg(test)]
360 mod tests {
361 use super::*;
362 use crate::config::{ApiProvider, Config};
363 use crate::localization::{Locale, MessageId};
364 use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
365 use crate::tools::todo::TodoStatus;
366 use crate::tui::app::{App, AppAction, TuiOptions};
367 use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement};
368 use std::ffi::OsString;
369 use std::path::{Path, PathBuf};
370 use tempfile::tempdir;
371
372 fn is_palette_safe_command_name(name: &str) -> bool {
373 let bytes = name.as_bytes();
374 !bytes.is_empty()
375 && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
376 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
377 && bytes
378 .iter()
379 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
380 && !name.contains("--")
381 }
382
383 fn create_test_app() -> App {
384 let options = TuiOptions {
385 ..crate::test_support::test_tui_options(PathBuf::from("."))
386 };
387 App::new(options, &Config::default())
388 }
389
390 #[test]
391 fn user_registry_module_is_compiled() {
392 super::user_registry::reload(None);
393 let registry = super::user_registry::current_registry();
394 assert!(registry.is_valid());
395 }
396
397 #[test]
398 fn preview_request_dispatch_preserves_prompt_edge_bytes() {
399 let mut app = create_test_app();
400 let result = execute("/preview-request --prompt lead\ntrail ", &mut app);
401
402 assert!(!result.is_error, "{result:?}");
403 assert!(matches!(
404 result.action,
405 Some(AppAction::PreviewOutboundRequest {
406 json: false,
407 base_prompt_only: false,
408 hypothetical_prompt,
409 }) if hypothetical_prompt.as_deref() == Some(" lead\ntrail ")
410 ));
411 }
412
413 #[test]
414 fn user_command_shadows_builtin_before_group_dispatch() {
415 let temp = tempdir().unwrap();
416 let commands_dir = temp.path().join(".codewhale").join("commands");
417 std::fs::create_dir_all(&commands_dir).unwrap();
418 std::fs::write(
419 commands_dir.join("help.md"),
420 "---\ndescription: User help\n---\nuser help $ARGUMENTS",
421 )
422 .unwrap();
423
424 let mut app = create_test_app();
425 app.workspace = temp.path().to_path_buf();
426 super::user_registry::reload(Some(temp.path()));
427
428 let result = execute("/help now", &mut app);
429 assert!(!result.is_error);
430 match result.action {
431 Some(AppAction::SendMessage(message)) => assert_eq!(message, "user help now"),
432 other => panic!("expected user command SendMessage action, got {other:?}"),
433 }
434 }
435
436 #[test]
437 fn removed_user_command_reloads_and_falls_back_to_builtin() {
438 let temp = tempdir().unwrap();
439 let commands_dir = temp.path().join(".codewhale").join("commands");
440 std::fs::create_dir_all(&commands_dir).unwrap();
441 let command_path = commands_dir.join("help.md");
442 std::fs::write(&command_path, "user help").unwrap();
443
444 let mut app = create_test_app();
445 app.workspace = temp.path().to_path_buf();
446 super::user_registry::reload(Some(temp.path()));
447 assert!(matches!(
448 execute("/help config", &mut app).action,
449 Some(AppAction::SendMessage(_))
450 ));
451
452 std::fs::remove_file(command_path).unwrap();
453 super::user_registry::reload(Some(temp.path()));
454 let result = execute("/help config", &mut app);
455 assert!(!result.is_error);
456 assert!(
457 result
458 .message
459 .as_deref()
460 .is_some_and(|message| message.contains("config")),
461 "built-in /help should handle the command"
462 );
463 assert!(result.action.is_none());
464 }
465
466 #[test]
467 fn command_registry_contains_config_and_links_but_not_set_or_deepseek() {
468 assert!(command_infos().iter().any(|cmd| cmd.name == "config"));
469 let rail = command_infos()
470 .into_iter()
471 .find(|cmd| cmd.name == "rail")
472 .expect("rail command should exist");
473 assert_eq!(rail.aliases, &["sidebar"]);
474 assert_eq!(rail.description_id, MessageId::CmdSidebarDescription);
475 assert!(rail.description_for(Locale::En).contains("rail"));
476 assert!(command_infos().iter().any(|cmd| cmd.name == "links"));
477 let hf = command_infos()
478 .into_iter()
479 .find(|cmd| cmd.name == "hf")
480 .expect("hf command should exist");
481 assert_eq!(hf.aliases, &["huggingface"]);
482 assert_eq!(hf.description_id, MessageId::CmdHfDescription);
483 assert!(hf.description_for(Locale::En).contains("Hugging Face"));
484 assert!(command_infos().iter().any(|cmd| cmd.name == "memory"));
485 assert!(!command_infos().iter().any(|cmd| cmd.name == "set"));
486 assert!(!command_infos().iter().any(|cmd| cmd.name == "deepseek"));
487 }
488
489 #[test]
490 fn links_command_has_dashboard_and_api_aliases() {
491 let links = command_infos()
492 .into_iter()
493 .find(|cmd| cmd.name == "links")
494 .expect("links command should exist");
495 assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]);
496 }
497
498 #[test]
499 fn transcript_command_is_discoverable_and_opens_live_overlay() {
500 let transcript = command_infos()
501 .into_iter()
502 .find(|cmd| cmd.name == "transcript")
503 .expect("transcript command should exist");
504 assert_eq!(transcript.usage, "/transcript");
505 assert!(transcript.show_in_empty_discovery());
506
507 let mut app = create_test_app();
508 let result = execute("/transcript", &mut app);
509 assert!(!result.is_error);
510 assert!(matches!(result.action, Some(AppAction::OpenLiveTranscript)));
511 }
512
513 #[test]
514 fn hf_alias_dispatches_to_concepts_helper() {
515 let mut app = create_test_app();
516 let result = execute("/huggingface concepts", &mut app);
517 assert!(!result.is_error);
518 let message = result.message.expect("concepts message");
519 assert!(message.contains("Hugging Face provider route"));
520 assert!(message.contains("Hugging Face MCP"));
521 assert!(message.contains("Hub workflows"));
522 }
523
524 #[test]
525 fn xai_device_auth_slash_command_starts_login() {
526 let mut app = create_test_app();
527 let result = execute("/auth xai-device", &mut app);
528 assert!(!result.is_error);
529 assert!(matches!(
530 result.action,
531 Some(AppAction::StartXaiDeviceLogin)
532 ));
533 }
534
535 #[test]
536 fn rlm_slash_command_routes_to_persistent_tool_instruction() {
537 let mut app = create_test_app();
538 let result = execute("/rlm 2 inspect this long corpus", &mut app);
539 assert!(!result.is_error);
540 assert!(
541 result
542 .message
543 .as_deref()
544 .unwrap_or("")
545 .contains("persistent working context")
546 );
547 let Some(AppAction::SendMessage(message)) = result.action else {
548 panic!("expected SendMessage action");
549 };
550 assert!(message.contains("session-persistent working context"));
551 assert!(message.contains("Do not use legacy `rlm` tool actions"));
552 }
553
554 /// `/kernel` was briefly introduced by an in-flight change and rejected:
555 /// the persistent working context is ordinary Agent behavior, not a
556 /// control surface users have to learn.
557 #[test]
558 fn kernel_is_not_a_command() {
559 let mut app = create_test_app();
560 let result = execute("/kernel inspect the fresh corpus", &mut app);
561 assert!(
562 result.is_error,
563 "/kernel must not resolve to a registered command"
564 );
565 }
566
567 #[test]
568 fn agent_slash_command_routes_to_persistent_tool_instruction() {
569 let mut app = create_test_app();
570 let result = execute("/agent 0 inspect the parser", &mut app);
571 assert!(!result.is_error);
572 let Some(AppAction::SendMessage(message)) = result.action else {
573 panic!("expected SendMessage action");
574 };
575 assert!(message.contains("`agent`"));
576 assert!(message.contains("max_depth: 0"));
577 }
578
579 #[test]
580 fn relay_slash_command_routes_to_session_relay_instruction() {
581 let mut app = create_test_app();
582 app.hunt.quarry = Some("Unify the work surface".to_string());
583 app.hunt.token_budget = Some(12_000);
584 {
585 let mut todos = app.todos.try_lock().expect("todo lock");
586 todos.add("inspect workspace".to_string(), TodoStatus::Completed);
587 todos.add("patch relay command".to_string(), TodoStatus::InProgress);
588 }
589 {
590 let mut plan = app.plan_state.try_lock().expect("plan lock");
591 plan.update(UpdatePlanArgs {
592 objective: Some("Keep relays grounded".to_string()),
593 explanation: Some("RLM-style strategy".to_string()),
594 sources_used: vec!["transcript context".to_string()],
595 critical_files: vec!["crates/tui/src/commands/mod.rs".to_string()],
596 constraints: vec!["Do not invent verification".to_string()],
597 verification_plan: Some("Check relay prompt assertions".to_string()),
598 handoff_packet: Some("Next thread should read the To-do list".to_string()),
599 plan: vec![PlanItemArg {
600 step: "keep To-do primary".to_string(),
601 status: StepStatus::InProgress,
602 }],
603 ..UpdatePlanArgs::default()
604 });
605 }
606
607 let result = execute("/relay verify install", &mut app);
608 assert!(!result.is_error);
609 assert!(
610 result
611 .message
612 .as_deref()
613 .unwrap_or_default()
614 .contains(".deepseek/handoff.md")
615 );
616 let Some(AppAction::SendMessage(message)) = result.action else {
617 panic!("expected SendMessage action");
618 };
619 assert!(message.contains("session relay"));
620 assert!(message.contains("接力"));
621 assert!(message.contains("Write or update `.deepseek/handoff.md`"));
622 assert!(message.contains("# Session relay"));
623 assert!(message.contains("Requested relay focus: verify install"));
624 assert!(message.contains("Goal objective: Unify the work surface"));
625 assert!(message.contains("Goal token budget: 12000"));
626 // #3983: the relay artifact carries the same bounded canonical body a
627 // parent request and a forked agent see — byte for byte.
628 let expected_body = crate::work_grounding::canonical_todo_body(
629 &app.todos.try_lock().expect("todo lock").snapshot(),
630 )
631 .expect("canonical body");
632 assert_eq!(
633 expected_body,
634 "To-do (50% settled)\n- [x] #1 inspect workspace\n- [~] #2 patch relay command"
635 );
636 assert!(
637 message.contains(&expected_body),
638 "relay must embed the canonical To-do body: {message}"
639 );
640 assert!(
641 !message.contains(crate::work_grounding::WORK_STATE_OPEN_TAG),
642 "the transient request-tail wrapper must not be stored in relay history: {message}"
643 );
644 assert!(message.contains("Conversational strategy notes from update_plan"));
645 assert!(message.contains("Objective: Keep relays grounded"));
646 assert!(message.contains("Explanation: RLM-style strategy"));
647 assert!(message.contains("Source: transcript context"));
648 assert!(message.contains("Critical file: crates/tui/src/commands/mod.rs"));
649 assert!(message.contains("Constraint: Do not invent verification"));
650 assert!(message.contains("Verification plan: Check relay prompt assertions"));
651 assert!(message.contains("Handoff packet: Next thread should read the To-do list"));
652 assert!(message.contains("[in_progress] keep To-do primary"));
653 assert!(
654 !message.contains("Work checklist"),
655 "relay copy should use To-do vocabulary: {message}"
656 );
657 }
658
659 /// #3983: `update_plan` is conversational strategy, not a Work ledger. A
660 /// session with plan state and an empty To-do has *no* Work state, and the
661 /// relay artifact must not manufacture one.
662 #[test]
663 fn relay_does_not_present_plan_only_state_as_work_state() {
664 let mut app = create_test_app();
665 {
666 let mut plan = app.plan_state.try_lock().expect("plan lock");
667 plan.update(UpdatePlanArgs {
668 objective: Some("Ship the grounding seam".to_string()),
669 plan: vec![PlanItemArg {
670 step: "draft the renderer".to_string(),
671 status: StepStatus::InProgress,
672 }],
673 ..UpdatePlanArgs::default()
674 });
675 }
676
677 let result = execute("/relay", &mut app);
678 let Some(AppAction::SendMessage(message)) = result.action else {
679 panic!("expected SendMessage action");
680 };
681
682 assert!(
683 !message.contains("Current Work state"),
684 "plan-only state must not render as Work state: {message}"
685 );
686 assert!(
687 !message.contains("To-do ("),
688 "plan-only state must not synthesize a To-do ledger: {message}"
689 );
690 assert!(message.contains("Conversational strategy notes from update_plan"));
691 }
692
693 /// #3983: a graph-backed update is authoritative immediately, even before
694 /// the compatibility To-do projection is published to the UI.
695 #[tokio::test]
696 async fn relay_reads_same_turn_graph_backed_work_update() {
697 use crate::tools::spec::ToolSpec as _;
698
699 let mut app = create_test_app();
700 let work =
701 crate::work_graph::new_shared_work_runtime(app.todos.clone(), app.plan_state.clone());
702 app.runtime_services.work = Some(work.clone());
703
704 let mut context = crate::tools::spec::ToolContext::new(app.workspace.clone());
705 context.runtime.work = Some(work);
706 crate::tools::todo::TodoWriteTool::work_update(app.todos.clone())
707 .execute(
708 serde_json::json!({
709 "todos": [{"content": "relay the staged graph", "status": "in_progress"}]
710 }),
711 &context,
712 )
713 .await
714 .expect("graph-backed work_update");
715
716 assert!(
717 app.todos.lock().await.snapshot().is_empty(),
718 "precondition: legacy projection has not published yet"
719 );
720
721 let result = execute("/relay", &mut app);
722 let Some(AppAction::SendMessage(message)) = result.action else {
723 panic!("expected SendMessage action");
724 };
725 assert!(
726 message.contains("[~] #1 relay the staged graph"),
727 "{message}"
728 );
729 }
730
731 #[test]
732 fn relay_command_has_bilingual_aliases() {
733 let relay = command_infos()
734 .into_iter()
735 .find(|cmd| cmd.name == "relay")
736 .expect("relay command should exist");
737 assert_eq!(relay.aliases, &["batonpass", "接力"]);
738 assert!(relay.description_for(Locale::ZhHans).contains("接力"));
739 assert!(relay.description_for(Locale::ZhHant).contains("接力"));
740
741 let mut app = create_test_app();
742 let result = execute("/接力 next hand", &mut app);
743 assert!(!result.is_error);
744 let Some(AppAction::SendMessage(message)) = result.action else {
745 panic!("expected SendMessage action");
746 };
747 assert!(message.contains("Requested relay focus: next hand"));
748 }
749
750 /// AT-008: No built-in command name or alias is registered twice,
751 /// and no built-in alias collides with another command's canonical name.
752 /// This test iterates every command from `command_infos()` (all 9 groups)
753 /// and asserts uniqueness across the full set of names and aliases.
754 #[test]
755 fn command_registry_has_unique_names_and_aliases() {
756 let mut names = std::collections::BTreeSet::new();
757 for command in command_infos() {
758 assert!(
759 names.insert(command.name),
760 "duplicate command name /{}",
761 command.name
762 );
763 }
764
765 let mut aliases = std::collections::BTreeSet::new();
766 for command in command_infos() {
767 for alias in command.aliases {
768 assert!(
769 !names.contains(alias),
770 "alias /{alias} collides with a command name"
771 );
772 assert!(aliases.insert(*alias), "duplicate command alias /{alias}");
773 }
774 }
775 }
776
777 /// AT-009: Command ownership contract — top-level `commands/mod.rs` only
778 /// registers groups (`groups::all_command_groups()`), each group owns its
779 /// `commands()` list, and every command has valid metadata.
780 ///
781 /// Config and debug groups are documented permanent exceptions: they keep
782 /// group-local `CommandInfo` statics and `dispatch()` in `mod.rs` rather
783 /// than extracting every command into a focused module. This is accepted
784 /// final structure per FEAT-008 §3.2.
785 ///
786 /// Enforcement strategy:
787 /// - Exactly 9 source-verified groups (from `groups/mod.rs`)
788 /// - Each group owns its commands() list
789 /// - Config and debug exceptions verified within their specific groups by
790 /// identifying the group through its first command ("config" and "tokens")
791 /// - Not circular: the group-iterated command count is a consistency check;
792 /// the primary enforcement is exact group count + per-group non-empty + valid metadata
793 #[test]
794 fn command_ownership_contract_is_enforced() {
795 let groups = groups::all_command_groups();
796
797 // AT-009 primary: exactly 9 groups matching groups/mod.rs
798 assert_eq!(
799 groups.len(),
800 9,
801 "expected exactly 9 command groups (core, session, config, debug, \
802 project, skills, memory, plugins, utility), got {}",
803 groups.len()
804 );
805
806 let mut total_commands = 0;
807 let mut has_config = false;
808 let mut has_debug = false;
809 for &group in groups {
810 let commands = group.commands();
811 assert!(
812 !commands.is_empty(),
813 "each group must have at least one command"
814 );
815 for cmd in commands {
816 let info = cmd.info();
817 assert!(!info.name.is_empty(), "command name must not be empty");
818 assert!(
819 is_palette_safe_command_name(info.name),
820 "/{} command names must be lowercase ASCII kebab-case",
821 info.name
822 );
823 let usage_prefix = format!("/{}", info.name);
824 assert!(
825 info.usage.starts_with(&usage_prefix),
826 "/{} usage must start with /{{name}}, got {:?}",
827 info.name,
828 info.usage
829 );
830 }
831 total_commands += commands.len();
832
833 // Identify config and debug groups by their command content to
834 // verify permanent-exception counts within the correct group.
835 if commands.iter().any(|c| c.info().name == "config") {
836 has_config = true;
837 assert_eq!(
838 commands.len(),
839 12,
840 "config group (group-local metadata exception) expected \
841 exactly 12 commands, got {}",
842 commands.len()
843 );
844 }
845 if commands.iter().any(|c| c.info().name == "tokens") {
846 has_debug = true;
847 assert_eq!(
848 commands.len(),
849 13,
850 "debug group (group-local metadata exception) expected \
851 exactly 13 commands, got {}",
852 commands.len()
853 );
854 }
855 }
856
857 // Config and debug groups must be found and verified by content identity
858 assert!(
859 has_config,
860 "config group not found (expected first command: /config)"
861 );
862 assert!(
863 has_debug,
864 "debug group not found (expected first command: /tokens)"
865 );
866
867 // Consistency: group-iterated command count must match registry
868 assert_eq!(
869 total_commands,
870 command_infos().len(),
871 "group-iterated command count must match registry infos count"
872 );
873 }
874
875 #[test]
876 fn command_groups_are_cached_once() {
877 let first_groups = groups::all_command_groups();
878 let second_groups = groups::all_command_groups();
879 assert!(
880 std::ptr::eq(first_groups.as_ptr(), second_groups.as_ptr()),
881 "command group list should be cached"
882 );
883
884 for &group in first_groups {
885 let first_commands = group.commands();
886 let second_commands = group.commands();
887 assert!(
888 std::ptr::eq(first_commands.as_ptr(), second_commands.as_ptr()),
889 "command list should be cached per group"
890 );
891 }
892 }
893
894 #[test]
895 fn command_registry_metadata_is_complete_and_palette_safe() {
896 for command in command_infos() {
897 assert!(!command.name.is_empty(), "command name must not be empty");
898 assert_eq!(
899 command.name.trim(),
900 command.name,
901 "/{} command name must not need trimming",
902 command.name
903 );
904 assert!(
905 is_palette_safe_command_name(command.name),
906 "/{} command names must stay lowercase ASCII kebab-case",
907 command.name
908 );
909
910 let expected_usage_prefix = format!("/{}", command.name);
911 assert!(
912 command.usage.starts_with(&expected_usage_prefix),
913 "/{} usage must start with its canonical slash command, got {:?}",
914 command.name,
915 command.usage
916 );
917
918 let description = command.description_for(Locale::En);
919 assert!(
920 !description.trim().is_empty(),
921 "/{} must have non-empty English help text",
922 command.name
923 );
924 // #3913: descriptions must not restate the usage field — the
925 // palette and /help already append `usage` when arguments exist.
926 assert!(
927 !description.contains(command.usage),
928 "/{} description embeds its usage string {:?}: {description:?}",
929 command.name,
930 command.usage
931 );
932 assert!(
933 !description.contains(&format!("/{}", command.name)),
934 "/{} description embeds slash-command syntax that usage already covers: {description:?}",
935 command.name
936 );
937 for banned_prefix in ["Toolbox:", "Reference:"] {
938 assert!(
939 !description.starts_with(banned_prefix),
940 "/{} description should not start with {banned_prefix:?}: {description:?}",
941 command.name
942 );
943 }
944
945 let palette_command = command.palette_command();
946 assert!(
947 palette_command.starts_with(&expected_usage_prefix),
948 "/{} palette command must use the canonical command, got {:?}",
949 command.name,
950 palette_command
951 );
952 assert_eq!(
953 palette_command.ends_with(' '),
954 command.requires_argument(),
955 "/{} palette command spacing must match argument requirement",
956 command.name
957 );
958
959 for &alias in command.aliases {
960 assert!(
961 !alias.trim().is_empty(),
962 "/{} alias must not be empty",
963 command.name
964 );
965 assert_eq!(
966 alias.trim(),
967 alias,
968 "/{} alias /{alias} must not need trimming",
969 command.name
970 );
971 assert!(
972 !alias.starts_with('/'),
973 "/{} alias /{alias} must be stored without a slash",
974 command.name
975 );
976 assert!(
977 !alias.chars().any(char::is_whitespace),
978 "/{} alias /{alias} must not contain whitespace",
979 command.name
980 );
981 assert!(
982 !alias.chars().any(|ch| ch.is_ascii_uppercase()),
983 "/{} alias /{alias} must not contain uppercase ASCII",
984 command.name
985 );
986 }
987 }
988 }
989
990 #[test]
991 fn command_discovery_tier_lists_use_canonical_registered_names() {
992 for (tier_name, names) in [
993 ("advanced", traits::ADVANCED_DISCOVERY_COMMANDS),
994 ("compatibility", traits::COMPATIBILITY_DISCOVERY_COMMANDS),
995 ] {
996 for &name in names {
997 let info = registry()
998 .get_info(name)
999 .unwrap_or_else(|| panic!("{tier_name} discovery entry {name:?} must resolve"));
1000 assert_eq!(
1001 info.name, name,
1002 "{tier_name} discovery entry {name:?} must be canonical, not an alias for /{}",
1003 info.name
1004 );
1005 }
1006 }
1007 }
1008
1009 #[test]
1010 fn command_info_resolves_canonical_names_and_aliases() {
1011 for command in command_infos() {
1012 for lookup in [command.name.to_string(), format!("/{}", command.name)] {
1013 let resolved = get_command_info(&lookup)
1014 .unwrap_or_else(|| panic!("{lookup:?} should resolve to /{}", command.name));
1015 assert_eq!(resolved.name, command.name);
1016 }
1017
1018 for &alias in command.aliases {
1019 for lookup in [alias.to_string(), format!("/{alias}")] {
1020 let resolved = get_command_info(&lookup).unwrap_or_else(|| {
1021 panic!("{lookup:?} should resolve to /{}", command.name)
1022 });
1023 assert_eq!(resolved.name, command.name);
1024 }
1025 }
1026 }
1027 }
1028
1029 #[test]
1030 fn every_registered_command_has_a_help_topic() {
1031 let mut app = create_test_app();
1032 for command in command_infos() {
1033 let result = execute(&format!("/help {}", command.name), &mut app);
1034 assert!(
1035 !result.is_error,
1036 "/help {} returned an error: {result:?}",
1037 command.name
1038 );
1039 let message = result
1040 .message
1041 .unwrap_or_else(|| panic!("/help {} should return text", command.name));
1042 assert!(
1043 message.contains(command.name),
1044 "/help {} should mention the command name, got {message:?}",
1045 command.name
1046 );
1047 assert!(
1048 message.contains(command.usage),
1049 "/help {} should include usage {:?}, got {message:?}",
1050 command.name,
1051 command.usage
1052 );
1053 }
1054 }
1055
1056 #[test]
1057 fn context_command_opens_inspector_and_keeps_ctx_alias() {
1058 let context = command_infos()
1059 .into_iter()
1060 .find(|cmd| cmd.name == "context")
1061 .expect("context command should exist");
1062 assert_eq!(context.aliases, &["ctx"]);
1063 assert!(context.description_for(Locale::En).contains("inspector"));
1064
1065 let mut app = create_test_app();
1066 let result = execute("/ctx", &mut app);
1067 assert!(matches!(
1068 result.action,
1069 Some(AppAction::OpenContextInspector)
1070 ));
1071
1072 let report = execute("/context report", &mut app);
1073 let message = report.message.expect("context report should return text");
1074 assert!(message.contains("Context Source Map"));
1075 }
1076
1077 #[test]
1078 fn cache_inspect_dispatches_through_cache_command() {
1079 let mut app = create_test_app();
1080 let result = execute("/cache inspect", &mut app);
1081 let msg = result.message.expect("cache inspect should return text");
1082 assert!(msg.contains("Cache Inspect"));
1083 assert!(msg.contains("Base static prefix hash:"));
1084 assert!(msg.contains("Full request prefix hash:"));
1085 assert!(result.action.is_none());
1086 }
1087
1088 #[test]
1089 fn cache_warmup_dispatches_action() {
1090 let mut app = create_test_app();
1091 let result = execute("/cache warmup", &mut app);
1092 assert!(result.message.is_none());
1093 assert!(matches!(result.action, Some(AppAction::CacheWarmup)));
1094 }
1095
1096 #[test]
1097 fn execute_config_opens_config_view_action() {
1098 let mut app = create_test_app();
1099 let result = execute("/config", &mut app);
1100 assert!(result.message.is_none());
1101 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
1102 }
1103
1104 #[test]
1105 fn execute_verbose_toggles_live_transcript_detail() {
1106 let mut app = create_test_app();
1107 assert!(!app.verbose_transcript);
1108
1109 let result = execute("/verbose on", &mut app);
1110 assert!(!result.is_error);
1111 assert!(app.verbose_transcript);
1112 assert!(result.message.unwrap().contains("on"));
1113
1114 let result = execute("/verbose off", &mut app);
1115 assert!(!result.is_error);
1116 assert!(!app.verbose_transcript);
1117 assert!(result.message.unwrap().contains("off"));
1118 }
1119
1120 #[test]
1121 fn voice_send_and_voice_control_commands_toggle_state() {
1122 let mut app = create_test_app();
1123 assert!(!app.voice_send_enabled);
1124 assert!(!app.voice_control_enabled);
1125
1126 for invocation in ["/voicesend", "/voice-send", "/yuyinsend", "/语音发送"] {
1127 let result = execute(invocation, &mut app);
1128 assert!(!result.is_error, "{invocation} should toggle cleanly");
1129 assert!(result.action.is_none());
1130 assert!(result.message.is_some());
1131 }
1132 // Four toggles land back at disabled.
1133 assert!(!app.voice_send_enabled);
1134
1135 let result = execute("/voicecontrol", &mut app);
1136 assert!(!result.is_error);
1137 assert!(app.voice_control_enabled);
1138 let result = execute("/voice-control", &mut app);
1139 assert!(!result.is_error);
1140 assert!(!app.voice_control_enabled);
1141 }
1142
1143 /// `/voice` defers the actual capture to the UI event loop via
1144 /// `AppAction::VoiceCapture`, so executing it never records audio.
1145 /// On hosts without a recorder it must fail gracefully instead.
1146 #[test]
1147 fn voice_command_toggles_on_and_off_or_fails_gracefully() {
1148 let mut app = create_test_app();
1149 let result = execute("/voice", &mut app);
1150 if app.voice_enabled {
1151 assert!(!result.is_error);
1152 assert!(matches!(result.action, Some(AppAction::VoiceCapture)));
1153 let off = execute("/voice", &mut app);
1154 assert!(!off.is_error);
1155 assert!(off.action.is_none());
1156 assert!(!app.voice_enabled);
1157 } else {
1158 assert!(result.is_error);
1159 assert!(result.action.is_none());
1160 }
1161 }
1162
1163 #[test]
1164 fn execute_rail_sets_placement_and_reports_actual_state() {
1165 let mut app = create_test_app();
1166
1167 let result = execute("/rail off", &mut app);
1168 assert!(!result.is_error);
1169 assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Off);
1170 assert!(
1171 result
1172 .message
1173 .as_deref()
1174 .unwrap_or_default()
1175 .contains("Rail is off")
1176 );
1177
1178 let result = execute("/rail right", &mut app);
1179 assert!(!result.is_error);
1180 assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Right);
1181 assert!(
1182 result
1183 .message
1184 .as_deref()
1185 .unwrap_or_default()
1186 .contains("right placement")
1187 );
1188
1189 // The /sidebar alias drives the same rail.
1190 let result = execute("/sidebar left", &mut app);
1191 assert!(!result.is_error);
1192 assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Left);
1193
1194 // Bare /rail reports the actual rendered state; it must never claim
1195 // visibility for a surface that cannot render.
1196 app.work_surface.placement = WorkSurfacePlacement::Off;
1197 let result = execute("/rail", &mut app);
1198 assert!(!result.is_error);
1199 assert!(
1200 result
1201 .message
1202 .as_deref()
1203 .unwrap_or_default()
1204 .contains("Rail is off")
1205 );
1206 }
1207
1208 #[test]
1209 fn execute_rail_accepts_panel_targets_and_legacy_words() {
1210 let mut app = create_test_app();
1211
1212 let result = execute("/rail agents", &mut app);
1213 assert!(!result.is_error);
1214 assert_eq!(app.work_surface.panel, RailPanel::Agents);
1215
1216 let result = execute("/sidebar context", &mut app);
1217 assert!(!result.is_error);
1218 assert_eq!(app.work_surface.panel, RailPanel::Context);
1219
1220 let result = execute("/rail activity", &mut app);
1221 assert!(!result.is_error);
1222 assert_eq!(
1223 app.work_surface.panel,
1224 RailPanel::Tasks,
1225 "activity maps onto the Tasks panel"
1226 );
1227
1228 let result = execute("/rail pinned", &mut app);
1229 assert!(!result.is_error);
1230 assert_eq!(app.work_surface.panel, RailPanel::Pinned);
1231
1232 let result = execute("/sidebar on", &mut app);
1233 assert!(!result.is_error);
1234 assert_eq!(
1235 app.work_surface.placement,
1236 WorkSurfacePlacement::Top,
1237 "on restores the default top rail"
1238 );
1239
1240 let result = execute("/sidebar none", &mut app);
1241 assert!(!result.is_error);
1242 assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Off);
1243 }
1244
1245 #[test]
1246 fn execute_rail_rejects_invalid_args() {
1247 let mut app = create_test_app();
1248 let result = execute("/rail maybe", &mut app);
1249 assert!(result.is_error);
1250 assert!(
1251 result
1252 .message
1253 .as_deref()
1254 .unwrap_or_default()
1255 .contains("Usage: /rail")
1256 );
1257 }
1258
1259 #[test]
1260 fn execute_links_and_aliases_return_links_message() {
1261 let mut app = create_test_app();
1262 for cmd in ["/links", "/dashboard", "/api", "/lianjie"] {
1263 let result = execute(cmd, &mut app);
1264 let msg = result.message.expect("links commands should return text");
1265 assert!(msg.contains("https://codewhale.net/en/docs"));
1266 assert!(msg.contains("https://codewhale.net/en/community"));
1267 assert!(msg.contains("https://github.com/Hmbown/CodeWhale"));
1268 assert!(msg.contains("https://app.codewhale.net"));
1269 assert!(msg.contains("separate sign-in"));
1270 assert!(msg.contains("not connected to the current local session"));
1271 assert!(msg.contains("https://platform.deepseek.com"));
1272 assert!(result.action.is_none());
1273 }
1274 }
1275
1276 #[test]
1277 fn execute_workspace_alias_switches_workspace() {
1278 let dir = tempdir().expect("temp dir");
1279 let mut app = create_test_app();
1280 let result = execute(&format!("/cwd {}", dir.path().display()), &mut app);
1281 assert!(matches!(
1282 result.action,
1283 Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap()
1284 ));
1285 }
1286
1287 #[test]
1288 fn removed_set_and_deepseek_commands_show_migration_hints() {
1289 let mut app = create_test_app();
1290 let set_result = execute("/set model deepseek-v4-pro", &mut app);
1291 let set_msg = set_result
1292 .message
1293 .expect("legacy command should return an error message");
1294 assert!(set_msg.contains("The /set command was retired"));
1295 assert!(set_msg.contains("/config"));
1296 assert!(set_msg.contains("/settings"));
1297 assert!(set_result.action.is_none());
1298
1299 let deepseek_result = execute("/deepseek", &mut app);
1300 let deepseek_msg = deepseek_result
1301 .message
1302 .expect("legacy command should return an error message");
1303 assert!(deepseek_msg.contains("The /deepseek command was renamed"));
1304 assert!(deepseek_msg.contains("/links"));
1305 assert!(deepseek_msg.contains("/dashboard"));
1306 assert!(deepseek_msg.contains("/api"));
1307 assert!(deepseek_result.action.is_none());
1308 }
1309
1310 struct ConfigPathGuard {
1311 previous: Option<OsString>,
1312 _lock: crate::test_support::TestEnvLock,
1313 }
1314
1315 impl ConfigPathGuard {
1316 fn new(config_path: &Path) -> Self {
1317 let lock = crate::test_support::lock_test_env();
1318 let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH");
1319 // Safety: test-only environment mutation guarded by a global mutex.
1320 unsafe {
1321 std::env::set_var("DEEPSEEK_CONFIG_PATH", config_path);
1322 }
1323 Self {
1324 previous,
1325 _lock: lock,
1326 }
1327 }
1328 }
1329
1330 impl Drop for ConfigPathGuard {
1331 fn drop(&mut self) {
1332 // Safety: test-only environment mutation guarded by a global mutex.
1333 unsafe {
1334 if let Some(previous) = self.previous.take() {
1335 std::env::set_var("DEEPSEEK_CONFIG_PATH", previous);
1336 } else {
1337 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
1338 }
1339 }
1340 }
1341 }
1342
1343 /// Build an App scoped to an isolated tempdir so dispatch-side-effects
1344 /// (e.g. `/init` writing AGENTS.md, explicit `/export <path>` writes, or
1345 /// `/logout` clearing credentials) don't pollute the repo working tree or
1346 /// the developer's real config when the smoke tests run.
1347 fn create_isolated_test_app() -> (App, tempfile::TempDir, ConfigPathGuard) {
1348 let tmpdir = tempfile::TempDir::new().expect("tempdir for smoke test");
1349 let workspace = tmpdir.path().to_path_buf();
1350 let config_path = workspace.join(".deepseek").join("config.toml");
1351 std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir");
1352 let guard = ConfigPathGuard::new(&config_path);
1353 let options = TuiOptions {
1354 config_path: Some(config_path),
1355 skills_dir: workspace.join("skills"),
1356 memory_path: workspace.join("memory.md"),
1357 notes_path: workspace.join("notes.txt"),
1358 mcp_config_path: workspace.join("mcp.json"),
1359 ..crate::test_support::test_tui_options(workspace.clone())
1360 };
1361 let app = App::new(options, &Config::default());
1362 (app, tmpdir, guard)
1363 }
1364
1365 /// Smoke test: every entry in `command_infos()` must dispatch to a real handler.
1366 /// A dispatch miss surfaces as the fall-through `Unknown command:` error
1367 /// message in `execute`. This catches the case where a new command is
1368 /// added to `command_infos()` (so it shows up in `/help` and the palette) but
1369 /// the matching arm in `execute` is forgotten — the user would type the
1370 /// command, see it autocomplete, and then get an unhelpful "did you
1371 /// mean" suggestion. Also catches panics in handlers because the test
1372 /// runner unwinds the panic and reports the offending command.
1373 /// `/save` still defaults its output path, while `/export` accepts a legacy
1374 /// direct file path. Pass explicit tempdir paths so this smoke test covers
1375 /// both file handlers without touching the developer's clipboard.
1376 fn invocation_for(command_name: &str, alias_or_name: &str, tmpdir: &std::path::Path) -> String {
1377 match command_name {
1378 "save" => format!("/{alias_or_name} {}", tmpdir.join("session.json").display()),
1379 "export" => format!("/{alias_or_name} {}", tmpdir.join("chat.md").display()),
1380 _ => format!("/{alias_or_name}"),
1381 }
1382 }
1383
1384 /// `/restore` is covered by its own dedicated tests in
1385 /// `commands/restore.rs` that serialize on the global env mutex via
1386 /// `scoped_home` (snapshot repo init shells out to git, which races
1387 /// against parallel-running tests). Skip it here so this smoke test
1388 /// stays parallel-safe.
1389 fn skip_in_dispatch_smoke(name: &str) -> bool {
1390 name == "restore"
1391 }
1392
1393 #[test]
1394 fn slash_parser_preserves_arguments_after_the_command_name() {
1395 let mut app = create_test_app();
1396 let result = execute("/agent 2 review this carefully", &mut app);
1397 assert!(!result.is_error);
1398 let Some(AppAction::SendMessage(message)) = result.action else {
1399 panic!("expected /agent to send a model instruction");
1400 };
1401 assert!(message.contains(r#"prompt: "review this carefully""#));
1402 assert!(message.contains("max_depth: 2"));
1403
1404 let mut app = create_test_app();
1405 let result = execute(" /relay ship command harness ", &mut app);
1406 assert!(!result.is_error);
1407 let Some(AppAction::SendMessage(message)) = result.action else {
1408 panic!("expected /relay to send a model instruction");
1409 };
1410 assert!(message.contains("Requested relay focus: ship command harness"));
1411
1412 let mut app = create_test_app();
1413 let result = execute("/rlm 3 inspect this corpus", &mut app);
1414 assert!(!result.is_error);
1415 let Some(AppAction::SendMessage(message)) = result.action else {
1416 panic!("expected /rlm to send a model instruction");
1417 };
1418 assert!(message.contains(r#"this text: "inspect this corpus""#));
1419 assert!(message.contains("session-persistent working context"));
1420 }
1421
1422 #[test]
1423 fn representative_command_groups_keep_dispatch_surfaces() {
1424 let mut app = create_test_app();
1425 let help = execute("/help clear", &mut app)
1426 .message
1427 .expect("/help clear should return text");
1428 assert!(help.contains("clear"));
1429 assert!(help.contains("/clear"));
1430
1431 let mut app = create_test_app();
1432 let result = execute("/config", &mut app);
1433 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
1434
1435 let mut app = create_test_app();
1436 let result = execute("/relay command boundary", &mut app);
1437 assert!(!result.is_error);
1438 assert!(matches!(
1439 result.action,
1440 Some(AppAction::SendMessage(message))
1441 if message.contains("Requested relay focus: command boundary")
1442 ));
1443
1444 let mut app = create_test_app();
1445 let note_help = execute("/note help", &mut app)
1446 .message
1447 .expect("/note help should return text");
1448 assert!(note_help.contains("Usage: /note"));
1449
1450 let mut app = create_test_app();
1451 let result = execute("/hunt ship layer 2 | budget: 100", &mut app);
1452 assert!(!result.is_error);
1453 assert_eq!(app.hunt.quarry.as_deref(), Some("ship layer 2"));
1454 assert_eq!(app.hunt.token_budget, Some(100));
1455
1456 let (mut app, _tmpdir, _guard) = create_isolated_test_app();
1457 let result = execute("/skills", &mut app);
1458 assert!(matches!(result.action, Some(AppAction::OpenSkillsManager)));
1459
1460 let mut app = create_test_app();
1461 let result = execute("/task list", &mut app);
1462 assert!(matches!(result.action, Some(AppAction::TaskList)));
1463
1464 let mut app = create_test_app();
1465 let tokens = execute("/tokens", &mut app)
1466 .message
1467 .expect("/tokens should return text");
1468 assert!(tokens.contains("deepseek-v4-pro"));
1469 }
1470
1471 /// Smoke test: every entry in `command_infos()` must dispatch to a real handler.
1472 /// A dispatch miss surfaces as the fall-through `Unknown command:` error
1473 /// message in `execute`. This catches the case where a new command is
1474 /// added to `command_infos()` (so it shows up in `/help` and the palette) but
1475 /// the matching arm in `execute` is forgotten — the user would type the
1476 /// command, see it autocomplete, and then get an unhelpful "did you
1477 /// mean" suggestion. Also catches panics in handlers because the test
1478 /// runner unwinds the panic and reports the offending command.
1479 #[test]
1480 fn every_registered_command_dispatches_to_a_handler() {
1481 for command in command_infos() {
1482 if skip_in_dispatch_smoke(command.name) {
1483 continue;
1484 }
1485 let (mut app, tmpdir, _guard) = create_isolated_test_app();
1486 let invocation = invocation_for(command.name, command.name, tmpdir.path());
1487 let result = execute(&invocation, &mut app);
1488 if let Some(msg) = &result.message {
1489 assert!(
1490 !msg.contains("Unknown command"),
1491 "/{} fell through to the unknown-command branch: {msg}",
1492 command.name,
1493 );
1494 }
1495 }
1496 }
1497
1498 /// Same check, but for declared aliases — `/q` should not fall through
1499 /// just because the registry lists it as an alias of `/exit`.
1500 #[test]
1501 fn every_command_alias_dispatches_to_a_handler() {
1502 for command in command_infos() {
1503 if skip_in_dispatch_smoke(command.name) {
1504 continue;
1505 }
1506 for alias in command.aliases {
1507 let (mut app, tmpdir, _guard) = create_isolated_test_app();
1508 let invocation = invocation_for(command.name, alias, tmpdir.path());
1509 let result = execute(&invocation, &mut app);
1510 if let Some(msg) = &result.message {
1511 assert!(
1512 !msg.contains("Unknown command"),
1513 "/{alias} (alias of /{}) fell through to unknown: {msg}",
1514 command.name,
1515 );
1516 }
1517 }
1518 }
1519 }
1520
1521 #[test]
1522 fn balance_command_has_own_help_text() {
1523 let info = get_command_info("balance").expect("balance command should be registered");
1524 assert_eq!(info.description_id, MessageId::CmdBalanceDescription);
1525 assert!(
1526 info.description_for(Locale::En)
1527 .contains("provider account balance")
1528 );
1529 }
1530
1531 #[test]
1532 fn balance_command_reports_scaffold_without_claiming_dispatch() {
1533 let mut app = create_test_app();
1534 app.api_provider = ApiProvider::Deepseek;
1535
1536 let result = execute("/balance", &mut app);
1537 let msg = result
1538 .message
1539 .expect("balance scaffold should explain current state");
1540
1541 assert!(!result.is_error);
1542 assert!(msg.contains("DeepSeek"));
1543 assert!(msg.contains("not wired"));
1544 assert!(!msg.contains("sent"));
1545 }
1546
1547 #[test]
1548 fn balance_command_reports_unsupported_provider_clearly() {
1549 let mut app = create_test_app();
1550 app.api_provider = ApiProvider::Ollama;
1551
1552 let result = execute("/balance", &mut app);
1553 let msg = result
1554 .message
1555 .expect("unsupported providers should return a clear message");
1556
1557 assert!(!result.is_error);
1558 assert!(msg.contains("Ollama"));
1559 assert!(msg.contains("not supported"));
1560 assert!(msg.contains("dashboard"));
1561 }
1562
1563 #[test]
1564 fn unknown_command_suggests_nearest_match() {
1565 let mut app = create_test_app();
1566 let result = execute("/modle", &mut app);
1567 let msg = result
1568 .message
1569 .expect("unknown command should return an error message");
1570 assert!(msg.contains("Unknown command: /modle"));
1571 assert!(msg.contains("Did you mean:"));
1572 assert!(msg.contains("/model"));
1573 }
1574
1575 #[test]
1576 fn unknown_command_without_close_match_keeps_help_guidance() {
1577 let mut app = create_test_app();
1578 let result = execute("/zzzzzz", &mut app);
1579 let msg = result
1580 .message
1581 .expect("unknown command should return an error message");
1582 assert!(msg.contains("Unknown command: /zzzzzz"));
1583 assert!(msg.contains("Type /help for available commands."));
1584 }
1585
1586 #[test]
1587 fn dollar_skill_prefix_with_no_name_shows_usage() {
1588 let mut app = create_test_app();
1589 let result = execute("$", &mut app);
1590 assert!(result.is_error);
1591 let msg = result.message.expect("should return error message");
1592 assert!(msg.contains("Type a skill name after $"));
1593 }
1594
1595 #[test]
1596 fn dollar_skill_prefix_unknown_skill_reports_unknown_skill() {
1597 let mut app = create_test_app();
1598 let result = execute("$definitely-not-a-real-skill-12345", &mut app);
1599 assert!(result.is_error);
1600 let msg = result.message.expect("should return error message");
1601 assert!(msg.contains("Unknown skill: $definitely-not-a-real-skill-12345"));
1602 assert!(msg.contains("/skills"));
1603 }
1604
1605 #[test]
1606 fn dollar_skill_prefix_does_not_break_existing_slash_dispatch() {
1607 let mut app = create_test_app();
1608 let result = execute("/help", &mut app);
1609 assert!(!result.is_error);
1610 }
1611
1612 fn write_test_skill(root: &Path, name: &str) {
1613 let skill_dir = root.join("skills").join(name);
1614 std::fs::create_dir_all(&skill_dir).expect("skill directory");
1615 std::fs::write(
1616 skill_dir.join("SKILL.md"),
1617 format!(
1618 "---\nname: {name}\ndescription: Test {name} skill\n---\nFollow the test instructions."
1619 ),
1620 )
1621 .expect("skill fixture");
1622 }
1623
1624 #[test]
1625 fn task_bearing_skill_invocations_send_the_task_on_the_activated_turn() {
1626 for invocation in ["$foo do X", "/foo do X", "/skill foo do X"] {
1627 let (mut app, tmpdir, _guard) = create_isolated_test_app();
1628 write_test_skill(tmpdir.path(), "foo");
1629
1630 let result = execute(invocation, &mut app);
1631
1632 assert!(!result.is_error, "{invocation}: {result:?}");
1633 assert!(
1634 result
1635 .message
1636 .as_deref()
1637 .is_some_and(|message| message.contains("Skill 'foo' activated")),
1638 "{invocation}: {result:?}"
1639 );
1640 assert!(
1641 matches!(result.action, Some(AppAction::SendMessage(ref task)) if task == "do X"),
1642 "{invocation}: {result:?}"
1643 );
1644 assert!(
1645 app.active_skill
1646 .as_deref()
1647 .is_some_and(|instruction| instruction.contains("# Skill: foo")),
1648 "{invocation} did not arm foo for the dispatched task"
1649 );
1650 }
1651 }
1652
1653 #[test]
1654 fn bare_dollar_skill_still_arms_the_next_message() {
1655 let (mut app, tmpdir, _guard) = create_isolated_test_app();
1656 write_test_skill(tmpdir.path(), "foo");
1657
1658 let result = execute("$foo", &mut app);
1659
1660 assert!(!result.is_error, "{result:?}");
1661 assert!(result.action.is_none());
1662 assert!(
1663 app.active_skill
1664 .as_deref()
1665 .is_some_and(|instruction| instruction.contains("# Skill: foo"))
1666 );
1667 }
1668
1669 #[test]
1670 fn shorthand_can_invoke_a_skill_named_install_without_stealing_management_commands() {
1671 for invocation in ["$install do X", "/install do X"] {
1672 let (mut app, tmpdir, _guard) = create_isolated_test_app();
1673 write_test_skill(tmpdir.path(), "install");
1674
1675 let result = execute(invocation, &mut app);
1676
1677 assert!(!result.is_error, "{invocation}: {result:?}");
1678 assert!(
1679 matches!(result.action, Some(AppAction::SendMessage(ref task)) if task == "do X"),
1680 "{invocation}: {result:?}"
1681 );
1682 assert!(
1683 app.active_skill
1684 .as_deref()
1685 .is_some_and(|instruction| instruction.contains("# Skill: install")),
1686 "{invocation} did not activate the install skill"
1687 );
1688 }
1689
1690 let (mut app, tmpdir, _guard) = create_isolated_test_app();
1691 write_test_skill(tmpdir.path(), "install");
1692 let result = execute("/skill install", &mut app);
1693 assert!(result.is_error, "management subcommand should show usage");
1694 assert!(
1695 result
1696 .message
1697 .as_deref()
1698 .is_some_and(|message| message.contains("/skill install"))
1699 );
1700 assert!(result.action.is_none());
1701 assert!(app.active_skill.is_none());
1702 }
1703 }
1704
1704 lines RUST