use crate::shared::i18n::Locale;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiCommand {
Settings,
SelfModel,
Chats,
Changes,
Tasks,
Help,
NewChat,
Rename,
AutoTitle,
Clone,
Copy,
Regen,
Continue,
Takeback,
Impersonate,
Stop,
Find,
Search,
Links,
Thoughts,
ToolCalls,
Subagents,
Mouse,
Emoji,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Arity {
None,
Optional,
Required,
Subcommand(&'static [&'static str]),
}
pub struct Spec {
pub aliases: &'static [&'static str],
pub command: UiCommand,
pub arity: Arity,
pub label: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parsed {
pub command: UiCommand,
pub argument: String,
pub alias: &'static str,
}
const fn row(
aliases: &'static [&'static str],
command: UiCommand,
arity: Arity,
label: &'static str,
description: &'static str,
) -> Spec {
Spec {
aliases,
command,
arity,
label,
description,
}
}
#[rustfmt::skip]
pub const COMMANDS: &[Spec] = &[
row(&["/settings"], UiCommand::Settings, Arity::None, "/settings", "ui.help.cmd_settings"),
row(&["/self"], UiCommand::SelfModel, Arity::Subcommand(&["clear"]), "ui.help.k.self", "ui.help.cmd_self"),
row(&["/chats"], UiCommand::Chats, Arity::None, "/chats", "ui.help.cmd_chats"),
row(&["/changes"], UiCommand::Changes, Arity::None, "/changes", "ui.help.cmd_changes"),
row(&["/tasks"], UiCommand::Tasks, Arity::Subcommand(&["stop"]), "ui.help.k.tasks", "ui.help.cmd_tasks"),
row(&["/help"], UiCommand::Help, Arity::None, "/help", "ui.help.cmd_help"),
row(&["/new"], UiCommand::NewChat, Arity::Optional, "ui.help.k.new", "ui.help.cmd_new"),
row(&["/rename"], UiCommand::Rename, Arity::Optional, "ui.help.k.rename", "ui.help.cmd_rename"),
row(&["/autotitle"], UiCommand::AutoTitle, Arity::None, "/autotitle", "ui.help.cmd_autotitle"),
row(&["/clone"], UiCommand::Clone, Arity::None, "/clone", "ui.help.cmd_clone"),
row(&["/copy"], UiCommand::Copy, Arity::None, "/copy", "ui.help.cmd_copy"),
row(&["/regen", "/retry"], UiCommand::Regen, Arity::None, "/regen · /retry", "ui.help.cmd_regen"),
row(&["/continue"], UiCommand::Continue, Arity::None, "/continue", "ui.help.cmd_continue"),
row(&["/takeback"], UiCommand::Takeback, Arity::None, "/takeback", "ui.help.cmd_takeback"),
row(&["/impersonate"], UiCommand::Impersonate, Arity::Optional, "ui.help.k.impersonate", "ui.help.cmd_impersonate"),
row(&["/stop"], UiCommand::Stop, Arity::None, "/stop", "ui.help.cmd_stop"),
row(&["/find"], UiCommand::Find, Arity::Optional, "ui.help.k.find", "ui.help.cmd_find"),
row(&["/search"], UiCommand::Search, Arity::Required, "ui.help.k.search", "ui.help.cmd_search"),
row(&["/links"], UiCommand::Links, Arity::None, "/links", "ui.help.cmd_links"),
row(&["/thoughts"], UiCommand::Thoughts, Arity::None, "/thoughts", "ui.help.cmd_thoughts"),
row(&["/toolcalls"], UiCommand::ToolCalls, Arity::None, "/toolcalls", "ui.help.cmd_toolcalls"),
row(&["/subagents"], UiCommand::Subagents, Arity::Subcommand(&["expand", "collapse", "stop"]), "ui.help.k.subagents", "ui.help.cmd_subagents"),
row(&["/mouse"], UiCommand::Mouse, Arity::None, "/mouse", "ui.help.cmd_mouse"),
row(&["/emoji"], UiCommand::Emoji, Arity::None, "/emoji", "ui.help.cmd_emoji"),
];
pub fn parse(input: &str, loc: &Locale) -> Option<Result<Parsed, String>> {
let trimmed = input.trim();
let mut parts = trimmed.splitn(2, char::is_whitespace);
let head = parts.next()?;
let (spec, alias) = COMMANDS.iter().find_map(|s| {
s.aliases
.iter()
.find(|a| head.eq_ignore_ascii_case(a))
.map(|a| (s, *a))
})?;
let argument = parts.next().unwrap_or_default().trim();
Some(match spec.arity {
Arity::None if !argument.is_empty() => {
Err(loc.tf("ui.cmd.bad_arg", &[("cmd", alias), ("arg", argument)]))
}
Arity::Required if argument.is_empty() => {
Err(loc.tf("ui.cmd.needs_arg", &[("usage", loc.t(spec.label))]))
}
Arity::Subcommand(words) if !argument.is_empty() => {
let (head, tail) = argument
.split_once(char::is_whitespace)
.map_or((argument, ""), |(h, t)| (h, t.trim()));
match words.iter().find(|w| head.eq_ignore_ascii_case(w)) {
Some(word) => Ok(Parsed {
command: spec.command,
argument: if tail.is_empty() {
(*word).to_string()
} else {
format!("{word} {tail}")
},
alias,
}),
None => Err(loc.tf(
"ui.cmd.bad_subcommand",
&[
("cmd", alias),
("arg", argument),
("usage", loc.t(spec.label)),
],
)),
}
}
_ => Ok(Parsed {
command: spec.command,
argument: argument.to_string(),
alias,
}),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
#[test]
fn every_alias_parses_in_any_case_and_padding() {
for spec in COMMANDS {
let arg = if spec.arity == Arity::Required {
" something"
} else {
""
};
for alias in spec.aliases {
for text in [
format!("{alias}{arg}"),
format!(" {alias}{arg} "),
format!("\t{alias}{arg}\n"),
format!("{}{arg}", alias.to_uppercase()),
] {
let parsed = parse(&text, ru())
.unwrap_or_else(|| panic!("{text:?} was not recognized"))
.unwrap_or_else(|e| panic!("{text:?} did not parse: {e}"));
assert_eq!(parsed.command, spec.command, "input {text:?}");
assert_eq!(parsed.alias, *alias, "input {text:?}");
}
}
}
}
#[test]
fn the_registry_is_unambiguous_and_self_describing() {
let mut seen = Vec::new();
for spec in COMMANDS {
for alias in spec.aliases {
assert!(
alias.starts_with('/') && !alias[1..].contains(char::is_whitespace),
"{alias:?} is not a bare /word"
);
assert!(!seen.contains(alias), "{alias:?} is claimed twice");
seen.push(alias);
}
let label = ru().t(spec.label);
for alias in spec.aliases {
assert!(
label.contains(alias),
"the label {label:?} does not show {alias:?}"
);
}
assert_eq!(
spec.arity == Arity::None,
!label.contains('<') && !label.contains('['),
"the label {label:?} disagrees with the arity of {:?}",
spec.command
);
}
}
#[test]
fn a_bare_word_takes_no_arguments() {
for spec in COMMANDS.iter().filter(|s| s.arity == Arity::None) {
let text = format!("{} nonsense", spec.aliases[0]);
let Some(Err(msg)) = parse(&text, ru()) else {
panic!("{text:?} should have been reported");
};
assert!(msg.contains("nonsense"), "the argument is named: {msg}");
assert!(
msg.contains(spec.aliases[0]),
"the typed spelling is named: {msg}"
);
}
}
#[test]
fn the_error_quotes_the_typed_spelling_only() {
let Some(Err(msg)) = parse("/retry now", ru()) else {
panic!("expected a report");
};
assert!(msg.contains("/retry"), "{msg}");
assert!(
!msg.contains("/regen"),
"the untyped spelling leaked: {msg}"
);
}
#[test]
fn a_required_argument_is_asked_for_and_an_optional_one_is_not() {
for spec in COMMANDS {
let parsed = parse(spec.aliases[0], ru()).expect("recognized");
match spec.arity {
Arity::Required => {
let Err(msg) = parsed else {
panic!("{:?} accepted a missing argument", spec.command);
};
assert!(
msg.contains(ru().t(spec.label)),
"the usage line is missing: {msg}"
);
}
_ => {
let Ok(p) = parsed else {
panic!("{:?} rejected a bare word", spec.command);
};
assert!(p.argument.is_empty());
}
}
}
}
#[test]
fn a_subcommand_word_is_normalized_and_anything_else_reported() {
assert_eq!(
parse("/self CLEAR", ru())
.expect("recognized")
.expect("parsed"),
Parsed {
command: UiCommand::SelfModel,
argument: "clear".into(),
alias: "/self",
}
);
assert!(
parse("/self", ru())
.expect("recognized")
.is_ok_and(|p| p.argument.is_empty()),
"bare /self keeps its own meaning"
);
let Some(Err(msg)) = parse("/self wipe", ru()) else {
panic!("expected a report");
};
assert!(msg.contains("wipe"), "the word is quoted back: {msg}");
assert!(
msg.contains(ru().t("ui.help.k.self")),
"the usage line is missing: {msg}"
);
}
#[test]
fn the_argument_is_the_whole_remainder() {
for text in [
"/rename a long title, with punctuation ",
"/search a long title, with punctuation",
"/impersonate a long title, with punctuation",
] {
let parsed = parse(text, ru()).expect("recognized").expect("parsed");
assert_eq!(parsed.argument, "a long title, with punctuation");
}
}
#[test]
fn other_input_is_none() {
for text in [
"/rag list",
"/file list",
"/image list",
"/tts stop",
"/compact",
"/reindex",
"/exit",
"/settings2",
"/self-model",
"/newest",
"/findings",
"help",
"how do I stop a runaway loop?",
"",
" ",
] {
assert_eq!(parse(text, ru()), None, "input {text:?}");
}
}
#[test]
fn errors_are_localized_for_all_langs() {
for &lang in Lang::ALL {
let loc = locale(lang);
for (input, must_name) in [
("/stop now", "/stop"),
("/search", "/search"),
("/tasks halt", "/tasks"),
] {
let Some(Err(msg)) = parse(input, loc) else {
panic!("{input:?} should have been reported in {lang:?}");
};
assert!(
!msg.contains('{') && !msg.contains('}'),
"unsubstituted placeholder in {lang:?}: {msg}"
);
assert!(
msg.contains(must_name),
"the message must name {must_name} in {lang:?}: {msg}"
);
if lang == Lang::En {
assert!(
!msg.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en message: {msg}"
);
}
}
}
}
#[test]
fn a_subcommand_word_keeps_its_tail() {
let parsed = parse("/subagents STOP 2", ru())
.expect("recognized")
.expect("parsed");
assert_eq!(parsed.command, UiCommand::Subagents);
assert_eq!(parsed.argument, "stop 2");
assert_eq!(
parse("/subagents stop", ru()).unwrap().unwrap().argument,
"stop"
);
assert!(parse("/subagents halt 2", ru()).unwrap().is_err());
}
#[test]
fn tasks_bare_is_the_screen_and_stop_carries_the_kind() {
let bare = parse("/tasks", ru()).expect("recognized").expect("parsed");
assert_eq!(bare.command, UiCommand::Tasks);
assert_eq!(bare.argument, "");
let stop = parse("/tasks STOP Reflection", ru())
.expect("recognized")
.expect("parsed");
assert_eq!(stop.command, UiCommand::Tasks);
assert_eq!(stop.argument, "stop Reflection");
let Some(Err(msg)) = parse("/tasks halt", ru()) else {
panic!("an unknown word must be reported");
};
assert!(msg.contains("/tasks [stop"), "{msg}");
}
}