Skip to main content

mermaid_cli/domain/
slash_commands.rs

1//! Single source of truth for slash commands. Used by:
2//! - The palette widget (rendering + filtering)
3//! - The dispatcher in `command_handler.rs` (validating known commands)
4//! - The `/help` handler (rendering the command list)
5//!
6//! Adding a new command means adding one entry to `COMMAND_REGISTRY`
7//! plus wiring its handler into the `match` in `handle_command`.
8//! The palette + help text update automatically.
9
10/// Metadata for one slash command. Names exclude the leading `/`.
11#[derive(Debug, Clone, Copy)]
12pub struct SlashCommand {
13    /// Canonical command name without the leading `/`. Lowercase.
14    pub name: &'static str,
15    /// Alternative names that route to the same handler. Used by the
16    /// dispatcher AND prefix filter — typing `/q` matches `/quit`.
17    pub aliases: &'static [&'static str],
18    /// One-line user-visible description shown in palette and `/help`.
19    pub description: &'static str,
20    /// Optional argument hint shown after the command name in the
21    /// palette, e.g. `Some("[name]")` for `/model [name]`.
22    pub arg_hint: Option<&'static str>,
23    /// UX grouping used by `/help`; the palette still preserves registry order.
24    pub group: SlashCommandGroup,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SlashCommandGroup {
29    Everyday,
30    ModelContext,
31    SafetyRecovery,
32    Integrations,
33    AdvancedRuntime,
34}
35
36impl SlashCommandGroup {
37    pub fn title(self) -> &'static str {
38        match self {
39            SlashCommandGroup::Everyday => "Everyday",
40            SlashCommandGroup::ModelContext => "Model and context",
41            SlashCommandGroup::SafetyRecovery => "Safety and recovery",
42            SlashCommandGroup::Integrations => "Integrations",
43            SlashCommandGroup::AdvancedRuntime => "Advanced runtime",
44        }
45    }
46}
47
48pub const COMMAND_GROUPS: &[SlashCommandGroup] = &[
49    SlashCommandGroup::Everyday,
50    SlashCommandGroup::ModelContext,
51    SlashCommandGroup::SafetyRecovery,
52    SlashCommandGroup::Integrations,
53    SlashCommandGroup::AdvancedRuntime,
54];
55
56/// Authoritative list of keyboard shortcuts (key → description), surfaced in
57/// `/help` beside the commands and reusable for a future `?` overlay. Kept in
58/// sync by hand with the bindings in `reducer::handle_key`. Plain text only.
59pub const KEYBINDINGS: &[(&str, &str)] = &[
60    ("Enter", "Submit the prompt"),
61    ("Ctrl+J", "Insert a newline (multi-line input)"),
62    ("Esc", "Interrupt the current turn"),
63    ("Up / Down", "Browse input history"),
64    ("PageUp / PageDown", "Scroll the transcript"),
65    ("Shift+Up / Shift+Down", "Scroll the transcript one line"),
66    ("End", "Jump to the newest message"),
67    ("Ctrl+V", "Paste (including images)"),
68    ("Ctrl+O", "Compose the prompt in $VISUAL/$EDITOR"),
69    ("Ctrl+B", "Background a running command"),
70    ("Ctrl+T", "Expand or collapse the task checklist"),
71    ("Alt+T", "Cycle reasoning depth"),
72    ("Alt+P", "Toggle plan mode"),
73    ("Shift+Tab", "Cycle the safety mode"),
74    ("Ctrl+C", "Cancel, then exit"),
75    ("Ctrl+D", "Quit"),
76];
77
78/// Authoritative registry of all slash commands. Order here is the
79/// order shown in the palette and `/help`.
80pub const COMMAND_REGISTRY: &[SlashCommand] = &[
81    SlashCommand {
82        name: "model",
83        aliases: &[],
84        description: "Switch model (auto-pulls if needed) or show current",
85        arg_hint: Some("[name]"),
86        group: SlashCommandGroup::ModelContext,
87    },
88    SlashCommand {
89        name: "reasoning",
90        aliases: &[],
91        description: "Set reasoning depth (none, minimal, low, medium, high, max, xhigh)",
92        arg_hint: Some("[level]"),
93        group: SlashCommandGroup::ModelContext,
94    },
95    SlashCommand {
96        name: "visible-reasoning",
97        aliases: &["visiblereasoning"],
98        description: "Show, hide, or toggle reasoning blocks in the transcript",
99        arg_hint: Some("[on|off|toggle]"),
100        group: SlashCommandGroup::ModelContext,
101    },
102    SlashCommand {
103        name: "clear",
104        aliases: &[],
105        description: "Clear chat history",
106        arg_hint: None,
107        group: SlashCommandGroup::Everyday,
108    },
109    SlashCommand {
110        name: "save",
111        aliases: &[],
112        description: "Save current conversation",
113        arg_hint: Some("[name]"),
114        group: SlashCommandGroup::Everyday,
115    },
116    SlashCommand {
117        name: "load",
118        aliases: &[],
119        description: "Load a conversation",
120        arg_hint: Some("[name]"),
121        group: SlashCommandGroup::Everyday,
122    },
123    SlashCommand {
124        name: "list",
125        aliases: &[],
126        description: "List saved conversations",
127        arg_hint: None,
128        group: SlashCommandGroup::Everyday,
129    },
130    SlashCommand {
131        name: "todos",
132        aliases: &["todo"],
133        description: "Show or edit the task checklist",
134        arg_hint: Some("[add <subject>|rm <id>|done <id>|clear]"),
135        group: SlashCommandGroup::Everyday,
136    },
137    SlashCommand {
138        name: "scratchpad",
139        aliases: &[],
140        description: "Show the session scratch directory and its contents",
141        arg_hint: None,
142        group: SlashCommandGroup::Everyday,
143    },
144    SlashCommand {
145        name: "usage",
146        aliases: &[],
147        description: "Show provider token usage and session totals",
148        arg_hint: None,
149        group: SlashCommandGroup::ModelContext,
150    },
151    SlashCommand {
152        name: "context",
153        aliases: &[],
154        description: "Show context window/budget; set Ollama num_ctx (Ollama auto-fits to VRAM)",
155        arg_hint: Some("[n|auto|max|offload on|off]"),
156        group: SlashCommandGroup::ModelContext,
157    },
158    SlashCommand {
159        name: "compact",
160        aliases: &["compress", "summarize"],
161        description: "Compact conversation context with optional focus instructions",
162        arg_hint: Some("[instructions]"),
163        group: SlashCommandGroup::ModelContext,
164    },
165    SlashCommand {
166        name: "memory",
167        aliases: &["memories"],
168        description: "List durable memories saved across sessions",
169        arg_hint: None,
170        group: SlashCommandGroup::ModelContext,
171    },
172    SlashCommand {
173        name: "remember",
174        aliases: &[],
175        description: "Save a fact to private durable memory",
176        arg_hint: Some("<fact>"),
177        group: SlashCommandGroup::ModelContext,
178    },
179    SlashCommand {
180        name: "forget",
181        aliases: &[],
182        description: "Delete a saved memory by name",
183        arg_hint: Some("<name>"),
184        group: SlashCommandGroup::ModelContext,
185    },
186    SlashCommand {
187        name: "consolidate-memory",
188        aliases: &["memory-consolidate", "prune-memory"],
189        description: "Prune duplicate or obsolete memories (model-assisted, reversible)",
190        arg_hint: None,
191        group: SlashCommandGroup::ModelContext,
192    },
193    SlashCommand {
194        name: "doctor",
195        aliases: &[],
196        description: "Show in-session readiness, model, safety, and instruction status",
197        arg_hint: None,
198        group: SlashCommandGroup::Everyday,
199    },
200    SlashCommand {
201        name: "tasks",
202        aliases: &[],
203        description: "List durable runtime tasks",
204        arg_hint: None,
205        group: SlashCommandGroup::AdvancedRuntime,
206    },
207    SlashCommand {
208        name: "task",
209        aliases: &[],
210        description: "Show a durable runtime task",
211        arg_hint: Some("<id>"),
212        group: SlashCommandGroup::AdvancedRuntime,
213    },
214    SlashCommand {
215        name: "pause",
216        aliases: &[],
217        description: "Pause a durable task by marking it blocked",
218        arg_hint: Some("<task-id>"),
219        group: SlashCommandGroup::AdvancedRuntime,
220    },
221    SlashCommand {
222        name: "resume",
223        aliases: &[],
224        description: "Resume a durable task by marking it running",
225        arg_hint: Some("<task-id>"),
226        group: SlashCommandGroup::AdvancedRuntime,
227    },
228    SlashCommand {
229        name: "cancel",
230        aliases: &[],
231        description: "Cancel the active turn or a durable task",
232        arg_hint: Some("[task-id]"),
233        group: SlashCommandGroup::Everyday,
234    },
235    SlashCommand {
236        name: "handoff",
237        aliases: &[],
238        description: "Write a handoff report for the current or named task",
239        arg_hint: Some("[task-id]"),
240        group: SlashCommandGroup::Everyday,
241    },
242    SlashCommand {
243        name: "report",
244        aliases: &[],
245        description: "Show current context report or task report",
246        arg_hint: Some("[task-id]"),
247        group: SlashCommandGroup::Everyday,
248    },
249    SlashCommand {
250        name: "agents",
251        aliases: &[],
252        description: "List or kill background agents",
253        arg_hint: Some("[kill <id>|all]"),
254        group: SlashCommandGroup::AdvancedRuntime,
255    },
256    SlashCommand {
257        name: "processes",
258        aliases: &["procs"],
259        description: "List durable runtime processes",
260        arg_hint: None,
261        group: SlashCommandGroup::AdvancedRuntime,
262    },
263    SlashCommand {
264        name: "logs",
265        aliases: &[],
266        description: "Show a durable runtime process log",
267        arg_hint: Some("<process-id>"),
268        group: SlashCommandGroup::AdvancedRuntime,
269    },
270    SlashCommand {
271        name: "stop",
272        aliases: &[],
273        description: "Stop a durable runtime process",
274        arg_hint: Some("<process-id>"),
275        group: SlashCommandGroup::AdvancedRuntime,
276    },
277    SlashCommand {
278        name: "restart",
279        aliases: &[],
280        description: "Restart a durable runtime process",
281        arg_hint: Some("<process-id>"),
282        group: SlashCommandGroup::AdvancedRuntime,
283    },
284    SlashCommand {
285        name: "open",
286        aliases: &[],
287        description: "Open a URL, file, or process target",
288        arg_hint: Some("<url|path|process-id>"),
289        group: SlashCommandGroup::AdvancedRuntime,
290    },
291    SlashCommand {
292        name: "ports",
293        aliases: &[],
294        description: "Show listening TCP ports",
295        arg_hint: None,
296        group: SlashCommandGroup::AdvancedRuntime,
297    },
298    SlashCommand {
299        name: "safety",
300        aliases: &["permission"],
301        description: "Show or set the session safety mode (Shift+Tab also cycles)",
302        arg_hint: Some("[read_only|ask|auto|full_access]"),
303        group: SlashCommandGroup::SafetyRecovery,
304    },
305    SlashCommand {
306        name: "plan",
307        aliases: &[],
308        description: "Enter or leave plan mode (Alt+P also toggles)",
309        arg_hint: Some("[off|show|config]"),
310        group: SlashCommandGroup::SafetyRecovery,
311    },
312    SlashCommand {
313        name: "config",
314        aliases: &[],
315        description: "Open the settings picker (plan mode section)",
316        arg_hint: None,
317        group: SlashCommandGroup::Everyday,
318    },
319    SlashCommand {
320        name: "approvals",
321        aliases: &[],
322        description: "List pending approvals",
323        arg_hint: None,
324        group: SlashCommandGroup::SafetyRecovery,
325    },
326    SlashCommand {
327        name: "approve",
328        aliases: &[],
329        description: "Approve a pending action",
330        arg_hint: Some("<approval-id>"),
331        group: SlashCommandGroup::SafetyRecovery,
332    },
333    SlashCommand {
334        name: "deny",
335        aliases: &[],
336        description: "Deny a pending action",
337        arg_hint: Some("<approval-id>"),
338        group: SlashCommandGroup::SafetyRecovery,
339    },
340    SlashCommand {
341        name: "checkpoint",
342        aliases: &[],
343        description: "Create a restore checkpoint for one or more paths",
344        arg_hint: Some("<path...>"),
345        group: SlashCommandGroup::SafetyRecovery,
346    },
347    SlashCommand {
348        name: "checkpoints",
349        aliases: &[],
350        description: "List restore checkpoints",
351        arg_hint: None,
352        group: SlashCommandGroup::SafetyRecovery,
353    },
354    SlashCommand {
355        name: "restore",
356        aliases: &[],
357        description: "Restore a checkpoint",
358        arg_hint: Some("<id>"),
359        group: SlashCommandGroup::SafetyRecovery,
360    },
361    SlashCommand {
362        name: "plugins",
363        aliases: &[],
364        description: "List installed plugins",
365        arg_hint: None,
366        group: SlashCommandGroup::Integrations,
367    },
368    SlashCommand {
369        name: "model-info",
370        aliases: &[],
371        description: "Show provider/model capability information",
372        arg_hint: Some("<model>"),
373        group: SlashCommandGroup::ModelContext,
374    },
375    SlashCommand {
376        name: "cloud-setup",
377        aliases: &[],
378        description: "Configure Ollama Cloud API key",
379        arg_hint: None,
380        group: SlashCommandGroup::Integrations,
381    },
382    SlashCommand {
383        name: "theme",
384        aliases: &[],
385        description: "Switch the color theme or show the current one",
386        arg_hint: Some("[dark|light]"),
387        group: SlashCommandGroup::Everyday,
388    },
389    SlashCommand {
390        name: "editor",
391        aliases: &[],
392        description: "Compose the prompt in $VISUAL/$EDITOR (Ctrl+O)",
393        arg_hint: None,
394        group: SlashCommandGroup::Everyday,
395    },
396    SlashCommand {
397        name: "help",
398        aliases: &["h"],
399        description: "Show command help",
400        arg_hint: None,
401        group: SlashCommandGroup::Everyday,
402    },
403    SlashCommand {
404        name: "quit",
405        aliases: &["q"],
406        description: "Quit the application",
407        arg_hint: None,
408        group: SlashCommandGroup::Everyday,
409    },
410];
411
412/// One row of the slash palette: a built-in registry command or a
413/// plugin-contributed prompt command. Unifying them in ONE list, produced
414/// by ONE function ([`filter_entries`]), keeps the palette widget, the
415/// row-count layout, and the reducer's cursor/Tab handling agreeing on
416/// indices.
417pub enum PaletteEntry<'a> {
418    Builtin(&'static SlashCommand),
419    Plugin(&'a crate::domain::PluginCommand),
420}
421
422impl PaletteEntry<'_> {
423    pub fn name(&self) -> &str {
424        match self {
425            PaletteEntry::Builtin(c) => c.name,
426            PaletteEntry::Plugin(p) => &p.name,
427        }
428    }
429
430    /// Palette/hint description; plugin rows carry their origin.
431    pub fn description(&self) -> String {
432        match self {
433            PaletteEntry::Builtin(c) => c.description.to_string(),
434            PaletteEntry::Plugin(p) => {
435                if p.description.is_empty() {
436                    format!("(plugin:{})", p.plugin)
437                } else {
438                    format!("{} (plugin:{})", p.description, p.plugin)
439                }
440            },
441        }
442    }
443
444    pub fn arg_hint(&self) -> Option<&'static str> {
445        match self {
446            PaletteEntry::Builtin(c) => c.arg_hint,
447            PaletteEntry::Plugin(_) => Some("[args]"),
448        }
449    }
450}
451
452/// The palette's single source of truth: built-ins (registry order) then
453/// plugin commands (already name-sorted by the loader), both prefix-filtered.
454/// EVERY palette consumer (widget rows, layout row count, reducer cursor)
455/// must use this so their indices agree.
456pub fn filter_entries<'a>(
457    typed: &str,
458    plugin: &'a [crate::domain::PluginCommand],
459) -> Vec<PaletteEntry<'a>> {
460    let needle = typed.to_lowercase();
461    let mut entries: Vec<PaletteEntry<'a>> = filter_by_prefix(typed)
462        .into_iter()
463        .map(PaletteEntry::Builtin)
464        .collect();
465    entries.extend(
466        plugin
467            .iter()
468            .filter(|p| needle.is_empty() || p.name.starts_with(&needle))
469            .map(PaletteEntry::Plugin),
470    );
471    entries
472}
473
474/// Filter the registry by a typed prefix (after stripping the leading
475/// `/`). An empty prefix returns the full registry. Matches against the
476/// canonical name AND any aliases — typing `/q` finds `quit` because
477/// `q` is a `quit` alias. Result preserves registry order (stable).
478pub fn filter_by_prefix(typed: &str) -> Vec<&'static SlashCommand> {
479    let needle = typed.to_lowercase();
480    if needle.is_empty() {
481        return COMMAND_REGISTRY.iter().collect();
482    }
483    // Plain prefix match against the canonical name and aliases. Typing the
484    // first word of a hyphenated command (`consolidate`) must reveal it
485    // (`consolidate-memory`) — an earlier carve-out that hid hyphenated names
486    // from hyphenless prefixes broke that for commands without a hyphenless
487    // alias.
488    COMMAND_REGISTRY
489        .iter()
490        .filter(|cmd| {
491            cmd.name.starts_with(&needle) || cmd.aliases.iter().any(|a| a.starts_with(&needle))
492        })
493        .collect()
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn filter_by_prefix_empty_returns_all() {
502        let result = filter_by_prefix("");
503        assert_eq!(result.len(), COMMAND_REGISTRY.len());
504        // Order preserved.
505        assert_eq!(result[0].name, COMMAND_REGISTRY[0].name);
506    }
507
508    #[test]
509    fn filter_by_prefix_includes_exact_name_first() {
510        // Prefix match surfaces `model` (and any longer `model-*`); the exact
511        // name stays first by registry order.
512        let result = filter_by_prefix("model");
513        assert!(result.iter().any(|c| c.name == "model"));
514        assert_eq!(result[0].name, "model");
515    }
516
517    #[test]
518    fn filter_by_prefix_partial_prefix_includes_model() {
519        let result = filter_by_prefix("mod");
520        assert!(result.iter().any(|c| c.name == "model"));
521    }
522
523    #[test]
524    fn filter_by_prefix_matches_hyphenated_command_by_first_word() {
525        // Regression: typing the first word of a hyphenated command must
526        // reveal it, even before the hyphen — and at shorter prefixes too.
527        // Previously `/consolidate` showed nothing until `/consolidate-`.
528        assert!(
529            filter_by_prefix("consolidate")
530                .iter()
531                .any(|c| c.name == "consolidate-memory"),
532            "/consolidate must surface /consolidate-memory"
533        );
534        assert!(
535            filter_by_prefix("conso")
536                .iter()
537                .any(|c| c.name == "consolidate-memory")
538        );
539        // Other hyphenated commands too.
540        assert!(
541            filter_by_prefix("cloud")
542                .iter()
543                .any(|c| c.name == "cloud-setup")
544        );
545    }
546
547    #[test]
548    fn filter_by_prefix_no_match_returns_empty() {
549        let result = filter_by_prefix("zzzzz");
550        assert!(result.is_empty());
551    }
552
553    #[test]
554    fn filter_by_prefix_matches_aliases() {
555        // `/q` should find `quit` via its alias.
556        let result = filter_by_prefix("q");
557        assert!(
558            result.iter().any(|c| c.name == "quit"),
559            "expected quit in: {:?}",
560            result.iter().map(|c| c.name).collect::<Vec<_>>()
561        );
562    }
563
564    #[test]
565    fn filter_by_prefix_is_case_insensitive() {
566        // User shouldn't have to type lowercase /Q or /MODEL.
567        let upper = filter_by_prefix("MODEL");
568        assert!(upper.iter().any(|c| c.name == "model"));
569        assert_eq!(upper[0].name, "model");
570    }
571
572    #[test]
573    fn registry_has_no_duplicate_names() {
574        // Defensive: catches accidental duplicate entries during
575        // registry maintenance. Duplicate names would route ambiguously
576        // in the dispatcher.
577        let mut names: Vec<&str> = COMMAND_REGISTRY.iter().map(|c| c.name).collect();
578        names.sort_unstable();
579        let len_before = names.len();
580        names.dedup();
581        assert_eq!(
582            names.len(),
583            len_before,
584            "duplicate command name detected in COMMAND_REGISTRY"
585        );
586    }
587}