Skip to main content

atomcode_tuix/
commands.rs

1// crates/atomcode-tuix/src/commands.rs
2#[derive(Debug, Clone, Copy)]
3pub struct Command {
4    pub name: &'static str,
5    pub desc: &'static str,
6    /// Commands that are *useless* without an argument (e.g. `/background <task>`).
7    /// When the slash-menu Enter handler sees one, it auto-completes the name
8    /// with a trailing space and leaves the cursor parked for the user to
9    /// type the argument — instead of firing a bad invocation immediately.
10    /// Commands that do something sensible with no arg (e.g. `/cd` opens the
11    /// recent-dirs picker, `/help` prints help) leave this `false`.
12    pub needs_args: bool,
13}
14
15pub struct CommandRegistry {
16    commands: &'static [Command],
17}
18
19impl CommandRegistry {
20    pub fn builtin() -> Self {
21        Self {
22            commands: BUILTIN_COMMANDS,
23        }
24    }
25
26    pub fn all(&self) -> &'static [Command] {
27        self.commands
28    }
29
30    pub fn find(&self, name: &str) -> Option<Command> {
31        // Built-in command names are all ASCII, so an ASCII
32        // case-insensitive match is equivalent to a Unicode-correct
33        // one here. `/SESSION` resolves to the same `session` entry
34        // as `/session`.
35        self.commands
36            .iter()
37            .find(|c| c.name.eq_ignore_ascii_case(name))
38            .copied()
39    }
40
41    pub fn matching_prefix(&self, prefix: &str) -> Vec<Command> {
42        let prefix_lower = prefix.to_ascii_lowercase();
43        self.commands
44            .iter()
45            .filter(|c| c.name.starts_with(prefix_lower.as_str()))
46            .copied()
47            .collect()
48    }
49
50    pub fn help_text(&self) -> String {
51        use crate::i18n::{t, Msg};
52        let max_name = self
53            .commands
54            .iter()
55            .map(|c| c.name.len())
56            .max()
57            .unwrap_or(6);
58        let mut out = t(Msg::HelpAvailableCommands).into_owned();
59        for c in self.commands {
60            let desc = cmd_desc_i18n(c.name).unwrap_or_else(|| c.desc.into());
61            out.push_str(&format!(
62                "    /{:<width$}  {}\n",
63                c.name,
64                desc,
65                width = max_name
66            ));
67        }
68        out
69    }
70}
71
72const BUILTIN_COMMANDS: &[Command] = &[
73    Command { name: "codingplan", desc: "Claim CodingPlan + set up models from the plan's model list", needs_args: false },
74    Command { name: "setup",      desc: "First run: install recommender skill + run it. Extra text forwarded as a steering hint", needs_args: true },
75    Command { name: "resume",  desc: "Resume a previous session", needs_args: false },
76    Command { name: "rename",  desc: "Rename current session", needs_args: true },
77    Command { name: "login",   desc: "Sign in with AtomGit OAuth", needs_args: false },
78    Command { name: "logout",  desc: "Sign out of AtomGit", needs_args: false },
79    Command { name: "whoami",  desc: "Show current logged-in user", needs_args: false },
80    Command { name: "model",   desc: "Switch provider / model", needs_args: false },
81    Command { name: "provider", desc: "Manage providers (add / edit / delete)", needs_args: false },
82    Command { name: "status",  desc: "Show session status", needs_args: false },
83    Command { name: "config",  desc: "Show config path", needs_args: false },
84    Command { name: "reload",  desc: "Reload ~/.atomcode/config.toml from disk", needs_args: false },
85    Command { name: "cd",      desc: "Change working directory", needs_args: false },
86    Command { name: "init",    desc: "Generate .atomcode.md project instructions from the working directory", needs_args: false },
87    Command { name: "bg",      desc: "Background sessions: /bg, /bg list, /bg <N>, /bg drop <N>", needs_args: false },
88    Command { name: "background", desc: "Compatibility alias: start a one-shot task in a /bg slot", needs_args: true },
89    Command { name: "diff",    desc: "Show git diff", needs_args: false },
90    Command { name: "clear",   desc: "Clear screen", needs_args: false },
91    Command { name: "session", desc: "Start a new session (clears conversation)", needs_args: false },
92    Command { name: "cost",    desc: "Show token cost", needs_args: false },
93    Command { name: "context", desc: "Show context budget breakdown", needs_args: false },
94    Command { name: "compact", desc: "Compact conversation history", needs_args: false },
95    Command { name: "remember", desc: "Save a fact to memory (/remember --global for global)", needs_args: true },
96    Command { name: "forget", desc: "Remove matching memories", needs_args: true },
97    Command { name: "memory", desc: "Show all saved memories", needs_args: false },
98    Command { name: "mcp",     desc: "Show MCP server status (subcommands: reload, tools, login, logout)", needs_args: false },
99    Command { name: "undo",    desc: "Undo last change (not yet supported)", needs_args: false },
100    Command { name: "worktree", desc: "Git worktree isolation (create/list/done/cleanup)", needs_args: true },
101    Command { name: "upgrade", desc: "Upgrade atomcode to latest (subcommand: rollback)", needs_args: false },
102    Command { name: "issue",   desc: "Report a bug / request a feature for AtomCode itself (interactive wizard)", needs_args: false },
103    Command { name: "plan",    desc: "Switch to Plan mode (read-only exploration)", needs_args: false },
104    Command { name: "build",   desc: "Switch to Build mode (full execution)", needs_args: false },
105    Command { name: "think",   desc: "Extended thinking control (on/off/budget N)", needs_args: false },
106    Command { name: "help",    desc: "Show this help", needs_args: false },
107    Command { name: "keys",    desc: "Show keyboard shortcuts", needs_args: false },
108    Command { name: "language", desc: "Switch display language", needs_args: false },
109    Command { name: "welcome", desc: "Re-run the onboarding wizard", needs_args: false },
110    Command { name: "quit",    desc: "Exit AtomCode", needs_args: false },
111    // Gateway entry that opens a second-level palette listing all
112    // user-invocable skills. needs_args=true so Enter rewrites the
113    // buffer to `/skills ` and lets the sub-mode menu render the
114    // skill list. Selecting a skill commits as `/skills <name>` →
115    // dispatched by the `skills` arm in execute_slash_command.
116    Command { name: "skills",  desc: "Browse loaded skills", needs_args: true },
117    Command { name: "plugin",  desc: "Plugin marketplace (subcommands: marketplace, install, uninstall, list)", needs_args: true },
118    // Windows fallback for Ctrl+V: Windows Terminal / conhost
119    // intercept Ctrl+V as their own `paste` action (which forwards
120    // only `CF_UNICODETEXT`) before the keystroke reaches atomcode,
121    // so an image-only clipboard never triggers the in-app handler.
122    // `/paste` calls the same `try_paste_clipboard_image` →
123    // `attach_image_to_input` pipeline directly so the user has a
124    // terminal-agnostic way to attach an image. Works on every OS.
125    Command { name: "paste",   desc: "Attach an image from the clipboard (Windows fallback for Ctrl+V)", needs_args: false },
126];
127
128/// Look up the i18n translation for a built-in command description.
129/// Returns `None` for unknown command names (callers fall back to
130/// the static `desc` field).
131pub fn cmd_desc_i18n(name: &str) -> Option<std::borrow::Cow<'static, str>> {
132    use crate::i18n::{t, Msg};
133    let msg = match name {
134        "setup" => Msg::CmdDescSetup,
135        "codingplan" => Msg::CmdDescCodingplan,
136        "resume" => Msg::CmdDescResume,
137        "rename" => Msg::CmdDescRename,
138        "login" => Msg::CmdDescLogin,
139        "logout" => Msg::CmdDescLogout,
140        "whoami" => Msg::CmdDescWhoami,
141        "model" => Msg::CmdDescModel,
142        "provider" => Msg::CmdDescProvider,
143        "status" => Msg::CmdDescStatus,
144        "config" => Msg::CmdDescConfig,
145        "reload" => Msg::CmdDescReload,
146        "cd" => Msg::CmdDescCd,
147        "init" => Msg::CmdDescInit,
148        "bg" => Msg::CmdDescBg,
149        "background" => Msg::CmdDescBackground,
150        "diff" => Msg::CmdDescDiff,
151        "clear" => Msg::CmdDescClear,
152        "session" => Msg::CmdDescSession,
153        "cost" => Msg::CmdDescCost,
154        "context" => Msg::CmdDescContext,
155        "compact" => Msg::CmdDescCompact,
156        "remember" => Msg::CmdDescRemember,
157        "forget" => Msg::CmdDescForget,
158        "memory" => Msg::CmdDescMemory,
159        "mcp" => Msg::CmdDescMcp,
160        "undo" => Msg::CmdDescUndo,
161        "worktree" => Msg::CmdDescWorktree,
162        "upgrade" => Msg::CmdDescUpgrade,
163        "issue" => Msg::CmdDescIssue,
164        "plan" => Msg::CmdDescPlan,
165        "build" => Msg::CmdDescBuild,
166        "think" => Msg::CmdDescThink,
167        "help" => Msg::CmdDescHelp,
168        "keys" => Msg::CmdDescKeys,
169        "language" => Msg::CmdDescLanguage,
170        "welcome" => Msg::CmdWelcomeDescription,
171        "quit" => Msg::CmdDescQuit,
172        "skills" => Msg::CmdDescSkills,
173        "plugin" => Msg::CmdDescPlugin,
174        "paste" => Msg::CmdDescPaste,
175        _ => return None,
176    };
177    Some(t(msg))
178}
179
180/// A completion candidate for slash-command Tab completion, merging built-in
181/// and user-defined custom commands.
182#[derive(Debug, Clone)]
183pub struct CompletionCandidate {
184    pub name: String,
185    pub description: String,
186    pub is_custom: bool,
187}
188
189/// Merge built-in and custom command completions for a given prefix.
190/// Results are sorted with built-ins first, then custom commands, each
191/// group sorted by name. Custom commands whose names collide with a
192/// built-in are suppressed.
193pub fn complete_commands(
194    prefix: &str,
195    custom_names: &[(String, String)],
196) -> Vec<CompletionCandidate> {
197    let prefix = prefix.strip_prefix('/').unwrap_or(prefix);
198    let mut candidates = Vec::new();
199    for cmd in BUILTIN_COMMANDS {
200        if cmd.name.starts_with(prefix) {
201            candidates.push(CompletionCandidate {
202                name: cmd.name.to_string(),
203                description: cmd_desc_i18n(cmd.name)
204                    .map(|cow| cow.into_owned())
205                    .unwrap_or_else(|| cmd.desc.to_string()),
206                is_custom: false,
207            });
208        }
209    }
210    for (name, desc) in custom_names {
211        if name.starts_with(prefix) && !candidates.iter().any(|c| c.name == *name) {
212            candidates.push(CompletionCandidate {
213                name: name.clone(),
214                description: desc.clone(),
215                is_custom: true,
216            });
217        }
218    }
219    candidates.sort_by_key(|c| (c.is_custom, c.name.clone()));
220    candidates
221}
222
223/// Parse `"/cmd args..."` into `(cmd, args)` when the leading `/` is a
224/// command invocation. Returns `None` when the `/` is actually part of a
225/// filesystem path, URL, or any other text the user wants sent to the
226/// agent verbatim.
227///
228/// A valid command name is ASCII alphanumeric + `_`/`-`, followed by
229/// whitespace or end-of-input. `/Users/me`, `/tmp`, `/https://...`,
230/// `/path/with/mixed/字符` all fail the shape test and fall through to
231/// agent dispatch.
232pub fn parse_slash_line(s: &str) -> Option<(&str, &str)> {
233    let rest = s.strip_prefix('/')?;
234    // Allow `:` in command names so namespaced skills like
235    // `/skills:brainstorming` (loose skill, atomcode prefix) and
236    // `/superpowers:writing-plans` (Claude Code plugin convention)
237    // parse as a single command name. Paths like `/Users/me/...` are
238    // still rejected by the non-whitespace follow-on check below.
239    let name_end = rest
240        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == ':'))
241        .unwrap_or(rest.len());
242    if name_end == 0 {
243        return None;
244    }
245    let name = &rest[..name_end];
246    let after = &rest[name_end..];
247    match after.chars().next() {
248        None => Some((name, "")),
249        Some(c) if c.is_whitespace() => Some((name, after.trim_start())),
250        // Non-space follow-on (`/`, `.`, etc.) means the `/` was
251        // a literal character in a path / URL — not a command.
252        _ => None,
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn registry_lookup_by_name() {
262        let reg = CommandRegistry::builtin();
263        assert!(reg.find("quit").is_some());
264        assert!(reg.find("nonexistent").is_none());
265    }
266
267    #[test]
268    fn builtin_contains_bg_command() {
269        let registry = CommandRegistry::builtin();
270        let cmd = registry.find("bg").unwrap();
271        assert_eq!(cmd.name, "bg");
272        assert!(!cmd.needs_args);
273    }
274
275    #[test]
276    fn tab_completion_finds_prefix_matches() {
277        let reg = CommandRegistry::builtin();
278        let matches = reg.matching_prefix("h");
279        assert!(matches.iter().any(|c| c.name == "help"));
280    }
281
282    #[test]
283    fn keys_command_is_registered_with_i18n_description_in_both_locales() {
284        // `/keys` should appear in the built-in completion list and
285        // resolve a non-empty description in every shipped locale —
286        // if a translator misses one, the slash menu shows the bare
287        // English fallback (CmdDescKeys default) and we want that to
288        // be a test failure, not a UI regression.
289        use crate::i18n::{Locale, Msg};
290        let reg = CommandRegistry::builtin();
291        let keys_cmd = reg
292            .matching_prefix("keys")
293            .into_iter()
294            .find(|c| c.name == "keys")
295            .expect("/keys must be a built-in command");
296        assert!(!keys_cmd.needs_args);
297
298        // i18n round-trip per locale: both the slash-menu description
299        // and the KeybindingsHelp body must produce non-empty text
300        // and carry the canonical keystroke labels. Snapshot the
301        // current locale up front and restore at the end so we don't
302        // poison parallel tests / future tests by leaving a side
303        // effect behind. `set_locale` is process-global.
304        let prev = crate::i18n::current_locale();
305        for locale in [Locale::En, Locale::ZhCn] {
306            crate::i18n::set_locale(locale);
307            let desc = cmd_desc_i18n("keys").expect("CmdDescKeys translation");
308            assert!(
309                !desc.trim().is_empty(),
310                "CmdDescKeys ({locale:?}) must not be empty"
311            );
312            let body = crate::i18n::t(Msg::KeybindingsHelp);
313            assert!(
314                body.contains("Ctrl+C"),
315                "KeybindingsHelp ({locale:?}) must list Ctrl+C — got:\n{body}"
316            );
317            assert!(
318                body.contains("Enter"),
319                "KeybindingsHelp ({locale:?}) must list Enter — got:\n{body}"
320            );
321        }
322        crate::i18n::set_locale(prev);
323    }
324
325    #[test]
326    fn tab_completion_empty_for_unknown() {
327        let reg = CommandRegistry::builtin();
328        let matches = reg.matching_prefix("zzzzz");
329        assert!(matches.is_empty());
330    }
331
332    #[test]
333    fn parse_extracts_command_and_args() {
334        let (cmd, arg) = parse_slash_line("/cd ~/projects").unwrap();
335        assert_eq!(cmd, "cd");
336        assert_eq!(arg, "~/projects");
337    }
338
339    #[test]
340    fn parse_no_args() {
341        let (cmd, arg) = parse_slash_line("/quit").unwrap();
342        assert_eq!(cmd, "quit");
343        assert_eq!(arg, "");
344    }
345
346    #[test]
347    fn parse_non_slash_returns_none() {
348        assert!(parse_slash_line("hello").is_none());
349    }
350
351    #[test]
352    fn parse_rejects_path_starting_with_slash() {
353        // A filesystem path the user pastes must reach the agent
354        // untouched, not trigger "Unknown command: /Users/...".
355        assert!(parse_slash_line("/Users/me/file.txt").is_none());
356        assert!(parse_slash_line("/tmp/x").is_none());
357        assert!(parse_slash_line("/path/with/中文/pic.png").is_none());
358    }
359
360    #[test]
361    fn parse_accepts_colon_namespaced_command() {
362        // Skills load under a `skills:` namespace; plugins (future) use
363        // their manifest name. The parser must keep the colon segment as
364        // part of the command name, not split on it.
365        let (cmd, arg) = parse_slash_line("/skills:brainstorming").unwrap();
366        assert_eq!(cmd, "skills:brainstorming");
367        assert_eq!(arg, "");
368
369        let (cmd, arg) = parse_slash_line("/skills:brainstorming why is X").unwrap();
370        assert_eq!(cmd, "skills:brainstorming");
371        assert_eq!(arg, "why is X");
372
373        let (cmd, _) = parse_slash_line("/superpowers:writing-plans").unwrap();
374        assert_eq!(cmd, "superpowers:writing-plans");
375    }
376
377    #[test]
378    fn parse_rejects_url_starting_with_slash() {
379        assert!(parse_slash_line("/https://example.com/x").is_none());
380    }
381
382    #[test]
383    fn parse_command_with_slash_argument_ok() {
384        // `/cd /path` is a command with a path argument — the second
385        // slash sits in args, not the command name.
386        let (cmd, arg) = parse_slash_line("/cd /tmp/x").unwrap();
387        assert_eq!(cmd, "cd");
388        assert_eq!(arg, "/tmp/x");
389    }
390
391    #[test]
392    fn parse_rejects_cjk_touching_command_name() {
393        // `/session是干什么的` — the user is asking the agent "what
394        // does /session do", NOT invoking /session. A CJK char
395        // directly after the command name (no whitespace) means it's
396        // prose, so parse_slash_line must return None and the line
397        // reaches the agent verbatim.
398        assert!(parse_slash_line("/session是干什么的").is_none());
399        assert!(parse_slash_line("/quit退出吗").is_none());
400        assert!(parse_slash_line("/model模型").is_none());
401    }
402
403    #[test]
404    fn parse_accepts_command_with_cjk_arg_after_space() {
405        // Whitespace separates cmd from args, so `/session 是干什么的`
406        // IS an invocation (with CJK-tail arg).
407        let (cmd, arg) = parse_slash_line("/session 是干什么的").unwrap();
408        assert_eq!(cmd, "session");
409        assert_eq!(arg, "是干什么的");
410    }
411
412    #[test]
413    fn help_text_lists_all_commands() {
414        let reg = CommandRegistry::builtin();
415        let help = reg.help_text();
416        for c in reg.all() {
417            assert!(help.contains(c.name), "help missing {}", c.name);
418        }
419    }
420
421    #[test]
422    fn complete_builtin_commands() {
423        let candidates = complete_commands("mo", &[]);
424        assert!(
425            candidates.iter().any(|c| c.name == "model"),
426            "\"mo\" should match built-in \"model\""
427        );
428        assert!(
429            candidates.iter().all(|c| !c.is_custom),
430            "built-in-only query should have no custom candidates"
431        );
432    }
433
434    #[test]
435    fn complete_custom_commands() {
436        let custom = vec![("review".to_string(), "Code review".to_string())];
437        let candidates = complete_commands("rev", &custom);
438        assert!(
439            candidates.iter().any(|c| c.name == "review" && c.is_custom),
440            "\"rev\" should match custom \"review\""
441        );
442    }
443
444    #[test]
445    fn builtin_takes_precedence() {
446        // Custom "help" should NOT appear because built-in "help" exists.
447        let custom = vec![("help".to_string(), "Custom help".to_string())];
448        let candidates = complete_commands("help", &custom);
449        let help_count = candidates.iter().filter(|c| c.name == "help").count();
450        assert_eq!(
451            help_count, 1,
452            "custom \"help\" must not duplicate built-in \"help\""
453        );
454        assert!(
455            !candidates.iter().any(|c| c.name == "help" && c.is_custom),
456            "the surviving \"help\" must be the built-in, not custom"
457        );
458    }
459
460    #[test]
461    fn empty_prefix_returns_all() {
462        let custom = vec![
463            ("review".to_string(), "Code review".to_string()),
464            ("deploy".to_string(), "Deploy app".to_string()),
465        ];
466        let candidates = complete_commands("", &custom);
467        // At least all 25 built-in commands + 2 custom
468        assert!(
469            candidates.len() >= 20,
470            "empty prefix should return at least 20 results, got {}",
471            candidates.len()
472        );
473        // Custom commands should be present
474        assert!(candidates.iter().any(|c| c.name == "review"));
475        assert!(candidates.iter().any(|c| c.name == "deploy"));
476    }
477
478    #[test]
479    fn complete_commands_strips_leading_slash() {
480        // Calling with "/mo" should behave identically to "mo".
481        let with_slash = complete_commands("/mo", &[]);
482        let without_slash = complete_commands("mo", &[]);
483        assert_eq!(with_slash.len(), without_slash.len());
484        for (a, b) in with_slash.iter().zip(without_slash.iter()) {
485            assert_eq!(a.name, b.name);
486        }
487    }
488}