use mecha_core::config::PermissionMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Help,
Tools,
Triggers,
Outbox,
Frontdoor,
Mail,
Polls,
Doctor,
Model(Option<String>),
Provider(Option<String>),
Mode(Option<PermissionMode>),
Usage,
Clear,
Session,
Todo,
Quit,
Unknown(String),
Mcp(Option<bool>),
McpServer(String, Option<bool>),
BadMode(String),
BadToggle(String),
Review(Option<ReviewMode>),
BadReview(String),
}
pub use crate::review_policy::ReviewMode;
fn parse_review(s: &str) -> Option<ReviewMode> {
ReviewMode::parse(s)
}
pub fn parse(line: &str) -> Option<Command> {
let line = line.trim();
let rest = line.strip_prefix('/')?;
let mut parts = rest.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("").trim();
let arg = parts.next().map(str::trim).filter(|a| !a.is_empty());
if name.is_empty() {
return None;
}
Some(match name {
"help" | "h" | "?" => Command::Help,
"tools" => Command::Tools,
"triggers" | "trigger" => Command::Triggers,
"outbox" => Command::Outbox,
"mail" | "inbox" => Command::Mail,
"frontdoor" | "requests" => Command::Frontdoor,
"polls" | "poll" => Command::Polls,
"doctor" => Command::Doctor,
"model" | "m" => Command::Model(arg.map(str::to_string)),
"provider" | "p" => Command::Provider(arg.map(str::to_string)),
"usage" => Command::Usage,
"clear" | "new" => Command::Clear,
"session" => Command::Session,
"todo" => Command::Todo,
"exit" | "quit" | "q" => Command::Quit,
"mcp" => match arg {
None => Command::Mcp(None),
Some(a) => {
let mut words = a.split_whitespace();
let first = words.next().unwrap_or("");
let second = words.next();
match (parse_toggle(first), second) {
(Some(v), None) => Command::Mcp(Some(v)),
(Some(_), Some(_)) => Command::BadToggle(a.to_string()),
(None, None) => Command::McpServer(first.to_string(), None),
(None, Some(word)) => match parse_toggle(word) {
Some(v) => Command::McpServer(first.to_string(), Some(v)),
None => Command::BadToggle(word.to_string()),
},
}
}
},
"mode" => match arg {
None => Command::Mode(None),
Some(a) => match parse_mode(a) {
Some(m) => Command::Mode(Some(m)),
None => Command::BadMode(a.to_string()),
},
},
"review" => match arg {
None => Command::Review(None),
Some(a) => match parse_review(a) {
Some(m) => Command::Review(Some(m)),
None => Command::BadReview(a.to_string()),
},
},
other => Command::Unknown(other.to_string()),
})
}
fn parse_mode(s: &str) -> Option<PermissionMode> {
match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"ask" => Some(PermissionMode::Ask),
"allow" | "auto" | "yes" => Some(PermissionMode::Allow),
"read-only" | "readonly" | "ro" | "plan" => Some(PermissionMode::ReadOnly),
_ => None,
}
}
fn parse_toggle(s: &str) -> Option<bool> {
match s.trim().to_ascii_lowercase().as_str() {
"on" | "yes" | "true" | "enable" => Some(true),
"off" | "no" | "false" | "disable" => Some(false),
_ => None,
}
}
pub fn shell_escape(line: &str) -> Option<&str> {
let rest = line.trim().strip_prefix('!')?.trim();
(!rest.is_empty()).then_some(rest)
}
pub fn at_token(input: &str, cursor: usize) -> Option<(usize, &str)> {
let cursor = cursor.min(input.len());
let before = &input[..cursor];
let token_start = before
.rfind(|c: char| c.is_whitespace())
.map(|i| i + before[i..].chars().next().map_or(1, char::len_utf8))
.unwrap_or(0);
let token = &before[token_start..];
token
.starts_with('@')
.then(|| (token_start + 1, &token[1..]))
}
pub fn path_candidates(partial: &str, workspace: &std::path::Path) -> Vec<String> {
if partial.starts_with('/') || partial.split('/').any(|c| c == "..") {
return Vec::new();
}
let (dir_part, file_part) = match partial.rfind('/') {
Some(i) => (&partial[..=i], &partial[i + 1..]),
None => ("", partial),
};
let Ok(entries) = std::fs::read_dir(workspace.join(dir_part)) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with(file_part) {
return None;
}
if name.starts_with('.') && !file_part.starts_with('.') {
return None;
}
if (name == ".git" || name == "target") && file_part.is_empty() {
return None;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
Some(format!("{dir_part}{name}{}", if is_dir { "/" } else { "" }))
})
.collect();
out.sort();
out.truncate(200);
out
}
pub const NAMES: [&str; 16] = [
"help",
"tools",
"triggers",
"outbox",
"frontdoor",
"polls",
"doctor",
"review",
"model",
"provider",
"mode",
"mcp",
"usage",
"clear",
"session",
"todo",
];
pub fn completions(input: &str) -> Vec<&'static str> {
let Some(rest) = input.strip_prefix('/') else {
return Vec::new();
};
if rest.contains(char::is_whitespace) {
return Vec::new();
}
let rest = rest.to_ascii_lowercase();
NAMES
.iter()
.copied()
.filter(|n| n.starts_with(&rest) && *n != rest)
.collect()
}
pub fn common_prefix<S: AsRef<str>>(candidates: &[S]) -> String {
let Some(first) = candidates.first().map(AsRef::as_ref) else {
return String::new();
};
let mut len = first.len();
for c in &candidates[1..] {
len = len.min(
first
.chars()
.zip(c.as_ref().chars())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum(),
);
}
first[..len].to_string()
}
pub fn mode_name(mode: PermissionMode) -> &'static str {
match mode {
PermissionMode::Ask => "ask",
PermissionMode::Allow => "allow",
PermissionMode::ReadOnly => "read-only",
}
}
pub const HELP: &str = "\
/help this list
/tools tools this agent can call
/triggers scheduled prompts: see, edit, run, cancel
/outbox staged outbound drafts: read, edit, send, reject
/frontdoor inbound requests: read, extract, triage, close
/polls open polls on the gate: tallies, close, export
/doctor what is silently wrong across the stores, and the way out
/review [now|later|auto] what happens when a run stages drafts
/model [id] show or switch the model
/provider [name] show or switch the provider
/mode [ask|allow|read-only] show or switch the permission mode
/mcp [on|off] list MCP servers, or turn them all off and on
/mcp <server> [on|off] turn one server off and on
/usage tokens used this session
/clear start a new conversation, dropping its taint
/session where the transcript is being written
/todo show or hide the live task pane
/exit quit";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_only_fires_while_the_name_is_still_being_typed() {
assert_eq!(completions("/mo"), vec!["model", "mode"]);
assert_eq!(completions("/mod"), vec!["model", "mode"]);
assert_eq!(
completions("/mode"),
vec!["model"],
"an exact match still offers longer names"
);
assert_eq!(completions("/c"), vec!["clear"]);
assert!(completions("summarise this").is_empty());
assert!(
completions("/model claude").is_empty(),
"arguments are not command names"
);
assert!(completions("/zzz").is_empty());
}
#[test]
fn tab_fills_in_what_every_candidate_agrees_on() {
assert_eq!(common_prefix(&completions("/mo")), "mode");
assert_eq!(common_prefix(&["session", "settings"]), "se");
assert_eq!(common_prefix(&completions("/u")), "usage");
assert_eq!(common_prefix::<&str>(&[]), "");
}
#[test]
fn the_name_list_and_the_help_text_cannot_drift_apart() {
for name in NAMES {
assert!(
!matches!(parse(&format!("/{name}")), None | Some(Command::Unknown(_))),
"{name} is offered for completion but does not parse"
);
}
for line in HELP.lines() {
let Some(advertised) = line.split_whitespace().next() else {
continue;
};
let advertised = advertised.trim_start_matches('/');
if advertised == "exit" {
continue; }
assert!(
NAMES.contains(&advertised),
"{advertised} is documented but not completable"
);
}
}
#[test]
fn an_ordinary_message_is_not_a_command() {
assert_eq!(parse("summarise the README"), None);
assert_eq!(parse("what does a/b mean?"), None);
}
fn fixture() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let root = std::env::temp_dir().join(format!(
"mecha-completion-test-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
for dir in ["docs", "src", "target", ".git"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
for file in [
"README.md",
"docs/HANDOFF.md",
"docs/TUI-RESEARCH.md",
".hidden",
] {
std::fs::write(root.join(file), "x").unwrap();
}
root
}
#[test]
fn the_at_token_is_found_anywhere_in_the_message() {
assert_eq!(at_token("@doc", 4), Some((1, "doc")));
assert_eq!(
at_token("summarise @docs/HAND", 20),
Some((11, "docs/HAND"))
);
assert_eq!(at_token("read @", 6), Some((6, "")));
assert_eq!(at_token("plain text", 10), None);
assert_eq!(
at_token("a@b.com is an email", 5),
None,
"@ mid-word is not a mention"
);
assert_eq!(at_token("@docs done", 10), None, "cursor is past the token");
}
#[test]
fn path_candidates_complete_the_workspace_and_descend_directories() {
let root = fixture();
let top = path_candidates("", &root);
assert!(top.contains(&"docs/".to_string()), "{top:?}");
assert!(top.contains(&"README.md".to_string()), "{top:?}");
assert_eq!(path_candidates("do", &root), vec!["docs/"]);
let inside = path_candidates("docs/", &root);
assert_eq!(inside, vec!["docs/HANDOFF.md", "docs/TUI-RESEARCH.md"]);
assert_eq!(
common_prefix(&inside),
"docs/",
"diverging names share only the dir"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn noise_and_escapes_are_not_offered() {
let root = fixture();
let top = path_candidates("", &root);
assert!(!top.iter().any(|c| c.contains(".git")), "{top:?}");
assert!(!top.iter().any(|c| c.contains("target")), "{top:?}");
assert!(!top.iter().any(|c| c.contains(".hidden")), "{top:?}");
assert_eq!(path_candidates(".hi", &root), vec![".hidden"]);
assert_eq!(path_candidates("targ", &root), vec!["target/"]);
assert!(path_candidates("/etc/pass", &root).is_empty());
assert!(path_candidates("../up", &root).is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_shell_escape_needs_a_command_after_the_bang() {
assert_eq!(shell_escape("!ls -la"), Some("ls -la"));
assert_eq!(shell_escape(" !git status "), Some("git status"));
assert_eq!(shell_escape("!"), None);
assert_eq!(shell_escape("! "), None);
assert_eq!(shell_escape("fix it!"), None);
assert_eq!(shell_escape("hello"), None);
}
#[test]
fn a_bare_slash_is_not_a_command() {
assert_eq!(parse("/"), None);
assert_eq!(parse("/ "), None);
assert_eq!(parse("/ read this"), None);
}
#[test]
fn commands_parse_with_and_without_arguments() {
assert_eq!(parse("/model"), Some(Command::Model(None)));
assert_eq!(
parse("/model claude-opus-5"),
Some(Command::Model(Some("claude-opus-5".into())))
);
assert_eq!(
parse("/provider local"),
Some(Command::Provider(Some("local".into())))
);
assert_eq!(parse("/clear"), Some(Command::Clear));
}
#[test]
fn surrounding_whitespace_never_decides_anything() {
assert_eq!(parse(" /usage "), Some(Command::Usage));
assert_eq!(
parse("/model gpt-4o "),
Some(Command::Model(Some("gpt-4o".into())))
);
}
#[test]
fn aliases_exist_because_nobody_remembers_which_word_this_surface_uses() {
assert_eq!(parse("/q"), Some(Command::Quit));
assert_eq!(parse("/quit"), Some(Command::Quit));
assert_eq!(parse("/exit"), Some(Command::Quit));
assert_eq!(parse("/new"), Some(Command::Clear));
assert_eq!(parse("/?"), Some(Command::Help));
}
#[test]
fn modes_accept_the_names_the_flags_use() {
for word in ["allow", "auto", "yes", "ALLOW"] {
assert_eq!(
parse(&format!("/mode {word}")),
Some(Command::Mode(Some(PermissionMode::Allow)))
);
}
for word in ["read-only", "readonly", "ro", "plan"] {
assert_eq!(
parse(&format!("/mode {word}")),
Some(Command::Mode(Some(PermissionMode::ReadOnly))),
"{word}"
);
}
assert_eq!(
parse("/mode ask"),
Some(Command::Mode(Some(PermissionMode::Ask)))
);
assert_eq!(parse("/mode"), Some(Command::Mode(None)));
}
#[test]
fn mcp_toggles_on_the_words_people_actually_type() {
for word in ["on", "yes", "true", "enable", "ON"] {
assert_eq!(
parse(&format!("/mcp {word}")),
Some(Command::Mcp(Some(true))),
"{word}"
);
}
for word in ["off", "no", "false", "disable"] {
assert_eq!(
parse(&format!("/mcp {word}")),
Some(Command::Mcp(Some(false))),
"{word}"
);
}
assert_eq!(parse("/mcp"), Some(Command::Mcp(None)));
assert_eq!(
parse("/mcp maybe"),
Some(Command::McpServer("maybe".into(), None))
);
}
#[test]
fn mcp_addresses_all_the_servers_or_one_of_them() {
assert_eq!(parse("/mcp off"), Some(Command::Mcp(Some(false))));
assert_eq!(
parse("/mcp pkg off"),
Some(Command::McpServer("pkg".into(), Some(false)))
);
assert_eq!(
parse("/mcp pkg on"),
Some(Command::McpServer("pkg".into(), Some(true)))
);
assert_eq!(
parse("/mcp pkg"),
Some(Command::McpServer("pkg".into(), None))
);
assert_eq!(
parse("/mcp off pkg"),
Some(Command::BadToggle("off pkg".into()))
);
assert_eq!(
parse("/mcp pkg maybe"),
Some(Command::BadToggle("maybe".into()))
);
}
#[test]
fn review_modes_parse_and_a_typo_is_reported() {
assert_eq!(parse("/review"), Some(Command::Review(None)));
assert_eq!(
parse("/review now"),
Some(Command::Review(Some(ReviewMode::Now)))
);
assert_eq!(
parse("/review LATER"),
Some(Command::Review(Some(ReviewMode::Later)))
);
assert_eq!(
parse("/review auto"),
Some(Command::Review(Some(ReviewMode::Auto)))
);
assert_eq!(
parse("/review sometimes"),
Some(Command::BadReview("sometimes".into()))
);
}
#[test]
fn an_unknown_mode_is_reported_rather_than_silently_ignored() {
assert_eq!(parse("/mode turbo"), Some(Command::BadMode("turbo".into())));
}
#[test]
fn a_mistyped_command_does_not_become_a_prompt() {
assert_eq!(parse("/moel"), Some(Command::Unknown("moel".into())));
assert_eq!(parse("/clera"), Some(Command::Unknown("clera".into())));
}
#[test]
fn every_command_in_the_help_text_actually_parses() {
for line in HELP.lines() {
let Some(name) = line.split_whitespace().next() else {
continue;
};
let parsed = parse(name);
assert!(
!matches!(parsed, None | Some(Command::Unknown(_))),
"{name} is advertised but does not parse"
);
}
}
}