返回 CodeWhale
traits.rs
根目录 / crates / tui / src / commands / traits.rs
1 //! Command traits and registry support.
2
3 use std::borrow::Cow;
4 use std::collections::HashMap;
5
6 use crate::tui::app::App;
7 use codewhale_localization::{Locale, MessageId, tr};
8
9 use super::CommandResult;
10
11 #[derive(Debug, Clone, Copy)]
12 pub struct CommandInfo {
13 pub name: &'static str,
14 pub aliases: &'static [&'static str],
15 pub usage: &'static str,
16 pub description_id: MessageId,
17 }
18
19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 pub enum CommandDiscovery {
21 Primary,
22 Advanced,
23 Compatibility,
24 }
25
26 pub(crate) const ADVANCED_DISCOVERY_COMMANDS: &[&str] = &[
27 "anchor",
28 "balance",
29 "cache",
30 "change",
31 "context",
32 "diff",
33 "edit",
34 "hf",
35 "lsp",
36 "modeldb",
37 "models",
38 "network",
39 "plugin",
40 "preview-request",
41 "profile",
42 "purge",
43 "relay",
44 "rename",
45 "rlm",
46 "settings",
47 "share",
48 "workbar",
49 "status",
50 "system",
51 "theme",
52 "tools",
53 "trust",
54 "verbose",
55 ];
56
57 pub(crate) const COMPATIBILITY_DISCOVERY_COMMANDS: &[&str] = &["subagents"];
58
59 /// Commands that exist and run, but are not advertised anywhere the operator
60 /// browses: not in slash completion, not in `/help`, not in the palette.
61 ///
62 /// This is for surfaces that are real but not ready to be recommended. Typing
63 /// the command still works, the `codewhale <verb>` CLI is untouched, and the
64 /// hotbar still registers a binding for it — `codewhale-lane` resolves its
65 /// control-plane actions through that registry, so it is a substrate rather
66 /// than a place to browse. The entry here only stops the product from
67 /// *teaching* a route it is not standing behind yet. Founder, 2026-09-03:
68 /// "remove /lane", "hide dispatch for now".
69 pub(crate) const UNLISTED_COMMANDS: &[&str] = &["lane", "dispatch"];
70
71 /// Small, task-oriented starting set for a bare `/` in the composer.
72 ///
73 /// The full command catalog remains searchable through `/help`, the command
74 /// palette, and by typing any command prefix. `agents` is the preferred alias
75 /// for the compatibility-owned `subagents` command.
76 pub(crate) const BARE_SLASH_DISCOVERY_COMMANDS: &[&str] =
77 &["help", "setup", "model", "settings", "resume", "rc"];
78
79 #[must_use]
80 pub(crate) fn bare_slash_discovery_rank(name: &str) -> Option<usize> {
81 BARE_SLASH_DISCOVERY_COMMANDS
82 .iter()
83 .position(|entry| *entry == name)
84 }
85
86 /// Built-in commands that the palette pastes into the composer instead of
87 /// executing, even though they have no *required* argument.
88 ///
89 /// Prefer keeping this empty. Every name here must be a registered canonical
90 /// command name — see `palette_paste_only_names_are_registered` in the
91 /// command palette tests.
92 pub(crate) const PALETTE_PASTE_ONLY: &[&str] = &[];
93
94 impl CommandDiscovery {
95 pub fn show_at_root(self) -> bool {
96 matches!(self, CommandDiscovery::Primary)
97 }
98 }
99
100 /// Bare words in a usage line that stand for a value the operator supplies
101 /// rather than a literal token they type. `/workspace [path|worktrees]` reads
102 /// the same to a parser either way, so the metavariables are named once here
103 /// instead of being re-encoded as a second usage source next to the registry.
104 const USAGE_METAVARIABLES: &[&str] = &[
105 "args", "command", "days", "dir", "key", "message", "model", "name", "path", "prompt", "query",
106 "text", "url", "value",
107 ];
108
109 /// Literal subcommands declared by a `usage` line, in declaration order.
110 ///
111 /// The registry's `usage` strings are the only place argument shapes are
112 /// written down, so this reads them instead of adding a parallel
113 /// `subcommands` field that would drift. It is deliberately conservative:
114 /// anything that does not look like a typed word — `<placeholder>`, `--flag`,
115 /// `snake_case` metavariables, `path/to/file` — is skipped, so an unfamiliar
116 /// usage shape yields no hint rather than a wrong one.
117 #[must_use]
118 pub fn usage_subcommands(usage: &str) -> Vec<&str> {
119 let Some((_, rest)) = usage.trim().split_once(char::is_whitespace) else {
120 return Vec::new();
121 };
122 // Only the first argument group names subcommands. A later group is a
123 // modifier on whichever one was chosen (`/workbar […] [--save]`).
124 let group = first_top_level_group(rest.trim_start());
125 let mut subcommands: Vec<&str> = Vec::new();
126 for alternative in split_top_level_alternatives(strip_one_bracket(group)) {
127 let Some(token) = leading_literal_token(alternative) else {
128 continue;
129 };
130 if !subcommands.contains(&token) {
131 subcommands.push(token);
132 }
133 }
134 subcommands
135 }
136
137 /// The first whitespace-separated chunk of `rest`, counting `[` and `<` so a
138 /// group containing spaces (`[open <id>|prune <days>]`) stays whole.
139 fn first_top_level_group(rest: &str) -> &str {
140 let mut depth = 0usize;
141 for (idx, ch) in rest.char_indices() {
142 match ch {
143 '[' | '<' => depth += 1,
144 ']' | '>' => depth = depth.saturating_sub(1),
145 _ if ch.is_whitespace() && depth == 0 => return &rest[..idx],
146 _ => {}
147 }
148 }
149 rest
150 }
151
152 /// Remove one enclosing `[…]`, or one enclosing `<…>` that fences a choice.
153 ///
154 /// `<entry_id>` is a value and stays wrapped so it is skipped later;
155 /// `<turn <n>|plan>` is a required choice between literal verbs and is opened.
156 fn strip_one_bracket(group: &str) -> &str {
157 let (open, close) = match group.chars().next() {
158 Some('[') => ('[', ']'),
159 Some('<') => ('<', '>'),
160 _ => return group,
161 };
162 if !group.ends_with(close) {
163 return group;
164 }
165 let inner = &group[open.len_utf8()..group.len() - close.len_utf8()];
166 if !brackets_balanced(inner) {
167 return group;
168 }
169 if open == '<' && split_top_level_alternatives(inner).len() < 2 {
170 return group;
171 }
172 inner
173 }
174
175 fn brackets_balanced(text: &str) -> bool {
176 let mut depth = 0isize;
177 for ch in text.chars() {
178 match ch {
179 '[' | '<' => depth += 1,
180 ']' | '>' => depth -= 1,
181 _ => {}
182 }
183 if depth < 0 {
184 return false;
185 }
186 }
187 depth == 0
188 }
189
190 /// Split on `|` that is not nested inside a `[…]` or `<…>`.
191 fn split_top_level_alternatives(group: &str) -> Vec<&str> {
192 let mut parts = Vec::new();
193 let mut depth = 0usize;
194 let mut start = 0usize;
195 for (idx, ch) in group.char_indices() {
196 match ch {
197 '[' | '<' => depth += 1,
198 ']' | '>' => depth = depth.saturating_sub(1),
199 '|' if depth == 0 => {
200 parts.push(group[start..idx].trim());
201 start = idx + ch.len_utf8();
202 }
203 _ => {}
204 }
205 }
206 parts.push(group[start..].trim());
207 parts.retain(|part| !part.is_empty());
208 parts
209 }
210
211 /// The literal verb an alternative starts with, or `None` when it starts with
212 /// a placeholder, a flag, or a metavariable.
213 fn leading_literal_token(alternative: &str) -> Option<&str> {
214 let end = alternative
215 .find(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-')
216 .unwrap_or(alternative.len());
217 let token = &alternative[..end];
218 // A word only counts when it is spelled the way a typed verb is spelled:
219 // starting lowercase (or a digit, for `/mode [act|plan|1|2|3]`) and made
220 // of ASCII word characters. `N`, `entry_id`, `--force` and
221 // `path/to/export.json` are all values, not verbs.
222 let mut chars = token.chars();
223 let first = chars.next()?;
224 if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
225 return None;
226 }
227 let trailing = &alternative[end..];
228 if trailing
229 .chars()
230 .next()
231 .is_some_and(|ch| matches!(ch, '_' | '/' | '.'))
232 {
233 return None;
234 }
235 if USAGE_METAVARIABLES.contains(&token) {
236 return None;
237 }
238 Some(token)
239 }
240
241 impl CommandInfo {
242 /// Literal subcommands this command's `usage` line declares.
243 ///
244 /// Used by the slash menu to list `/workspace worktrees` and friends
245 /// once the command name has been typed.
246 #[must_use]
247 pub fn subcommands(&self) -> Vec<&'static str> {
248 usage_subcommands(self.usage)
249 }
250
251 pub fn requires_argument(&self) -> bool {
252 self.usage.contains('<') || self.usage.contains('[')
253 }
254
255 pub fn requires_required_argument(&self) -> bool {
256 let mut optional_depth = 0usize;
257 for ch in self.usage.chars() {
258 match ch {
259 '[' => optional_depth += 1,
260 ']' => optional_depth = optional_depth.saturating_sub(1),
261 '<' if optional_depth == 0 => return true,
262 _ => {}
263 }
264 }
265 false
266 }
267
268 /// Whether the slash menu / composer should leave a trailing space so the
269 /// user can type arguments immediately. `/change` is bare-useful (opens
270 /// the latest changelog) even though its usage documents an optional
271 /// version, so it is the only historical carve-out.
272 pub fn composer_wants_trailing_space(&self) -> bool {
273 self.name != "change" && self.requires_argument()
274 }
275
276 /// Whether the command palette should run this command immediately when
277 /// selected, instead of pasting it into the composer.
278 ///
279 /// Default: run anything that does not require a mandatory positional
280 /// argument (including optional-arg commands that open a picker when bare).
281 /// [`PALETTE_PASTE_ONLY`] is the explicit opt-out for side-effectful or
282 /// multi-step no-arg commands that should still paste for confirmation.
283 pub fn palette_runs_directly(&self) -> bool {
284 if self.requires_required_argument() {
285 return false;
286 }
287 !PALETTE_PASTE_ONLY.contains(&self.name)
288 }
289
290 pub fn palette_command(&self) -> String {
291 if self.requires_argument() {
292 format!("/{} ", self.name)
293 } else {
294 format!("/{}", self.name)
295 }
296 }
297
298 pub fn description_for(&self, locale: Locale) -> Cow<'static, str> {
299 tr(locale, self.description_id)
300 }
301
302 pub fn palette_description_for(&self, locale: Locale) -> String {
303 let desc = self.description_for(locale);
304 if self.aliases.is_empty() {
305 desc.to_string()
306 } else {
307 format!("{} aliases: {}", desc, self.aliases.join(", "))
308 }
309 }
310
311 pub fn discovery(&self) -> CommandDiscovery {
312 if COMPATIBILITY_DISCOVERY_COMMANDS.contains(&self.name) {
313 CommandDiscovery::Compatibility
314 } else if ADVANCED_DISCOVERY_COMMANDS.contains(&self.name) {
315 CommandDiscovery::Advanced
316 } else {
317 CommandDiscovery::Primary
318 }
319 }
320
321 pub fn show_in_empty_discovery(&self) -> bool {
322 self.discovery().show_at_root()
323 }
324
325 /// Whether this command may appear in slash completion at all.
326 ///
327 /// Always: the menu is how the command surface is discovered, so hiding
328 /// commands from it makes them unfindable. Founder live-test: "I like how
329 /// we prioritize the slash thing but it should still be able to find all
330 /// of them." A bare `/` used to return only
331 /// [`BARE_SLASH_DISCOVERY_COMMANDS`], which is a *ranking* concern — the
332 /// menu already sorts those six to the top and the popup scrolls around
333 /// the selection, so the short list is preserved as the head of a
334 /// complete one rather than as the whole of a truncated one.
335 pub fn show_in_slash_completion(&self, _prefix: &str) -> bool {
336 !self.is_unlisted()
337 }
338
339 /// Whether this command is deliberately not advertised — see
340 /// [`UNLISTED_COMMANDS`]. It still runs when typed.
341 #[must_use]
342 pub fn is_unlisted(&self) -> bool {
343 UNLISTED_COMMANDS
344 .iter()
345 .any(|name| self.name == *name || self.aliases.contains(name))
346 }
347 }
348
349 pub trait Command: Send + Sync {
350 fn info(&self) -> &'static CommandInfo;
351 fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult;
352
353 /// FEAT-015 dual-path seam: if the entry carries a capability-scoped
354 /// handler, the dispatcher builds the envelope from `app` and calls it
355 /// here; otherwise the legacy `execute(app, args)` path is used. The
356 /// default keeps every existing entry legacy (D2).
357 fn contextual_handler(
358 &self,
359 ) -> Option<codewhale_command_contract::handler::CommandHandler<CommandResult>> {
360 None
361 }
362 }
363
364 pub trait CommandGroup: Send + Sync {
365 fn commands(&self) -> &'static [Box<dyn Command>];
366 }
367
368 pub(crate) type CommandHandler = fn(&mut App, Option<&str>) -> CommandResult;
369
370 /// Trait implemented by focused built-in command modules.
371 ///
372 /// A command module owns its metadata and exposes a static execution function
373 /// that the group registry can wire into [`FunctionCommand`].
374 pub trait RegisterCommand {
375 fn info() -> &'static CommandInfo;
376 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult;
377 }
378
379 pub(crate) struct FunctionCommand {
380 info: &'static CommandInfo,
381 handler: CommandHandler,
382 }
383
384 impl FunctionCommand {
385 pub(crate) const fn new(info: &'static CommandInfo, handler: CommandHandler) -> Self {
386 Self { info, handler }
387 }
388 }
389
390 impl Command for FunctionCommand {
391 fn info(&self) -> &'static CommandInfo {
392 self.info
393 }
394
395 fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult {
396 (self.handler)(app, args)
397 }
398 }
399
400 /// A registry entry that carries an optional capability-scoped handler.
401 ///
402 /// FEAT-015's dual-path seam (D2): migrated registrations may supply a
403 /// `CommandHandler<CommandResult>` (App-free; built from `CommandContexts`),
404 /// while unmigrated registrations keep the legacy `execute(app, args)` path.
405 /// This entry type is App-free — only the dispatcher in `commands/mod.rs`
406 /// touches `App` when it builds the envelope from the bundle.
407 ///
408 /// FEAT-015 ships no production contextual registration, so in production
409 /// builds this type is only referenced through the trait; the test fixture
410 /// (D6) constructs it under `#[cfg(test)]`. The allow is removed once a
411 /// production group migrates (FEAT-018+).
412 pub(crate) struct ContextualCommand {
413 info: &'static CommandInfo,
414 handler: Option<codewhale_command_contract::handler::CommandHandler<CommandResult>>,
415 legacy: Option<CommandHandler>,
416 }
417
418 impl ContextualCommand {
419 pub(crate) const fn contextual(
420 info: &'static CommandInfo,
421 handler: codewhale_command_contract::handler::CommandHandler<CommandResult>,
422 ) -> Self {
423 Self {
424 info,
425 handler: Some(handler),
426 legacy: None,
427 }
428 }
429
430 /// Bridge one portable contract registration into the TUI-owned registry.
431 ///
432 /// The command supplies only contract metadata and an App-free handler;
433 /// the TUI resolves the localization key and owns the resulting registry
434 /// entry. This is the dependency inversion later command crates reuse.
435 pub(crate) fn from_contract<C>() -> Result<Self, String>
436 where
437 C: codewhale_command_contract::metadata::RegisterCommand<CommandResult>,
438 {
439 let portable = C::info();
440 let description_id = super::contract::key_to_message_id(portable.description_key)
441 .ok_or_else(|| {
442 format!(
443 "unknown command description key {:?} for /{}",
444 portable.description_key, portable.name
445 )
446 })?;
447 let info = Box::leak(Box::new(CommandInfo {
448 name: portable.name,
449 aliases: portable.aliases,
450 usage: portable.usage,
451 description_id,
452 }));
453 Ok(Self::contextual(info, C::handler()))
454 }
455 }
456 impl Command for ContextualCommand {
457 fn info(&self) -> &'static CommandInfo {
458 self.info
459 }
460
461 fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult {
462 match self.legacy {
463 Some(legacy) => legacy(app, args),
464 None => CommandResult::error("command has no executable handler"),
465 }
466 }
467
468 fn contextual_handler(
469 &self,
470 ) -> Option<codewhale_command_contract::handler::CommandHandler<CommandResult>> {
471 self.handler.clone()
472 }
473 }
474 pub struct CommandRegistry {
475 commands: Vec<&'static dyn Command>,
476 name_to_index: HashMap<&'static str, usize>,
477 }
478
479 impl CommandRegistry {
480 pub fn empty() -> Self {
481 Self {
482 commands: Vec::new(),
483 name_to_index: HashMap::new(),
484 }
485 }
486
487 pub fn register(&mut self, command: &'static dyn Command) {
488 let index = self.commands.len();
489 let info = command.info();
490 self.name_to_index.insert(info.name, index);
491 for alias in info.aliases {
492 self.name_to_index.insert(alias, index);
493 }
494 self.commands.push(command);
495 }
496
497 pub fn register_group(&mut self, group: &dyn CommandGroup) {
498 for command in group.commands() {
499 self.register(command.as_ref());
500 }
501 }
502
503 /// FEAT-015: register a test-only contextual command under `#[cfg(test)]`.
504 /// The production registry is untouched (D6); the fixture dispatches
505 /// through the public `execute()` to prove the seam.
506 #[cfg(test)]
507 pub(crate) fn register_test_only(&mut self, command: &'static dyn Command) {
508 self.register(command);
509 }
510
511 pub fn get(&self, name: &str) -> Option<&dyn Command> {
512 let name = name.strip_prefix('/').unwrap_or(name);
513 self.name_to_index
514 .get(name)
515 .and_then(|index| self.commands.get(*index))
516 .copied()
517 }
518
519 pub fn get_info(&self, name: &str) -> Option<&'static CommandInfo> {
520 self.get(name).map(Command::info)
521 }
522
523 /// FEAT-015: whether the named entry has a capability-scoped handler.
524 /// Used by test assertions under `#[cfg(test)]`; production builds have
525 /// no contextual entries, so the method is dead there until a group
526 /// migrates (FEAT-018+).
527 #[cfg_attr(not(test), expect(dead_code))]
528 pub(crate) fn has_contextual_handler(&self, name: &str) -> bool {
529 self.get(name)
530 .is_some_and(|command| command.contextual_handler().is_some())
531 }
532
533 pub fn iter(&self) -> impl Iterator<Item = &dyn Command> {
534 self.commands.iter().copied()
535 }
536
537 pub fn infos(&self) -> Vec<&'static CommandInfo> {
538 self.iter().map(Command::info).collect()
539 }
540 }
541
542 #[cfg(test)]
543 mod usage_subcommand_tests {
544 use super::*;
545
546 use crate::commands::get_command_info;
547
548 #[test]
549 fn workspace_usage_names_the_worktree_manager() {
550 let workspace = get_command_info("workspace").expect("built-in workspace command");
551 assert_eq!(workspace.subcommands(), vec!["worktrees"]);
552 }
553
554 /// The managers #5952 names: each one hid behind a single word, and each
555 /// one now has its verbs on the menu the moment the name is typed.
556 #[test]
557 fn the_managers_the_issue_names_declare_their_verbs() {
558 for (name, expected) in [
559 ("workspace", vec!["worktrees"]),
560 (
561 "fleet",
562 vec!["members", "setup", "teams", "workers", "help"],
563 ),
564 (
565 "sessions",
566 vec!["show", "open", "archive", "unarchive", "prune"],
567 ),
568 (
569 "automation",
570 vec!["list", "show", "print", "pause", "resume", "delete", "run"],
571 ),
572 ] {
573 let info = get_command_info(name).expect("registered command");
574 assert_eq!(info.subcommands(), expected, "/{name}");
575 }
576 let mcp = get_command_info("mcp").expect("registered command");
577 for verb in ["init", "import", "add", "doctor", "reload"] {
578 assert!(
579 mcp.subcommands().contains(&verb),
580 "/mcp must offer `{verb}`: {:?}",
581 mcp.subcommands()
582 );
583 }
584 }
585
586 #[test]
587 fn a_choice_group_yields_every_literal_verb_once() {
588 assert_eq!(
589 usage_subcommands("/queue [list|send <n>|edit <n>|drop <n>|clear]"),
590 vec!["list", "send", "edit", "drop", "clear"]
591 );
592 // `/mcp` repeats `import` and `add` with different tails; the menu
593 // offers each verb once.
594 assert_eq!(
595 usage_subcommands(
596 "/mcp [init|import|import approve <name>|add stdio <name>|add http <name> <url>|doctor]"
597 ),
598 vec!["init", "import", "add", "doctor"]
599 );
600 }
601
602 #[test]
603 fn a_required_choice_between_verbs_is_opened_but_a_value_is_not() {
604 assert_eq!(
605 usage_subcommands(
606 "/structcopy <turn <n>|tool <call-id>|plan|workflow <run-id>> [stdout]"
607 ),
608 vec!["turn", "tool", "plan", "workflow"]
609 );
610 assert!(usage_subcommands("/branch <entry_id>").is_empty());
611 assert!(usage_subcommands("/rename <new title>").is_empty());
612 }
613
614 #[test]
615 fn a_bare_alternation_without_brackets_still_parses() {
616 assert_eq!(
617 usage_subcommands("/auth xai-device|chatgpt|chatgpt-revoke"),
618 vec!["xai-device", "chatgpt", "chatgpt-revoke"]
619 );
620 assert_eq!(usage_subcommands("/turn inspect"), vec!["inspect"]);
621 }
622
623 #[test]
624 fn values_flags_and_later_groups_are_not_offered_as_verbs() {
625 // Metavariables spelled bare, snake_case values, paths, flags, and a
626 // single uppercase placeholder are all values the operator supplies.
627 assert!(usage_subcommands("/help [command]").is_empty());
628 assert!(usage_subcommands("/profile <name>").is_empty());
629 assert!(usage_subcommands("/save [path]").is_empty());
630 assert!(usage_subcommands("/new [--force]").is_empty());
631 assert!(usage_subcommands("/agent [N] <task>").is_empty());
632 assert!(
633 usage_subcommands("/resume [session_id|path/to/export.json]").is_empty(),
634 "snake_case and path-shaped alternatives are values"
635 );
636 // Only the first group names subcommands; `[--save]` modifies whichever
637 // placement was chosen.
638 assert_eq!(
639 usage_subcommands("/workbar [bottom|top|off] [--save]"),
640 vec!["bottom", "top", "off"]
641 );
642 }
643
644 #[test]
645 fn a_command_without_arguments_declares_no_subcommands() {
646 assert!(usage_subcommands("/copy").is_empty());
647 assert!(usage_subcommands("").is_empty());
648 }
649
650 #[test]
651 fn every_registered_usage_parses_without_panicking() {
652 // The parser reads strings maintained by hand across ~120 commands;
653 // an unfamiliar shape must yield no hint rather than a panic.
654 for info in crate::commands::command_infos() {
655 let subcommands = info.subcommands();
656 for subcommand in subcommands {
657 assert!(
658 info.usage.contains(subcommand),
659 "/{}: `{subcommand}` is not in `{}`",
660 info.name,
661 info.usage
662 );
663 }
664 }
665 }
666 }
667
667 lines RUST