#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SlashCmd {
Help,
Model,
Fast,
Tokens,
Add,
Save,
Clear,
Undo,
Rewind,
Expand,
Reduce,
Experimental,
Compact,
Context,
Handoff,
Plan,
Goal,
Review,
Side,
Effort,
Exit,
}
pub struct SlashCommandSpec {
pub cmd: SlashCmd,
pub name: &'static str,
pub aliases: &'static [&'static str],
pub arg_hint: &'static str,
pub desc: &'static str,
pub takes_path: bool,
}
pub const SLASH_COMMANDS: &[SlashCommandSpec] = &[
SlashCommandSpec {
cmd: SlashCmd::Help,
name: "/help",
aliases: &["/?"],
arg_hint: "",
desc: "this help",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Model,
name: "/model",
aliases: &[],
arg_hint: "",
desc: "show the active model, or pick a new one (interactive terminal)",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Fast,
name: "/fast",
aliases: &[],
arg_hint: "[on|off]",
desc: "toggle the priority (fast) service tier for this session",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Tokens,
name: "/tokens",
aliases: &[],
arg_hint: "",
desc: "output tokens + turns this session",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Add,
name: "/add",
aliases: &[],
arg_hint: "<file>",
desc: "stage a file as context for the next message",
takes_path: true,
},
SlashCommandSpec {
cmd: SlashCmd::Save,
name: "/save",
aliases: &[],
arg_hint: "[file]",
desc: "write the transcript (default transcript.jsonl)",
takes_path: true,
},
SlashCommandSpec {
cmd: SlashCmd::Clear,
name: "/clear",
aliases: &["/reset"],
arg_hint: "",
desc: "reset the conversation (keep the system prompt)",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Undo,
name: "/undo",
aliases: &[],
arg_hint: "",
desc: "drop the last exchange",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Rewind,
name: "/rewind",
aliases: &[],
arg_hint: "[n|undo]",
desc: "list rewind points, rewind the conversation to one, or undo the last rewind",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Expand,
name: "/expand",
aliases: &[],
arg_hint: "<id|all|last>",
desc: "rehydrate a stub (reduced sessions only)",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Reduce,
name: "/reduce",
aliases: &[],
arg_hint: "",
desc: "re-stub everything not excluded (reduced sessions only)",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Experimental,
name: "/experimental",
aliases: &["/features"],
arg_hint: "",
desc: "list the staged experimental flags and this session's values",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Compact,
name: "/compact",
aliases: &[],
arg_hint: "[focus]",
desc: "compact the conversation now; the optional focus steers what the summary keeps",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Context,
name: "/context",
aliases: &[],
arg_hint: "",
desc: "context-window usage: tokens, percentage, and what is consuming it",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Handoff,
name: "/handoff",
aliases: &[],
arg_hint: "<objective>",
desc: "start a fresh working context seeded with an objective and the recent tail",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Plan,
name: "/plan",
aliases: &[],
arg_hint: "[on|off]",
desc: "toggle plan mode: a read-only research phase (write/exec tools refused)",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Goal,
name: "/goal",
aliases: &[],
arg_hint: "[objective|clear]",
desc: "show, set, or clear the session's standing objective",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Review,
name: "/review",
aliases: &["/code-review"],
arg_hint: "[focus]",
desc: "run the dedicated code-review turn with this harness's report format",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Side,
name: "/btw",
aliases: &["/side"],
arg_hint: "<question>",
desc: "ask a tool-less question over the full context; the exchange never enters history",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Effort,
name: "/effort",
aliases: &[],
arg_hint: "[low|medium|high|off]",
desc: "show or change the reasoning effort for the rest of the session",
takes_path: false,
},
SlashCommandSpec {
cmd: SlashCmd::Exit,
name: "/exit",
aliases: &["/quit", "/q"],
arg_hint: "",
desc: "leave (also Ctrl-D)",
takes_path: false,
},
];
pub fn resolve_slash_command(input: &str) -> Option<SlashCmd> {
SLASH_COMMANDS
.iter()
.find(|c| c.name == input || c.aliases.contains(&input))
.map(|c| c.cmd)
}
pub fn slash_command_names() -> impl Iterator<Item = &'static str> {
SLASH_COMMANDS
.iter()
.flat_map(|c| std::iter::once(c.name).chain(c.aliases.iter().copied()))
}
pub fn slash_command_takes_path(input: &str) -> bool {
SLASH_COMMANDS
.iter()
.any(|c| c.takes_path && (c.name == input || c.aliases.contains(&input)))
}
pub fn help_text() -> String {
let labels: Vec<String> = SLASH_COMMANDS
.iter()
.map(|c| {
if c.arg_hint.is_empty() {
c.name.to_string()
} else {
format!("{} {}", c.name, c.arg_hint)
}
})
.collect();
let width = labels.iter().map(|l| l.len()).max().unwrap_or(0);
let mut out = String::from("commands:");
for (label, spec) in labels.iter().zip(SLASH_COMMANDS.iter()) {
out.push_str(&format!("\n {label:width$} {}", spec.desc));
}
out
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut dp: Vec<usize> = (0..=b.len()).collect();
for i in 1..=a.len() {
let mut prev = dp[0];
dp[0] = i;
for j in 1..=b.len() {
let temp = dp[j];
dp[j] = if a[i - 1] == b[j - 1] {
prev
} else {
1 + prev.min(dp[j]).min(dp[j - 1])
};
prev = temp;
}
}
dp[b.len()]
}
pub fn suggest_slash_command(input: &str) -> Option<&'static str> {
if !input.starts_with('/') || input.len() <= 1 {
return None;
}
let mut best: Option<(&'static str, usize)> = None;
for name in slash_command_names() {
let d = levenshtein(input, name);
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((name, d));
}
}
best.filter(|(_, d)| *d <= 2 && *d < input.len())
.map(|(name, _)| name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_finds_canonical_and_aliases() {
assert_eq!(resolve_slash_command("/help"), Some(SlashCmd::Help));
assert_eq!(resolve_slash_command("/?"), Some(SlashCmd::Help));
assert_eq!(resolve_slash_command("/reset"), Some(SlashCmd::Clear));
assert_eq!(resolve_slash_command("/q"), Some(SlashCmd::Exit));
assert_eq!(resolve_slash_command("/nope"), None);
}
#[test]
fn every_table_entry_resolves_to_itself() {
for spec in SLASH_COMMANDS {
assert_eq!(resolve_slash_command(spec.name), Some(spec.cmd));
for alias in spec.aliases {
assert_eq!(resolve_slash_command(alias), Some(spec.cmd));
}
}
}
#[test]
fn suggest_typo_did_you_mean() {
assert_eq!(suggest_slash_command("/hlep"), Some("/help"));
assert_eq!(suggest_slash_command("/toekns"), Some("/tokens"));
assert_eq!(suggest_slash_command("/exti"), Some("/exit"));
assert_eq!(suggest_slash_command("/reduc"), Some("/reduce"));
}
#[test]
fn suggest_none_for_unrelated_or_empty() {
assert_eq!(suggest_slash_command("/zzzzzzzzzzzz"), None);
assert_eq!(suggest_slash_command("/"), None);
assert_eq!(suggest_slash_command("not-a-slash"), None);
}
#[test]
fn help_text_lists_every_command() {
let text = help_text();
for spec in SLASH_COMMANDS {
assert!(text.contains(spec.name), "help text missing {}", spec.name);
assert!(
text.contains(spec.desc),
"help text missing desc for {}",
spec.name
);
}
}
#[test]
fn takes_path_matches_table() {
assert!(slash_command_takes_path("/add"));
assert!(slash_command_takes_path("/save"));
assert!(!slash_command_takes_path("/help"));
assert!(!slash_command_takes_path("/tokens"));
}
}