use runtime::{compact_session, CompactionConfig, Session}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandManifestEntry { pub name: String, pub source: CommandSource, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandSource { Builtin, InternalOnly, FeatureGated, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CommandRegistry { entries: Vec, } impl CommandRegistry { #[must_use] pub fn new(entries: Vec) -> Self { Self { entries } } #[must_use] pub fn entries(&self) -> &[CommandManifestEntry] { &self.entries } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SlashCommandSpec { pub name: &'static str, pub summary: &'static str, pub argument_hint: Option<&'static str>, pub resume_supported: bool, } const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "help", summary: "Show available slash commands", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "status", summary: "Show current session status", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "compact", summary: "Compact local session history", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "model", summary: "Show or switch the active model", argument_hint: Some("[model]"), resume_supported: false, }, SlashCommandSpec { name: "permissions", summary: "Show or switch the active permission mode", argument_hint: Some("[read-only|workspace-write|danger-full-access]"), resume_supported: false, }, SlashCommandSpec { name: "clear", summary: "Start a fresh local session", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "cost", summary: "Show cumulative token usage for this session", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "resume", summary: "Load a saved session into the REPL", argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { name: "config", summary: "Inspect discovered Claude config files", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "memory", summary: "Inspect loaded Claude instruction memory files", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "init", summary: "Create a starter CLAUDE.md for this repo", argument_hint: None, resume_supported: true, }, ]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SlashCommand { Help, Status, Compact, Model { model: Option }, Permissions { mode: Option }, Clear, Cost, Resume { session_path: Option }, Config, Memory, Init, Unknown(String), } impl SlashCommand { #[must_use] pub fn parse(input: &str) -> Option { let trimmed = input.trim(); if !trimmed.starts_with('/') { return None; } let mut parts = trimmed.trim_start_matches('/').split_whitespace(); let command = parts.next().unwrap_or_default(); Some(match command { "help" => Self::Help, "status" => Self::Status, "compact" => Self::Compact, "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, "permissions" => Self::Permissions { mode: parts.next().map(ToOwned::to_owned), }, "clear" => Self::Clear, "cost" => Self::Cost, "resume" => Self::Resume { session_path: parts.next().map(ToOwned::to_owned), }, "config" => Self::Config, "memory" => Self::Memory, "init" => Self::Init, other => Self::Unknown(other.to_string()), }) } } #[must_use] pub fn slash_command_specs() -> &'static [SlashCommandSpec] { SLASH_COMMAND_SPECS } #[must_use] pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> { slash_command_specs() .iter() .filter(|spec| spec.resume_supported) .collect() } #[must_use] pub fn render_slash_command_help() -> String { let mut lines = vec![ "Available commands:".to_string(), " (resume-safe commands are marked with [resume])".to_string(), ]; for spec in slash_command_specs() { let name = match spec.argument_hint { Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), None => format!("/{}", spec.name), }; let resume = if spec.resume_supported { " [resume]" } else { "" }; lines.push(format!(" {name:<20} {}{}", spec.summary, resume)); } lines.join("\n") } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SlashCommandResult { pub message: String, pub session: Session, } #[must_use] pub fn handle_slash_command( input: &str, session: &Session, compaction: CompactionConfig, ) -> Option { match SlashCommand::parse(input)? { SlashCommand::Compact => { let result = compact_session(session, compaction); let message = if result.removed_message_count == 0 { "Compaction skipped: session is below the compaction threshold.".to_string() } else { format!( "Compacted {} messages into a resumable system summary.", result.removed_message_count ) }; Some(SlashCommandResult { message, session: result.compacted_session, }) } SlashCommand::Help => Some(SlashCommandResult { message: render_slash_command_help(), session: session.clone(), }), SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear | SlashCommand::Cost | SlashCommand::Resume { .. } | SlashCommand::Config | SlashCommand::Memory | SlashCommand::Init | SlashCommand::Unknown(_) => None, } } #[cfg(test)] mod tests { use super::{ handle_slash_command, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; #[test] fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); assert_eq!( SlashCommand::parse("/model claude-opus"), Some(SlashCommand::Model { model: Some("claude-opus".to_string()), }) ); assert_eq!( SlashCommand::parse("/model"), Some(SlashCommand::Model { model: None }) ); assert_eq!( SlashCommand::parse("/permissions read-only"), Some(SlashCommand::Permissions { mode: Some("read-only".to_string()), }) ); assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear)); assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost)); assert_eq!( SlashCommand::parse("/resume session.json"), Some(SlashCommand::Resume { session_path: Some("session.json".to_string()), }) ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); } #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); assert!(help.contains("resume-safe commands")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert_eq!(slash_command_specs().len(), 11); assert_eq!(resume_supported_slash_commands().len(), 8); } #[test] fn compacts_sessions_via_slash_command() { let session = Session { version: 1, messages: vec![ ConversationMessage::user_text("a ".repeat(200)), ConversationMessage::assistant(vec![ContentBlock::Text { text: "b ".repeat(200), }]), ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false), ConversationMessage::assistant(vec![ContentBlock::Text { text: "recent".to_string(), }]), ], }; let result = handle_slash_command( "/compact", &session, CompactionConfig { preserve_recent_messages: 2, max_estimated_tokens: 1, }, ) .expect("slash command should be handled"); assert!(result.message.contains("Compacted 2 messages")); assert_eq!(result.session.messages[0].role, MessageRole::System); } #[test] fn help_command_is_non_mutating() { let session = Session::new(); let result = handle_slash_command("/help", &session, CompactionConfig::default()) .expect("help command should be handled"); assert_eq!(result.session, session); assert!(result.message.contains("Available commands:")); } #[test] fn ignores_unknown_or_runtime_bound_slash_commands() { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() ); assert!(handle_slash_command( "/permissions read-only", &session, CompactionConfig::default() ) .is_none()); assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command( "/resume session.json", &session, CompactionConfig::default() ) .is_none()); assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); } }