1use anyhow::Result;
10
11use super::{App, FilesTab};
12
13pub struct Command {
17 pub name: &'static str,
18 pub desc: &'static str,
19 pub aliases: &'static [&'static str],
20}
21
22pub enum Match {
24 Builtin(&'static Command),
25}
26
27impl Match {
28 pub const fn name(&self) -> &str {
29 match self {
30 Self::Builtin(c) => c.name,
31 }
32 }
33
34 pub const fn desc(&self) -> &str {
35 match self {
36 Self::Builtin(c) => c.desc,
37 }
38 }
39}
40
41pub const COMMANDS: &[Command] = &[
42 Command {
43 name: "new",
44 desc: "start new chat",
45 aliases: &["chat", "clear"],
46 },
47 Command {
48 name: "compact",
49 desc: "summarize old messages",
50 aliases: &["compaction", "summarize"],
51 },
52 Command {
53 name: "session",
54 desc: "switch sessions",
55 aliases: &["sessions", "history", "resume", "continue", "switch"],
56 },
57 Command {
58 name: "space",
59 desc: "switch spaces",
60 aliases: &["spaces", "project", "workspace"],
61 },
62 Command {
63 name: "model",
64 desc: "pick a model",
65 aliases: &["models", "llm"],
66 },
67 Command {
68 name: "login",
69 desc: "pick a backend to log into",
70 aliases: &[
71 "key",
72 "apikey",
73 "token",
74 "auth",
75 "codex",
76 "subscription",
77 "oauth",
78 "chatgpt",
79 "opencode",
80 ],
81 },
82 Command {
83 name: "swarm",
84 desc: "multi-persona roundtable roster",
85 aliases: &["swarms", "personas", "panel"],
86 },
87 Command {
88 name: "config",
89 desc: "settings & stats",
90 aliases: &["settings", "stats", "nerd", "params"],
91 },
92 Command {
93 name: "theme",
94 desc: "set UI background",
95 aliases: &["appearance", "colors"],
96 },
97 Command {
98 name: "skills",
99 desc: "manage skills",
100 aliases: &["addskill"],
101 },
102 Command {
103 name: "files",
104 desc: "browse space files / images / scripts",
105 aliases: &[
106 "file", "attach", "upload", "docs", "image", "images", "img", "pictures", "script",
107 "scripts",
108 ],
109 },
110 Command {
111 name: "apps",
112 desc: "view space apps",
113 aliases: &["app", "webapps"],
114 },
115 Command {
116 name: "research",
117 desc: "deep multi-agent research (blank = scope topic from this chat)",
118 aliases: &["deep-research"],
119 },
120 Command {
121 name: "export",
122 desc: "write session's report + sources to a file",
123 aliases: &["save-report"],
124 },
125 Command {
126 name: "watch",
127 desc: "standing research, re-runs every 24h",
128 aliases: &["watches"],
129 },
130 Command {
131 name: "usage",
132 desc: "token/cache/cost analytics by backend and model",
133 aliases: &["analytics", "costs", "billing"],
134 },
135 Command {
136 name: "web",
137 desc: "toggle web answer mode (search-first, cited)",
138 aliases: &["websearch"],
139 },
140 Command {
141 name: "incognito",
142 desc: "toggle incognito (no persistence, no apps)",
143 aliases: &["private", "anon"],
144 },
145 Command {
146 name: "copy",
147 desc: "copy last reply",
148 aliases: &["yank", "clip"],
149 },
150 Command {
151 name: "quit",
152 desc: "exit the app",
153 aliases: &["q", "exit"],
154 },
155];
156
157pub fn fuzzy_score(hay: &str, needle: &str) -> Option<i32> {
161 let hay = hay.to_lowercase();
162 let needle = needle.to_lowercase();
163 let mut chars = hay.chars();
164 let mut score = 0i32;
165 let mut prev_matched = false;
166 let mut pos = 0i32;
167 for nc in needle.chars() {
168 loop {
169 let hc = chars.next()?;
170 if hc == nc {
171 score += 1;
172 if prev_matched {
173 score += 2;
174 }
175 if pos == 0 {
176 score += 3;
177 }
178 prev_matched = true;
179 pos += 1;
180 break;
181 }
182 prev_matched = false;
183 pos += 1;
184 }
185 }
186 Some(score)
187}
188
189pub fn command_score(c: &Command, needle: &str) -> Option<i32> {
192 if needle.is_empty() {
193 return Some(0);
194 }
195 let mut best: Option<i32> = None;
196 let mut upd = |s: &str, bonus: i32| {
197 if let Some(sc) = fuzzy_score(s, needle) {
198 let v = sc + bonus;
199 best = Some(best.map_or(v, |b| b.max(v)));
200 }
201 };
202 upd(c.name, 100);
203 for a in c.aliases {
204 upd(a, 50);
205 }
206 upd(c.desc, 0);
207 best
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218pub enum AppCommand {
219 Quit,
221 Send { text: String },
223 Cancel { task: Option<u64> },
225 Steer { text: String },
227 AnswerGate { text: String },
229 NewSession,
231 Compact,
233 OpenSessionPicker,
235 OpenSpacePicker,
237 OpenModelPicker,
239 OpenLogin,
241 OpenSwarm,
243 OpenSettings,
245 SetTheme { mode: String },
247 OpenCopyMenu,
249 OpenSkills,
251 OpenFiles { tab: FilesTab },
253 OpenApps,
255 RunResearch { topic: String, gated: bool },
258 Export,
260 ToggleWeb,
262 Incognito { on: bool },
264 Watch { topic: Option<String> },
266 OpenUsage,
268 ArmSkill { name: String, rest: Option<String> },
270 SwitchSpace { name: String },
272 ResolveSession { id: String },
274 SetModel { id: String },
276 SetSetting { key: String, value: String },
278}
279
280impl App {
281 pub fn parse_command(&self, cmd: &str) -> std::result::Result<AppCommand, String> {
286 if let Some(rest) = cmd.strip_prefix("research!") {
289 return Ok(AppCommand::RunResearch {
290 topic: rest.trim().to_string(),
291 gated: false,
292 });
293 }
294 let token = cmd.split_whitespace().next().unwrap_or("");
295 let canonical = COMMANDS
297 .iter()
298 .find(|c| c.name == token || c.aliases.contains(&token))
299 .map_or(token, |c| c.name);
300 let rest = |cmd: &str, token: &str| cmd[token.len()..].trim().to_string();
301 match canonical {
302 "quit" => Ok(AppCommand::Quit),
303 "new" => Ok(AppCommand::NewSession),
304 "compact" => Ok(AppCommand::Compact),
305 "session" => Ok(AppCommand::OpenSessionPicker),
306 "space" => Ok(AppCommand::OpenSpacePicker),
307 "model" => Ok(AppCommand::OpenModelPicker),
308 "login" => Ok(AppCommand::OpenLogin),
309 "swarm" => Ok(AppCommand::OpenSwarm),
310 "config" => Ok(AppCommand::OpenSettings),
311 "theme" => Ok(AppCommand::SetTheme {
312 mode: rest(cmd, token),
313 }),
314 "copy" => Ok(AppCommand::OpenCopyMenu),
315 "skills" => Ok(AppCommand::OpenSkills),
316 "files" => Ok(AppCommand::OpenFiles {
317 tab: match token {
318 t if t == "image" || t == "images" || t == "img" || t == "pictures" => {
319 FilesTab::Images
320 }
321 t if t == "script" || t == "scripts" => FilesTab::Scripts,
322 _ => FilesTab::Files,
323 },
324 }),
325 "apps" => Ok(AppCommand::OpenApps),
326 "research" => Ok(AppCommand::RunResearch {
327 topic: rest(cmd, token),
328 gated: true,
329 }),
330 "export" => Ok(AppCommand::Export),
331 "web" => Ok(AppCommand::ToggleWeb),
332 "incognito" => Ok(AppCommand::Incognito {
333 on: !self.incognito,
334 }),
335 "watch" => {
336 let arg = rest(cmd, token);
337 Ok(AppCommand::Watch {
338 topic: (!arg.is_empty()).then_some(arg),
339 })
340 }
341 "usage" => Ok(AppCommand::OpenUsage),
342 other => {
343 if self.skills.iter().any(|s| s.name == other) {
344 let text = rest(cmd, token);
345 Ok(AppCommand::ArmSkill {
346 name: other.to_string(),
347 rest: (!text.is_empty()).then_some(text),
348 })
349 } else {
350 Err(format!("unknown command: /{other}"))
351 }
352 }
353 }
354 }
355
356 pub fn execute(&mut self, cmd: AppCommand) -> Result<()> {
362 match cmd {
363 AppCommand::Quit
367 | AppCommand::OpenSessionPicker
368 | AppCommand::OpenSpacePicker
369 | AppCommand::OpenModelPicker
370 | AppCommand::OpenLogin
371 | AppCommand::OpenSwarm
372 | AppCommand::OpenSettings
373 | AppCommand::SetTheme { .. }
374 | AppCommand::OpenCopyMenu
375 | AppCommand::OpenSkills
376 | AppCommand::OpenFiles { .. }
377 | AppCommand::OpenApps
378 | AppCommand::OpenUsage
379 | AppCommand::Watch { .. } => {}
380 AppCommand::Send { text } => self.send_message(text)?,
381 AppCommand::Cancel { task } => match task {
382 Some(id) => self.cancel_chat_task(id)?,
383 None => self.stop_stream()?,
384 },
385 AppCommand::Steer { text } => self.steer_research(&text),
386 AppCommand::AnswerGate { text } => self.reply_to_survey_gate(&text),
387 AppCommand::NewSession => self.new_session(),
388 AppCommand::Compact => self.force_compact(),
389 AppCommand::RunResearch { topic, gated } => {
390 if !gated {
391 self.start_research_with_gate(&topic, false);
392 } else if topic.is_empty() {
393 self.start_research_from_chat();
394 } else {
395 self.start_research(&topic);
396 }
397 }
398 AppCommand::Export => {
399 self.export_report()?;
400 }
401 AppCommand::ToggleWeb => self.toggle_web_mode(),
402 AppCommand::Incognito { on } => {
403 if on != self.incognito {
404 self.toggle_incognito()?;
405 }
406 }
407 AppCommand::ArmSkill { name, rest } => {
408 self.forced_skill = Some(name.clone());
409 if let Some(text) = rest {
410 self.send_message(text)?;
411 } else {
412 self.push_status(format!("skill {name} armed for next message"));
413 }
414 }
415 AppCommand::SwitchSpace { name } => {
416 self.switch_space_cli(&name)?;
417 }
418 AppCommand::ResolveSession { id } => {
419 self.switch_to_session_by_id(&id)?;
420 }
421 AppCommand::SetModel { id } => {
422 self.pick_model(&id)?;
423 }
424 AppCommand::SetSetting { key, value } => {
425 self.set_setting(&key, &value)?;
426 }
427 }
428 Ok(())
429 }
430
431 pub fn run_command(&mut self, cmd: &str) -> Result<()> {
434 match self.parse_command(cmd) {
435 Ok(cmd) => self.execute(cmd),
436 Err(message) => {
437 self.push_status(message);
438 Ok(())
439 }
440 }
441 }
442}