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: "skills",
94 desc: "manage skills",
95 aliases: &["addskill"],
96 },
97 Command {
98 name: "files",
99 desc: "browse space files / images / scripts",
100 aliases: &[
101 "file", "attach", "upload", "docs", "image", "images", "img", "pictures", "script",
102 "scripts",
103 ],
104 },
105 Command {
106 name: "apps",
107 desc: "view space apps",
108 aliases: &["app", "webapps"],
109 },
110 Command {
111 name: "research",
112 desc: "deep multi-agent research (blank = scope topic from this chat)",
113 aliases: &["deep-research"],
114 },
115 Command {
116 name: "export",
117 desc: "write session's report + sources to a file",
118 aliases: &["save-report"],
119 },
120 Command {
121 name: "watch",
122 desc: "standing research, re-runs every 24h",
123 aliases: &["watches"],
124 },
125 Command {
126 name: "usage",
127 desc: "token/cache/cost analytics by backend and model",
128 aliases: &["analytics", "costs", "billing"],
129 },
130 Command {
131 name: "web",
132 desc: "toggle web answer mode (search-first, cited)",
133 aliases: &["websearch"],
134 },
135 Command {
136 name: "incognito",
137 desc: "toggle incognito (no persistence, no apps)",
138 aliases: &["private", "anon"],
139 },
140 Command {
141 name: "copy",
142 desc: "copy last reply",
143 aliases: &["yank", "clip"],
144 },
145 Command {
146 name: "quit",
147 desc: "exit the app",
148 aliases: &["q", "exit"],
149 },
150];
151
152pub fn fuzzy_score(hay: &str, needle: &str) -> Option<i32> {
156 let hay = hay.to_lowercase();
157 let needle = needle.to_lowercase();
158 let mut chars = hay.chars();
159 let mut score = 0i32;
160 let mut prev_matched = false;
161 let mut pos = 0i32;
162 for nc in needle.chars() {
163 loop {
164 let hc = chars.next()?;
165 if hc == nc {
166 score += 1;
167 if prev_matched {
168 score += 2;
169 }
170 if pos == 0 {
171 score += 3;
172 }
173 prev_matched = true;
174 pos += 1;
175 break;
176 }
177 prev_matched = false;
178 pos += 1;
179 }
180 }
181 Some(score)
182}
183
184pub fn command_score(c: &Command, needle: &str) -> Option<i32> {
187 if needle.is_empty() {
188 return Some(0);
189 }
190 let mut best: Option<i32> = None;
191 let mut upd = |s: &str, bonus: i32| {
192 if let Some(sc) = fuzzy_score(s, needle) {
193 let v = sc + bonus;
194 best = Some(best.map_or(v, |b| b.max(v)));
195 }
196 };
197 upd(c.name, 100);
198 for a in c.aliases {
199 upd(a, 50);
200 }
201 upd(c.desc, 0);
202 best
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213pub enum AppCommand {
214 Quit,
216 Send { text: String },
218 Cancel { task: Option<u64> },
220 Steer { text: String },
222 AnswerGate { text: String },
224 NewSession,
226 Compact,
228 OpenSessionPicker,
230 OpenSpacePicker,
232 OpenModelPicker,
234 OpenLogin,
236 OpenSwarm,
238 OpenSettings,
240 OpenCopyMenu,
242 OpenSkills,
244 OpenFiles { tab: FilesTab },
246 OpenApps,
248 RunResearch { topic: String, gated: bool },
251 Export,
253 ToggleWeb,
255 Incognito { on: bool },
257 Watch { topic: Option<String> },
259 OpenUsage,
261 ArmSkill { name: String, rest: Option<String> },
263 SwitchSpace { name: String },
265 ResolveSession { id: String },
267 SetModel { id: String },
269 SetSetting { key: String, value: String },
271}
272
273impl App {
274 pub fn parse_command(&self, cmd: &str) -> std::result::Result<AppCommand, String> {
279 if let Some(rest) = cmd.strip_prefix("research!") {
282 return Ok(AppCommand::RunResearch {
283 topic: rest.trim().to_string(),
284 gated: false,
285 });
286 }
287 let token = cmd.split_whitespace().next().unwrap_or("");
288 let canonical = COMMANDS
290 .iter()
291 .find(|c| c.name == token || c.aliases.contains(&token))
292 .map_or(token, |c| c.name);
293 let rest = |cmd: &str, token: &str| cmd[token.len()..].trim().to_string();
294 match canonical {
295 "quit" => Ok(AppCommand::Quit),
296 "new" => Ok(AppCommand::NewSession),
297 "compact" => Ok(AppCommand::Compact),
298 "session" => Ok(AppCommand::OpenSessionPicker),
299 "space" => Ok(AppCommand::OpenSpacePicker),
300 "model" => Ok(AppCommand::OpenModelPicker),
301 "login" => Ok(AppCommand::OpenLogin),
302 "swarm" => Ok(AppCommand::OpenSwarm),
303 "config" => Ok(AppCommand::OpenSettings),
304 "copy" => Ok(AppCommand::OpenCopyMenu),
305 "skills" => Ok(AppCommand::OpenSkills),
306 "files" => Ok(AppCommand::OpenFiles {
307 tab: match token {
308 t if t == "image" || t == "images" || t == "img" || t == "pictures" => {
309 FilesTab::Images
310 }
311 t if t == "script" || t == "scripts" => FilesTab::Scripts,
312 _ => FilesTab::Files,
313 },
314 }),
315 "apps" => Ok(AppCommand::OpenApps),
316 "research" => Ok(AppCommand::RunResearch {
317 topic: rest(cmd, token),
318 gated: true,
319 }),
320 "export" => Ok(AppCommand::Export),
321 "web" => Ok(AppCommand::ToggleWeb),
322 "incognito" => Ok(AppCommand::Incognito {
323 on: !self.incognito,
324 }),
325 "watch" => {
326 let arg = rest(cmd, token);
327 Ok(AppCommand::Watch {
328 topic: (!arg.is_empty()).then_some(arg),
329 })
330 }
331 "usage" => Ok(AppCommand::OpenUsage),
332 other => {
333 if self.skills.iter().any(|s| s.name == other) {
334 let text = rest(cmd, token);
335 Ok(AppCommand::ArmSkill {
336 name: other.to_string(),
337 rest: (!text.is_empty()).then_some(text),
338 })
339 } else {
340 Err(format!("unknown command: /{other}"))
341 }
342 }
343 }
344 }
345
346 pub fn execute(&mut self, cmd: AppCommand) -> Result<()> {
352 match cmd {
353 AppCommand::Quit
357 | AppCommand::OpenSessionPicker
358 | AppCommand::OpenSpacePicker
359 | AppCommand::OpenModelPicker
360 | AppCommand::OpenLogin
361 | AppCommand::OpenSwarm
362 | AppCommand::OpenSettings
363 | AppCommand::OpenCopyMenu
364 | AppCommand::OpenSkills
365 | AppCommand::OpenFiles { .. }
366 | AppCommand::OpenApps
367 | AppCommand::OpenUsage
368 | AppCommand::Watch { .. } => {}
369 AppCommand::Send { text } => self.send_message(text)?,
370 AppCommand::Cancel { task } => match task {
371 Some(id) => self.cancel_chat_task(id)?,
372 None => self.stop_stream()?,
373 },
374 AppCommand::Steer { text } => self.steer_research(&text),
375 AppCommand::AnswerGate { text } => self.reply_to_survey_gate(&text),
376 AppCommand::NewSession => self.new_session(),
377 AppCommand::Compact => self.force_compact(),
378 AppCommand::RunResearch { topic, gated } => {
379 if !gated {
380 self.start_research_with_gate(&topic, false);
381 } else if topic.is_empty() {
382 self.start_research_from_chat();
383 } else {
384 self.start_research(&topic);
385 }
386 }
387 AppCommand::Export => {
388 self.export_report()?;
389 }
390 AppCommand::ToggleWeb => self.toggle_web_mode(),
391 AppCommand::Incognito { on } => {
392 if on != self.incognito {
393 self.toggle_incognito()?;
394 }
395 }
396 AppCommand::ArmSkill { name, rest } => {
397 self.forced_skill = Some(name.clone());
398 if let Some(text) = rest {
399 self.send_message(text)?;
400 } else {
401 self.push_status(format!("skill {name} armed for next message"));
402 }
403 }
404 AppCommand::SwitchSpace { name } => {
405 self.switch_space_cli(&name)?;
406 }
407 AppCommand::ResolveSession { id } => {
408 self.switch_to_session_by_id(&id)?;
409 }
410 AppCommand::SetModel { id } => {
411 self.pick_model(&id)?;
412 }
413 AppCommand::SetSetting { key, value } => {
414 self.set_setting(&key, &value)?;
415 }
416 }
417 Ok(())
418 }
419
420 pub fn run_command(&mut self, cmd: &str) -> Result<()> {
423 match self.parse_command(cmd) {
424 Ok(cmd) => self.execute(cmd),
425 Err(message) => {
426 self.push_status(message);
427 Ok(())
428 }
429 }
430 }
431}