1#[derive(Debug, Clone, Copy)]
3pub struct Command {
4 pub name: &'static str,
5 pub desc: &'static str,
6 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 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 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 Command { name: "paste", desc: "Attach an image from the clipboard (Windows fallback for Ctrl+V)", needs_args: false },
126];
127
128pub 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#[derive(Debug, Clone)]
183pub struct CompletionCandidate {
184 pub name: String,
185 pub description: String,
186 pub is_custom: bool,
187}
188
189pub 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
223pub fn parse_slash_line(s: &str) -> Option<(&str, &str)> {
233 let rest = s.strip_prefix('/')?;
234 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 _ => 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 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 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 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 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 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 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 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 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 assert!(
469 candidates.len() >= 20,
470 "empty prefix should return at least 20 results, got {}",
471 candidates.len()
472 );
473 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 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}