Skip to main content

albert_commands/
lib.rs

1use runtime::{compact_session, CompactionConfig, Session};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct CommandManifestEntry {
5    pub name: String,
6    pub source: CommandSource,
7}
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CommandSource {
11    Builtin,
12    InternalOnly,
13    FeatureGated,
14}
15
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct CommandRegistry {
18    entries: Vec<CommandManifestEntry>,
19}
20
21impl CommandRegistry {
22    #[must_use]
23    pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
24        Self { entries }
25    }
26
27    #[must_use]
28    pub fn entries(&self) -> &[CommandManifestEntry] {
29        &self.entries
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct SlashCommandSpec {
35    pub name: &'static str,
36    pub summary: &'static str,
37    pub argument_hint: Option<&'static str>,
38    pub resume_supported: bool,
39}
40
41const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
42    SlashCommandSpec {
43        name: "help",
44        summary: "Show available slash commands",
45        argument_hint: None,
46        resume_supported: true,
47    },
48    SlashCommandSpec {
49        name: "status",
50        summary: "Show current session status",
51        argument_hint: None,
52        resume_supported: true,
53    },
54    SlashCommandSpec {
55        name: "compact",
56        summary: "Compact local session history",
57        argument_hint: None,
58        resume_supported: true,
59    },
60    SlashCommandSpec {
61        name: "compress",
62        summary: "Aggressively compress session history into a summary",
63        argument_hint: None,
64        resume_supported: true,
65    },
66    SlashCommandSpec {
67        name: "model",
68        summary: "Show or switch the active model",
69        argument_hint: Some("[model]"),
70        resume_supported: false,
71    },
72    SlashCommandSpec {
73        name: "permissions",
74        summary: "Show or switch the active permission mode",
75        argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
76        resume_supported: false,
77    },
78    SlashCommandSpec {
79        name: "clear",
80        summary: "Start a fresh local session",
81        argument_hint: Some("[--confirm]"),
82        resume_supported: true,
83    },
84    SlashCommandSpec {
85        name: "cost",
86        summary: "Show cumulative token usage for this session",
87        argument_hint: None,
88        resume_supported: true,
89    },
90    SlashCommandSpec {
91        name: "resume",
92        summary: "Load a saved session into the REPL",
93        argument_hint: Some("<session-path>"),
94        resume_supported: false,
95    },
96    SlashCommandSpec {
97        name: "config",
98        summary: "Inspect Ternlang config files or merged sections",
99        argument_hint: Some("[env|hooks|model]"),
100        resume_supported: true,
101    },
102    SlashCommandSpec {
103        name: "memory",
104        summary: "Inspect loaded Ternlang instruction memory files",
105        argument_hint: None,
106        resume_supported: true,
107    },
108    SlashCommandSpec {
109        name: "init",
110        summary: "Create a starter ALBERT.md for this repo",
111        argument_hint: None,
112        resume_supported: true,
113    },
114    SlashCommandSpec {
115        name: "treemap",
116        summary: "View the repository structure tree in an overlay",
117        argument_hint: None,
118        resume_supported: true,
119    },
120    SlashCommandSpec {
121        name: "diff",
122        summary: "Show git diff for current workspace changes",
123        argument_hint: None,
124        resume_supported: true,
125    },
126    SlashCommandSpec {
127        name: "version",
128        summary: "Show CLI version and build information",
129        argument_hint: None,
130        resume_supported: true,
131    },
132    SlashCommandSpec {
133        name: "bughunter",
134        summary: "Inspect the codebase for likely bugs",
135        argument_hint: Some("[scope]"),
136        resume_supported: false,
137    },
138    SlashCommandSpec {
139        name: "commit",
140        summary: "Generate a commit message and create a git commit",
141        argument_hint: None,
142        resume_supported: false,
143    },
144    SlashCommandSpec {
145        name: "pr",
146        summary: "Draft or create a pull request from the conversation",
147        argument_hint: Some("[context]"),
148        resume_supported: false,
149    },
150    SlashCommandSpec {
151        name: "issue",
152        summary: "Draft or create a GitHub issue from the conversation",
153        argument_hint: Some("[context]"),
154        resume_supported: false,
155    },
156    SlashCommandSpec {
157        name: "ultraplan",
158        summary: "Run a deep planning prompt with multi-step reasoning",
159        argument_hint: Some("[task]"),
160        resume_supported: false,
161    },
162    SlashCommandSpec {
163        name: "teleport",
164        summary: "Jump to a file or symbol by searching the workspace",
165        argument_hint: Some("<symbol-or-path>"),
166        resume_supported: false,
167    },
168    SlashCommandSpec {
169        name: "debug-tool-call",
170        summary: "Replay the last tool call with debug details",
171        argument_hint: None,
172        resume_supported: false,
173    },
174    SlashCommandSpec {
175        name: "export",
176        summary: "Export the current conversation to a file",
177        argument_hint: Some("[file]"),
178        resume_supported: true,
179    },
180    SlashCommandSpec {
181        name: "session",
182        summary: "List or switch managed local sessions",
183        argument_hint: Some("[list|switch <session-id>]"),
184        resume_supported: false,
185    },
186    SlashCommandSpec {
187        name: "auth",
188        summary: "Configure LLM provider and API keys",
189        argument_hint: Some("[provider]"),
190        resume_supported: false,
191    },
192    SlashCommandSpec {
193        name: "plan",
194        summary: "Restate requirements and assess risks before implementation",
195        argument_hint: Some("[task]"),
196        resume_supported: false,
197    },
198    SlashCommandSpec {
199        name: "tdd",
200        summary: "Enforce test-driven development workflow",
201        argument_hint: Some("[interface]"),
202        resume_supported: false,
203    },
204    SlashCommandSpec {
205        name: "verify",
206        summary: "Run full verification: build, lint, test, and type-check",
207        argument_hint: None,
208        resume_supported: false,
209    },
210    SlashCommandSpec {
211        name: "code-review",
212        summary: "Full quality, security, and maintainability review",
213        argument_hint: Some("[files]"),
214        resume_supported: false,
215    },
216    SlashCommandSpec {
217        name: "build-fix",
218        summary: "Automatically detect and fix build errors",
219        argument_hint: None,
220        resume_supported: false,
221    },
222    SlashCommandSpec {
223        name: "aside",
224        summary: "Ask a quick side question without losing context",
225        argument_hint: Some("<question>"),
226        resume_supported: false,
227    },
228    SlashCommandSpec {
229        name: "learn",
230        summary: "Extract reusable patterns from the current session",
231        argument_hint: None,
232        resume_supported: false,
233    },
234    SlashCommandSpec {
235        name: "refactor",
236        summary: "Remove dead code and consolidate structure",
237        argument_hint: Some("[scope]"),
238        resume_supported: false,
239    },
240    SlashCommandSpec {
241        name: "checkpoint",
242        summary: "Mark a checkpoint in the current session",
243        argument_hint: Some("[label]"),
244        resume_supported: false,
245    },
246    SlashCommandSpec {
247        name: "docs",
248        summary: "Look up library or API documentation",
249        argument_hint: Some("<query>"),
250        resume_supported: false,
251    },
252    SlashCommandSpec {
253        name: "loop",
254        summary: "Engage autopilot loop to complete a mission",
255        argument_hint: Some("<mission>"),
256        resume_supported: false,
257    },
258    SlashCommandSpec {
259        name: "mcp",
260        summary: "Manage MCP servers (list / add / remove)",
261        argument_hint: Some("[list|add <name> <cmd>|remove <name>]"),
262        resume_supported: false,
263    },
264    SlashCommandSpec {
265        name: "remember",
266        summary: "Commit something to Albert's persistent vault memory",
267        argument_hint: Some("<text>"),
268        resume_supported: true,
269    },
270    SlashCommandSpec {
271        name: "recall",
272        summary: "Search Albert's vault for memories matching a keyword or #tag",
273        argument_hint: Some("<query>"),
274        resume_supported: true,
275    },
276    SlashCommandSpec {
277        name: "vault",
278        summary: "Show recent vault entries or search by tag/keyword",
279        argument_hint: Some("[query]"),
280        resume_supported: true,
281    },
282    SlashCommandSpec {
283        name: "upgrade",
284        summary: "Check for and install CLI updates",
285        argument_hint: None,
286        resume_supported: false,
287    },
288    SlashCommandSpec {
289        name: "terminal-setup",
290        summary: "Configure TUI theme and keybindings",
291        argument_hint: None,
292        resume_supported: false,
293    },
294    SlashCommandSpec {
295        name: "setup-github",
296        summary: "Configure GitHub authentication for /pr and /issue",
297        argument_hint: None,
298        resume_supported: false,
299    },
300    SlashCommandSpec {
301        name: "recap",
302        summary: "Summarize work done in the current session",
303        argument_hint: None,
304        resume_supported: false,
305    },
306    SlashCommandSpec {
307        name: "session-recap",
308        summary: "Recap the previous session and bring it into context",
309        argument_hint: None,
310        resume_supported: false,
311    },
312];
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum SlashCommand {
316    Help,
317    Status,
318    Compact,
319    Compress,
320    Bughunter {
321        scope: Option<String>,
322    },
323    Commit,
324    Pr {
325        context: Option<String>,
326    },
327    Issue {
328        context: Option<String>,
329    },
330    Ultraplan {
331        task: Option<String>,
332    },
333    Teleport {
334        target: Option<String>,
335    },
336    DebugToolCall,
337    Model {
338        model: Option<String>,
339    },
340    Permissions {
341        mode: Option<String>,
342    },
343    Clear {
344        confirm: bool,
345    },
346    Cost,
347    Resume {
348        session_path: Option<String>,
349    },
350    Config {
351        section: Option<String>,
352    },
353    Memory,
354    Init,
355    Treemap,
356    Diff,
357    Version,
358    Export {
359        path: Option<String>,
360    },
361    Session {
362        action: Option<String>,
363        target: Option<String>,
364    },
365    Auth {
366        provider: Option<String>,
367    },
368    Plan {
369        task: Option<String>,
370    },
371    Tdd {
372        interface: Option<String>,
373    },
374    Verify,
375    CodeReview {
376        files: Option<String>,
377    },
378    BuildFix,
379    Aside {
380        question: Option<String>,
381    },
382    Learn,
383    Refactor {
384        scope: Option<String>,
385    },
386    Checkpoint {
387        label: Option<String>,
388    },
389    Docs {
390        query: Option<String>,
391    },
392    Loop {
393        mission: Option<String>,
394    },
395    Mcp {
396        action: Option<String>,
397        args: Option<String>,
398    },
399    Remember {
400        content: Option<String>,
401    },
402    Recall {
403        query: Option<String>,
404    },
405    Vault {
406        query: Option<String>,
407    },
408    Upgrade,
409    TerminalSetup,
410    SetupGithub,
411    Settings,
412    Recap,
413    SessionRecap,
414    Unknown(String),
415}
416
417impl SlashCommand {
418    #[must_use]
419    pub fn parse(input: &str) -> Option<Self> {
420        let trimmed = input.trim();
421        if !trimmed.starts_with('/') {
422            return None;
423        }
424
425        let mut parts = trimmed.trim_start_matches('/').split_whitespace();
426        let command = parts.next().unwrap_or_default();
427        Some(match command {
428            "help" | "?" => Self::Help,
429            "status" => Self::Status,
430            "compact" => Self::Compact,
431            "compress" => Self::Compress,
432            "upgrade" => Self::Upgrade,
433            "terminal-setup" => Self::TerminalSetup,
434            "setup-github" => Self::SetupGithub,
435            "settings" => Self::Settings,
436            "recap" => Self::Recap,
437            "session-recap" => Self::SessionRecap,
438            "bughunter" => Self::Bughunter {
439                scope: remainder_after_command(trimmed, command),
440            },
441            "commit" => Self::Commit,
442            "pr" => Self::Pr {
443                context: remainder_after_command(trimmed, command),
444            },
445            "issue" => Self::Issue {
446                context: remainder_after_command(trimmed, command),
447            },
448            "ultraplan" => Self::Ultraplan {
449                task: remainder_after_command(trimmed, command),
450            },
451            "teleport" => Self::Teleport {
452                target: remainder_after_command(trimmed, command),
453            },
454            "debug-tool-call" => Self::DebugToolCall,
455            "model" => Self::Model {
456                model: parts.next().map(ToOwned::to_owned),
457            },
458            "permissions" => Self::Permissions {
459                mode: parts.next().map(ToOwned::to_owned),
460            },
461            "clear" => Self::Clear {
462                confirm: parts.next() == Some("--confirm"),
463            },
464            "cost" => Self::Cost,
465            "resume" => Self::Resume {
466                session_path: parts.next().map(ToOwned::to_owned),
467            },
468            "config" => Self::Config {
469                section: parts.next().map(ToOwned::to_owned),
470            },
471            "memory" => Self::Memory,
472            "init" => Self::Init,
473            "treemap" => Self::Treemap,
474            "diff" => Self::Diff,
475            "version" => Self::Version,
476            "export" => Self::Export {
477                path: parts.next().map(ToOwned::to_owned),
478            },
479            "session" => Self::Session {
480                action: parts.next().map(ToOwned::to_owned),
481                target: parts.next().map(ToOwned::to_owned),
482            },
483            "auth" => Self::Auth {
484                provider: parts.next().map(ToOwned::to_owned),
485            },
486            "plan" => Self::Plan {
487                task: remainder_after_command(trimmed, command),
488            },
489            "tdd" => Self::Tdd {
490                interface: remainder_after_command(trimmed, command),
491            },
492            "verify" => Self::Verify,
493            "code-review" => Self::CodeReview {
494                files: remainder_after_command(trimmed, command),
495            },
496            "build-fix" => Self::BuildFix,
497            "aside" => Self::Aside {
498                question: remainder_after_command(trimmed, command),
499            },
500            "learn" => Self::Learn,
501            "refactor" => Self::Refactor {
502                scope: remainder_after_command(trimmed, command),
503            },
504            "checkpoint" => Self::Checkpoint {
505                label: remainder_after_command(trimmed, command),
506            },
507            "docs" => Self::Docs {
508                query: remainder_after_command(trimmed, command),
509            },
510            "loop" => Self::Loop {
511                mission: remainder_after_command(trimmed, command),
512            },
513            "mcp" => {
514                let rest = remainder_after_command(trimmed, command);
515                let (action, args) = rest.as_deref().map_or((None, None), |s| {
516                    let mut iter = s.splitn(2, ' ');
517                    let a = iter.next().map(ToOwned::to_owned);
518                    let b = iter.next().map(str::trim).filter(|v| !v.is_empty()).map(ToOwned::to_owned);
519                    (a, b)
520                });
521                Self::Mcp { action, args }
522            },
523            "remember" => Self::Remember {
524                content: remainder_after_command(trimmed, command),
525            },
526            "recall" => Self::Recall {
527                query: remainder_after_command(trimmed, command),
528            },
529            "vault" => Self::Vault {
530                query: remainder_after_command(trimmed, command),
531            },
532            other => Self::Unknown(other.to_string()),
533        })
534    }
535}
536
537fn remainder_after_command(input: &str, command: &str) -> Option<String> {
538    input
539        .trim()
540        .strip_prefix(&format!("/{command}"))
541        .map(str::trim)
542        .filter(|value| !value.is_empty())
543        .map(ToOwned::to_owned)
544}
545
546#[must_use]
547pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
548    SLASH_COMMAND_SPECS
549}
550
551#[must_use]
552pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
553    slash_command_specs()
554        .iter()
555        .filter(|spec| spec.resume_supported)
556        .collect()
557}
558
559#[must_use]
560pub fn render_slash_command_help() -> String {
561    use console::style;
562    
563    let specs = slash_command_specs();
564    let mut output = String::new();
565    
566    output.push_str(&format!("\n{}\n", style("SLASH COMMAND LIBRARY").bold().underlined()));
567    output.push_str(&format!("  {}\n", style("[resume] works with --resume SESSION.json").dim()));
568
569    let categories = vec![
570        ("SESSION & CONTEXT", vec!["status", "clear", "resume", "session", "export", "compact", "compress", "cost", "memory", "aside", "checkpoint", "learn", "recap", "session-recap"]),
571        ("DEVELOPMENT & REASONING", vec!["ultraplan", "plan", "loop", "tdd", "verify", "code-review", "build-fix", "refactor", "docs", "bughunter", "init", "treemap", "teleport", "diff", "commit", "pr", "issue", "debug-tool-call"]),
572        ("CONFIGURATION & AUTH", vec!["model", "permissions", "auth", "config", "setup-github", "terminal-setup", "settings"]),
573        ("UTILITY", vec!["help", "version", "upgrade"]),
574    ];
575
576    for (cat_name, cat_cmds) in categories {
577        output.push_str(&format!("\n{}\n", style(cat_name).cyan().bold()));
578        for cmd_name in cat_cmds {
579            if let Some(spec) = specs.iter().find(|s| s.name == cmd_name) {
580                let name_display = match spec.argument_hint {
581                    Some(hint) => format!("/{} {}", spec.name, hint),
582                    None => format!("/{}", spec.name),
583                };
584                let resume = if spec.resume_supported {
585                    style(" [resume]").dim().to_string()
586                } else {
587                    "".to_string()
588                };
589                output.push_str(&format!("  {:<25} {}{}\n", style(name_display).green(), spec.summary, resume));
590            }
591        }
592    }
593    
594    output
595}
596
597#[derive(Debug, Clone, PartialEq, Eq)]
598pub struct SlashCommandResult {
599    pub message: String,
600    pub session: Session,
601}
602
603#[must_use]
604pub fn handle_slash_command(
605    input: &str,
606    session: &Session,
607    compaction: CompactionConfig,
608) -> Option<SlashCommandResult> {
609    match SlashCommand::parse(input)? {
610        SlashCommand::Compact => {
611            let result = compact_session(session, compaction);
612            let message = if result.removed_message_count == 0 {
613                "Compaction skipped: session is below the compaction threshold.".to_string()
614            } else {
615                format!(
616                    "Compacted {} messages into a resumable system summary.",
617                    result.removed_message_count
618                )
619            };
620            Some(SlashCommandResult {
621                message,
622                session: result.compacted_session,
623            })
624        }
625        SlashCommand::Compress => {
626            // Aggressive manual compression: only keep last 2 messages
627            let result = compact_session(session, CompactionConfig {
628                preserve_recent_messages: 2,
629                max_estimated_tokens: 1, // Force it
630            });
631            let message = if result.removed_message_count == 0 {
632                "Compression skipped: session is empty or too short.".to_string()
633            } else {
634                format!(
635                    "Aggressively compressed {} messages. Albert's memory is now lean and sharp.",
636                    result.removed_message_count
637                )
638            };
639            Some(SlashCommandResult {
640                message,
641                session: result.compacted_session,
642            })
643        }
644        SlashCommand::Help => Some(SlashCommandResult {
645            message: render_slash_command_help(),
646            session: session.clone(),
647        }),
648        SlashCommand::Auth { .. }
649        | SlashCommand::Status
650        | SlashCommand::Bughunter { .. }
651        | SlashCommand::Commit
652        | SlashCommand::Pr { .. }
653        | SlashCommand::Issue { .. }
654        | SlashCommand::Ultraplan { .. }
655        | SlashCommand::Teleport { .. }
656        | SlashCommand::DebugToolCall
657        | SlashCommand::Model { .. }
658        | SlashCommand::Permissions { .. }
659        | SlashCommand::Clear { .. }
660        | SlashCommand::Cost
661        | SlashCommand::Resume { .. }
662        | SlashCommand::Config { .. }
663        | SlashCommand::Memory
664        | SlashCommand::Init
665        | SlashCommand::Treemap
666        | SlashCommand::Diff
667        | SlashCommand::Version
668        | SlashCommand::Export { .. }
669        | SlashCommand::Session { .. }
670        | SlashCommand::Plan { .. }
671        | SlashCommand::Tdd { .. }
672        | SlashCommand::Verify
673        | SlashCommand::CodeReview { .. }
674        | SlashCommand::BuildFix
675        | SlashCommand::Aside { .. }
676        | SlashCommand::Learn
677        | SlashCommand::Refactor { .. }
678        | SlashCommand::Checkpoint { .. }
679        | SlashCommand::Docs { .. }
680        | SlashCommand::Loop { .. }
681        | SlashCommand::Mcp { .. }
682        | SlashCommand::Remember { .. }
683        | SlashCommand::Recall { .. }
684        | SlashCommand::Vault { .. }
685        | SlashCommand::Upgrade
686        | SlashCommand::TerminalSetup
687        | SlashCommand::SetupGithub
688        | SlashCommand::Settings
689        | SlashCommand::Recap
690        | SlashCommand::SessionRecap
691        | SlashCommand::Unknown(_) => None,
692    }
693}
694
695
696#[cfg(test)]
697mod tests {
698    use super::{
699        handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
700        slash_command_specs, SlashCommand,
701    };
702    use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
703
704    #[test]
705    fn parses_supported_slash_commands() {
706        assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
707        assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
708        assert_eq!(
709            SlashCommand::parse("/bughunter runtime"),
710            Some(SlashCommand::Bughunter {
711                scope: Some("runtime".to_string())
712            })
713        );
714        assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
715        assert_eq!(
716            SlashCommand::parse("/pr ready for review"),
717            Some(SlashCommand::Pr {
718                context: Some("ready for review".to_string())
719            })
720        );
721        assert_eq!(
722            SlashCommand::parse("/issue flaky test"),
723            Some(SlashCommand::Issue {
724                context: Some("flaky test".to_string())
725            })
726        );
727        assert_eq!(
728            SlashCommand::parse("/ultraplan ship both features"),
729            Some(SlashCommand::Ultraplan {
730                task: Some("ship both features".to_string())
731            })
732        );
733        assert_eq!(
734            SlashCommand::parse("/teleport conversation.rs"),
735            Some(SlashCommand::Teleport {
736                target: Some("conversation.rs".to_string())
737            })
738        );
739        assert_eq!(
740            SlashCommand::parse("/debug-tool-call"),
741            Some(SlashCommand::DebugToolCall)
742        );
743        assert_eq!(
744            SlashCommand::parse("/model ternlang-opus"),
745            Some(SlashCommand::Model {
746                model: Some("ternlang-opus".to_string()),
747            })
748        );
749        assert_eq!(
750            SlashCommand::parse("/model"),
751            Some(SlashCommand::Model { model: None })
752        );
753        assert_eq!(
754            SlashCommand::parse("/permissions read-only"),
755            Some(SlashCommand::Permissions {
756                mode: Some("read-only".to_string()),
757            })
758        );
759        assert_eq!(
760            SlashCommand::parse("/clear"),
761            Some(SlashCommand::Clear { confirm: false })
762        );
763        assert_eq!(
764            SlashCommand::parse("/clear --confirm"),
765            Some(SlashCommand::Clear { confirm: true })
766        );
767        assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
768        assert_eq!(
769            SlashCommand::parse("/resume session.json"),
770            Some(SlashCommand::Resume {
771                session_path: Some("session.json".to_string()),
772            })
773        );
774        assert_eq!(
775            SlashCommand::parse("/config"),
776            Some(SlashCommand::Config { section: None })
777        );
778        assert_eq!(
779            SlashCommand::parse("/config env"),
780            Some(SlashCommand::Config {
781                section: Some("env".to_string())
782            })
783        );
784        assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
785        assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
786        assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
787        assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
788        assert_eq!(
789            SlashCommand::parse("/export notes.txt"),
790            Some(SlashCommand::Export {
791                path: Some("notes.txt".to_string())
792            })
793        );
794        assert_eq!(
795            SlashCommand::parse("/session switch abc123"),
796            Some(SlashCommand::Session {
797                action: Some("switch".to_string()),
798                target: Some("abc123".to_string())
799            })
800        );
801    }
802
803    #[test]
804    fn renders_help_from_shared_specs() {
805        let help = render_slash_command_help();
806        assert!(help.contains("works with --resume SESSION.json"));
807        assert!(help.contains("/help"));
808        assert!(help.contains("/status"));
809        assert!(help.contains("/compact"));
810        assert!(help.contains("/bughunter [scope]"));
811        assert!(help.contains("/commit"));
812        assert!(help.contains("/pr [context]"));
813        assert!(help.contains("/issue [context]"));
814        assert!(help.contains("/ultraplan [task]"));
815        assert!(help.contains("/teleport <symbol-or-path>"));
816        assert!(help.contains("/debug-tool-call"));
817        assert!(help.contains("/model [model]"));
818        assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
819        assert!(help.contains("/clear [--confirm]"));
820        assert!(help.contains("/cost"));
821        assert!(help.contains("/resume <session-path>"));
822        assert!(help.contains("/config [env|hooks|model]"));
823        assert!(help.contains("/memory"));
824        assert!(help.contains("/init"));
825        assert!(help.contains("/diff"));
826        assert!(help.contains("/version"));
827        assert!(help.contains("/export [file]"));
828        assert!(help.contains("/session [list|switch <session-id>]"));
829        assert_eq!(slash_command_specs().len(), 22);
830        assert_eq!(resume_supported_slash_commands().len(), 11);
831    }
832
833    #[test]
834    fn compacts_sessions_via_slash_command() {
835        let session = Session {
836            version: 1,
837            messages: vec![
838                ConversationMessage::user_text("a ".repeat(200)),
839                ConversationMessage::assistant(vec![ContentBlock::Text {
840                    text: "b ".repeat(200),
841                }]),
842                ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
843                ConversationMessage::assistant(vec![ContentBlock::Text {
844                    text: "recent".to_string(),
845                }]),
846            ],
847        };
848
849        let result = handle_slash_command(
850            "/compact",
851            &session,
852            CompactionConfig {
853                preserve_recent_messages: 2,
854                max_estimated_tokens: 1,
855            },
856        )
857        .expect("slash command should be handled");
858
859        assert!(result.message.contains("Compacted 2 messages"));
860        assert_eq!(result.session.messages[0].role, MessageRole::System);
861    }
862
863    #[test]
864    fn help_command_is_non_mutating() {
865        let session = Session::new();
866        let result = handle_slash_command("/help", &session, CompactionConfig::default())
867            .expect("help command should be handled");
868        assert_eq!(result.session, session);
869        assert!(result.message.contains("Slash commands"));
870    }
871
872    #[test]
873    fn ignores_unknown_or_runtime_bound_slash_commands() {
874        let session = Session::new();
875        assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
876        assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
877        assert!(
878            handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
879        );
880        assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
881        assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
882        assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
883        assert!(
884            handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
885        );
886        assert!(
887            handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
888        );
889        assert!(
890            handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
891                .is_none()
892        );
893        assert!(
894            handle_slash_command("/model ternlang", &session, CompactionConfig::default()).is_none()
895        );
896        assert!(handle_slash_command(
897            "/permissions read-only",
898            &session,
899            CompactionConfig::default()
900        )
901        .is_none());
902        assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
903        assert!(
904            handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
905                .is_none()
906        );
907        assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
908        assert!(handle_slash_command(
909            "/resume session.json",
910            &session,
911            CompactionConfig::default()
912        )
913        .is_none());
914        assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
915        assert!(
916            handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
917        );
918        assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
919        assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
920        assert!(
921            handle_slash_command("/export note.txt", &session, CompactionConfig::default())
922                .is_none()
923        );
924        assert!(
925            handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
926        );
927    }
928}