Skip to main content

nexus_core/app/
commands.rs

1//! The command seam: one enum for every user intent, parsed from the
2//! `/`-command line (or synthesized by the TUI keys, the CLI, or the Phase 4
3//! host). `run_command` stays the string front; everything that mutates the
4//! app goes through `App::execute`. The slash-command catalog (`COMMANDS`,
5//! `Command`, `Match`, `fuzzy_score`) also lives here — it's the pure,
6//! dependency-free half of the old `input.rs`, which 2e split into this
7//! catalog plus the TUI's composer ops (`crates/tui/src/composer.rs`).
8
9use anyhow::Result;
10
11use super::{App, FilesTab};
12
13/// A slash command: canonical `name`, a short (≤20 char) `desc`, and alias
14/// keywords. Names, aliases, and the description are all fuzzy-searchable, so
15/// typing `/history` surfaces `session`.
16pub struct Command {
17    pub name: &'static str,
18    pub desc: &'static str,
19    pub aliases: &'static [&'static str],
20}
21
22/// One row of the slash-command autocomplete.
23pub 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
157/// Subsequence fuzzy score, case-insensitive. `None` if `needle` isn't a
158/// subsequence of `hay`; higher is a better match (bonuses for contiguous runs
159/// and matching at the start).
160pub 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
189/// Best fuzzy score of `needle` across a command's name/aliases/desc, with the
190/// name weighted highest and the description lowest.
191pub 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/// One user intent, in the seam's own words. Parsed from the `/`-command
211/// line (`App::parse_command`) or synthesized by the TUI keys, the CLI, or
212/// the Phase 4 host; `App::execute` is the only mutation path.
213///
214/// Serde: `POST /v1/command` ships this enum directly — every payload is
215/// plain (strings, bools, optionals, [`FilesTab`](super::FilesTab)), so the
216/// seam itself is the wire type; no mirror needed.
217#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218pub enum AppCommand {
219    /// `/quit` — exit the app.
220    Quit,
221    /// Send a chat message (the composer's Enter, host messages).
222    Send { text: String },
223    /// Cancel an in-flight chat response; `None` = the active one (Esc).
224    Cancel { task: Option<u64> },
225    /// `/steer` — queue an extra instruction for the running research job.
226    Steer { text: String },
227    /// Reply to the parked survey/plan gate.
228    AnswerGate { text: String },
229    /// `/new` — fresh session.
230    NewSession,
231    /// `/compact` — compact the session transcript.
232    Compact,
233    /// `/session` — the session picker.
234    OpenSessionPicker,
235    /// `/space` — the space picker.
236    OpenSpacePicker,
237    /// `/model` — the model picker.
238    OpenModelPicker,
239    /// `/login` — the provider login popup.
240    OpenLogin,
241    /// `/swarm` — the swarm roster popup.
242    OpenSwarm,
243    /// `/config` — the nerd-config popup.
244    OpenSettings,
245    /// `/theme [opaque|transparent]` — configure the TUI background.
246    SetTheme { mode: String },
247    /// `/copy` — the copy menu.
248    OpenCopyMenu,
249    /// `/skills` — the skills popup.
250    OpenSkills,
251    /// `/files`, `/image`, `/script` — the file popup on a tab.
252    OpenFiles { tab: FilesTab },
253    /// `/apps` — the apps popup.
254    OpenApps,
255    /// `/research [topic]` — `gated: false` skips the plan-approval gate
256    /// (`/research!`); an empty topic distills one from recent chat.
257    RunResearch { topic: String, gated: bool },
258    /// `/export` — print the latest report + sources.
259    Export,
260    /// `/web` — toggle web-answer mode.
261    ToggleWeb,
262    /// `/incognito` — toggle (`on` is absolute for the host).
263    Incognito { on: bool },
264    /// `/watch [topic]` — open the watch picker, or create a watch.
265    Watch { topic: Option<String> },
266    /// `/usage` — the analytics popup.
267    OpenUsage,
268    /// `/<skill-name> [text]` — arm a skill; `text` sends it immediately.
269    ArmSkill { name: String, rest: Option<String> },
270    /// Switch the active space (CLI `--space`, host).
271    SwitchSpace { name: String },
272    /// Resolve and open a session by id/slug/prefix (CLI `open`, host).
273    ResolveSession { id: String },
274    /// Set the active model (CLI `--model`, host).
275    SetModel { id: String },
276    /// Set one named setting by key (host).
277    SetSetting { key: String, value: String },
278}
279
280impl App {
281    /// Parse a `/`-command line (without the leading slash) into the command
282    /// seam. Resolves aliases via the `COMMANDS` catalog and recognizes
283    /// `/<skill-name>` arms. `Err` carries a status-line message for unknown
284    /// commands — the TUI shows it without failing the key handler.
285    pub fn parse_command(&self, cmd: &str) -> std::result::Result<AppCommand, String> {
286        // `/research! <topic>` = research without the plan-approval gate.
287        // Handled before command lookup: the `!` makes the token miss COMMANDS.
288        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        // Resolve aliases (e.g. "history" -> "session") to a canonical name.
296        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    /// Run one parsed command — the mutation path for domain intents. The
357    /// TUI's `AppView::execute` intercepts the view-only commands (quit,
358    /// popup opens, the watch picker) before delegating here; headless
359    /// consumers only ever send domain commands. `run_command` is the
360    /// `/`-string parse front.
361    pub fn execute(&mut self, cmd: AppCommand) -> Result<()> {
362        match cmd {
363            // View-only commands — the TUI's `AppView::execute` handles these
364            // (they need the popup/should-quit state the view owns). They
365            // land here only from headless consumers, which never send them.
366            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    /// The `/`-string front: parse into the seam, then execute. Unknown
432    /// commands surface as a status line rather than an error.
433    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}