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: "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
152/// Subsequence fuzzy score, case-insensitive. `None` if `needle` isn't a
153/// subsequence of `hay`; higher is a better match (bonuses for contiguous runs
154/// and matching at the start).
155pub 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
184/// Best fuzzy score of `needle` across a command's name/aliases/desc, with the
185/// name weighted highest and the description lowest.
186pub 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/// One user intent, in the seam's own words. Parsed from the `/`-command
206/// line (`App::parse_command`) or synthesized by the TUI keys, the CLI, or
207/// the Phase 4 host; `App::execute` is the only mutation path.
208///
209/// Serde: `POST /v1/command` ships this enum directly — every payload is
210/// plain (strings, bools, optionals, [`FilesTab`](super::FilesTab)), so the
211/// seam itself is the wire type; no mirror needed.
212#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213pub enum AppCommand {
214    /// `/quit` — exit the app.
215    Quit,
216    /// Send a chat message (the composer's Enter, host messages).
217    Send { text: String },
218    /// Cancel an in-flight chat response; `None` = the active one (Esc).
219    Cancel { task: Option<u64> },
220    /// `/steer` — queue an extra instruction for the running research job.
221    Steer { text: String },
222    /// Reply to the parked survey/plan gate.
223    AnswerGate { text: String },
224    /// `/new` — fresh session.
225    NewSession,
226    /// `/compact` — compact the session transcript.
227    Compact,
228    /// `/session` — the session picker.
229    OpenSessionPicker,
230    /// `/space` — the space picker.
231    OpenSpacePicker,
232    /// `/model` — the model picker.
233    OpenModelPicker,
234    /// `/login` — the provider login popup.
235    OpenLogin,
236    /// `/swarm` — the swarm roster popup.
237    OpenSwarm,
238    /// `/config` — the nerd-config popup.
239    OpenSettings,
240    /// `/copy` — the copy menu.
241    OpenCopyMenu,
242    /// `/skills` — the skills popup.
243    OpenSkills,
244    /// `/files`, `/image`, `/script` — the file popup on a tab.
245    OpenFiles { tab: FilesTab },
246    /// `/apps` — the apps popup.
247    OpenApps,
248    /// `/research [topic]` — `gated: false` skips the plan-approval gate
249    /// (`/research!`); an empty topic distills one from recent chat.
250    RunResearch { topic: String, gated: bool },
251    /// `/export` — print the latest report + sources.
252    Export,
253    /// `/web` — toggle web-answer mode.
254    ToggleWeb,
255    /// `/incognito` — toggle (`on` is absolute for the host).
256    Incognito { on: bool },
257    /// `/watch [topic]` — open the watch picker, or create a watch.
258    Watch { topic: Option<String> },
259    /// `/usage` — the analytics popup.
260    OpenUsage,
261    /// `/<skill-name> [text]` — arm a skill; `text` sends it immediately.
262    ArmSkill { name: String, rest: Option<String> },
263    /// Switch the active space (CLI `--space`, host).
264    SwitchSpace { name: String },
265    /// Resolve and open a session by id/slug/prefix (CLI `open`, host).
266    ResolveSession { id: String },
267    /// Set the active model (CLI `--model`, host).
268    SetModel { id: String },
269    /// Set one named setting by key (host).
270    SetSetting { key: String, value: String },
271}
272
273impl App {
274    /// Parse a `/`-command line (without the leading slash) into the command
275    /// seam. Resolves aliases via the `COMMANDS` catalog and recognizes
276    /// `/<skill-name>` arms. `Err` carries a status-line message for unknown
277    /// commands — the TUI shows it without failing the key handler.
278    pub fn parse_command(&self, cmd: &str) -> std::result::Result<AppCommand, String> {
279        // `/research! <topic>` = research without the plan-approval gate.
280        // Handled before command lookup: the `!` makes the token miss COMMANDS.
281        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        // Resolve aliases (e.g. "history" -> "session") to a canonical name.
289        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    /// Run one parsed command — the mutation path for domain intents. The
347    /// TUI's `AppView::execute` intercepts the view-only commands (quit,
348    /// popup opens, the watch picker) before delegating here; headless
349    /// consumers only ever send domain commands. `run_command` is the
350    /// `/`-string parse front.
351    pub fn execute(&mut self, cmd: AppCommand) -> Result<()> {
352        match cmd {
353            // View-only commands — the TUI's `AppView::execute` handles these
354            // (they need the popup/should-quit state the view owns). They
355            // land here only from headless consumers, which never send them.
356            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    /// The `/`-string front: parse into the seam, then execute. Unknown
421    /// commands surface as a status line rather than an error.
422    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}