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 let result = compact_session(session, CompactionConfig {
628 preserve_recent_messages: 2,
629 max_estimated_tokens: 1, });
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}