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