use anyhow::Result;
use super::{App, FilesTab};
pub struct Command {
pub name: &'static str,
pub desc: &'static str,
pub aliases: &'static [&'static str],
}
pub enum Match {
Builtin(&'static Command),
}
impl Match {
pub const fn name(&self) -> &str {
match self {
Self::Builtin(c) => c.name,
}
}
pub const fn desc(&self) -> &str {
match self {
Self::Builtin(c) => c.desc,
}
}
}
pub const COMMANDS: &[Command] = &[
Command {
name: "new",
desc: "start new chat",
aliases: &["chat", "clear"],
},
Command {
name: "compact",
desc: "summarize old messages",
aliases: &["compaction", "summarize"],
},
Command {
name: "session",
desc: "switch sessions",
aliases: &["sessions", "history", "resume", "continue", "switch"],
},
Command {
name: "space",
desc: "switch spaces",
aliases: &["spaces", "project", "workspace"],
},
Command {
name: "model",
desc: "pick a model",
aliases: &["models", "llm"],
},
Command {
name: "login",
desc: "pick a backend to log into",
aliases: &[
"key",
"apikey",
"token",
"auth",
"codex",
"subscription",
"oauth",
"chatgpt",
"opencode",
],
},
Command {
name: "swarm",
desc: "multi-persona roundtable roster",
aliases: &["swarms", "personas", "panel"],
},
Command {
name: "config",
desc: "settings & stats",
aliases: &["settings", "stats", "nerd", "params"],
},
Command {
name: "theme",
desc: "set UI background",
aliases: &["appearance", "colors"],
},
Command {
name: "skills",
desc: "manage skills",
aliases: &["addskill"],
},
Command {
name: "files",
desc: "browse space files / images / scripts",
aliases: &[
"file", "attach", "upload", "docs", "image", "images", "img", "pictures", "script",
"scripts",
],
},
Command {
name: "apps",
desc: "view space apps",
aliases: &["app", "webapps"],
},
Command {
name: "research",
desc: "deep multi-agent research (blank = scope topic from this chat)",
aliases: &["deep-research"],
},
Command {
name: "export",
desc: "write session's report + sources to a file",
aliases: &["save-report"],
},
Command {
name: "watch",
desc: "standing research, re-runs every 24h",
aliases: &["watches"],
},
Command {
name: "usage",
desc: "token/cache/cost analytics by backend and model",
aliases: &["analytics", "costs", "billing"],
},
Command {
name: "web",
desc: "toggle web answer mode (search-first, cited)",
aliases: &["websearch"],
},
Command {
name: "incognito",
desc: "toggle incognito (no persistence, no apps)",
aliases: &["private", "anon"],
},
Command {
name: "copy",
desc: "copy last reply",
aliases: &["yank", "clip"],
},
Command {
name: "quit",
desc: "exit the app",
aliases: &["q", "exit"],
},
];
pub fn fuzzy_score(hay: &str, needle: &str) -> Option<i32> {
let hay = hay.to_lowercase();
let needle = needle.to_lowercase();
let mut chars = hay.chars();
let mut score = 0i32;
let mut prev_matched = false;
let mut pos = 0i32;
for nc in needle.chars() {
loop {
let hc = chars.next()?;
if hc == nc {
score += 1;
if prev_matched {
score += 2;
}
if pos == 0 {
score += 3;
}
prev_matched = true;
pos += 1;
break;
}
prev_matched = false;
pos += 1;
}
}
Some(score)
}
pub fn command_score(c: &Command, needle: &str) -> Option<i32> {
if needle.is_empty() {
return Some(0);
}
let mut best: Option<i32> = None;
let mut upd = |s: &str, bonus: i32| {
if let Some(sc) = fuzzy_score(s, needle) {
let v = sc + bonus;
best = Some(best.map_or(v, |b| b.max(v)));
}
};
upd(c.name, 100);
for a in c.aliases {
upd(a, 50);
}
upd(c.desc, 0);
best
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AppCommand {
Quit,
Send { text: String },
Cancel { task: Option<u64> },
Steer { text: String },
AnswerGate { text: String },
NewSession,
Compact,
OpenSessionPicker,
OpenSpacePicker,
OpenModelPicker,
OpenLogin,
OpenSwarm,
OpenSettings,
SetTheme { mode: String },
OpenCopyMenu,
OpenSkills,
OpenFiles { tab: FilesTab },
OpenApps,
RunResearch { topic: String, gated: bool },
Export,
ToggleWeb,
Incognito { on: bool },
Watch { topic: Option<String> },
OpenUsage,
ArmSkill { name: String, rest: Option<String> },
SwitchSpace { name: String },
ResolveSession { id: String },
SetModel { id: String },
SetSetting { key: String, value: String },
}
impl App {
pub fn parse_command(&self, cmd: &str) -> std::result::Result<AppCommand, String> {
if let Some(rest) = cmd.strip_prefix("research!") {
return Ok(AppCommand::RunResearch {
topic: rest.trim().to_string(),
gated: false,
});
}
let token = cmd.split_whitespace().next().unwrap_or("");
let canonical = COMMANDS
.iter()
.find(|c| c.name == token || c.aliases.contains(&token))
.map_or(token, |c| c.name);
let rest = |cmd: &str, token: &str| cmd[token.len()..].trim().to_string();
match canonical {
"quit" => Ok(AppCommand::Quit),
"new" => Ok(AppCommand::NewSession),
"compact" => Ok(AppCommand::Compact),
"session" => Ok(AppCommand::OpenSessionPicker),
"space" => Ok(AppCommand::OpenSpacePicker),
"model" => Ok(AppCommand::OpenModelPicker),
"login" => Ok(AppCommand::OpenLogin),
"swarm" => Ok(AppCommand::OpenSwarm),
"config" => Ok(AppCommand::OpenSettings),
"theme" => Ok(AppCommand::SetTheme {
mode: rest(cmd, token),
}),
"copy" => Ok(AppCommand::OpenCopyMenu),
"skills" => Ok(AppCommand::OpenSkills),
"files" => Ok(AppCommand::OpenFiles {
tab: match token {
t if t == "image" || t == "images" || t == "img" || t == "pictures" => {
FilesTab::Images
}
t if t == "script" || t == "scripts" => FilesTab::Scripts,
_ => FilesTab::Files,
},
}),
"apps" => Ok(AppCommand::OpenApps),
"research" => Ok(AppCommand::RunResearch {
topic: rest(cmd, token),
gated: true,
}),
"export" => Ok(AppCommand::Export),
"web" => Ok(AppCommand::ToggleWeb),
"incognito" => Ok(AppCommand::Incognito {
on: !self.incognito,
}),
"watch" => {
let arg = rest(cmd, token);
Ok(AppCommand::Watch {
topic: (!arg.is_empty()).then_some(arg),
})
}
"usage" => Ok(AppCommand::OpenUsage),
other => {
if self.skills.iter().any(|s| s.name == other) {
let text = rest(cmd, token);
Ok(AppCommand::ArmSkill {
name: other.to_string(),
rest: (!text.is_empty()).then_some(text),
})
} else {
Err(format!("unknown command: /{other}"))
}
}
}
}
pub fn execute(&mut self, cmd: AppCommand) -> Result<()> {
match cmd {
AppCommand::Quit
| AppCommand::OpenSessionPicker
| AppCommand::OpenSpacePicker
| AppCommand::OpenModelPicker
| AppCommand::OpenLogin
| AppCommand::OpenSwarm
| AppCommand::OpenSettings
| AppCommand::SetTheme { .. }
| AppCommand::OpenCopyMenu
| AppCommand::OpenSkills
| AppCommand::OpenFiles { .. }
| AppCommand::OpenApps
| AppCommand::OpenUsage
| AppCommand::Watch { .. } => {}
AppCommand::Send { text } => self.send_message(text)?,
AppCommand::Cancel { task } => match task {
Some(id) => self.cancel_chat_task(id)?,
None => self.stop_stream()?,
},
AppCommand::Steer { text } => self.steer_research(&text),
AppCommand::AnswerGate { text } => self.reply_to_survey_gate(&text),
AppCommand::NewSession => self.new_session(),
AppCommand::Compact => self.force_compact(),
AppCommand::RunResearch { topic, gated } => {
if !gated {
self.start_research_with_gate(&topic, false);
} else if topic.is_empty() {
self.start_research_from_chat();
} else {
self.start_research(&topic);
}
}
AppCommand::Export => {
self.export_report()?;
}
AppCommand::ToggleWeb => self.toggle_web_mode(),
AppCommand::Incognito { on } => {
if on != self.incognito {
self.toggle_incognito()?;
}
}
AppCommand::ArmSkill { name, rest } => {
self.forced_skill = Some(name.clone());
if let Some(text) = rest {
self.send_message(text)?;
} else {
self.push_status(format!("skill {name} armed for next message"));
}
}
AppCommand::SwitchSpace { name } => {
self.switch_space_cli(&name)?;
}
AppCommand::ResolveSession { id } => {
self.switch_to_session_by_id(&id)?;
}
AppCommand::SetModel { id } => {
self.pick_model(&id)?;
}
AppCommand::SetSetting { key, value } => {
self.set_setting(&key, &value)?;
}
}
Ok(())
}
pub fn run_command(&mut self, cmd: &str) -> Result<()> {
match self.parse_command(cmd) {
Ok(cmd) => self.execute(cmd),
Err(message) => {
self.push_status(message);
Ok(())
}
}
}
}