| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- 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<CommandManifestEntry>,
- }
- impl CommandRegistry {
- #[must_use]
- pub fn new(entries: Vec<CommandManifestEntry>) -> 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: Some("[--confirm]"),
- 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("<session-path>"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "config",
- summary: "Inspect Claude config files or merged sections",
- argument_hint: Some("[env|hooks|model]"),
- 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,
- },
- SlashCommandSpec {
- name: "diff",
- summary: "Show git diff for current workspace changes",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "version",
- summary: "Show CLI version and build information",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "export",
- summary: "Export the current conversation to a file",
- argument_hint: Some("[file]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "session",
- summary: "List or switch managed local sessions",
- argument_hint: Some("[list|switch <session-id>]"),
- resume_supported: false,
- },
- ];
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum SlashCommand {
- Help,
- Status,
- Compact,
- Model {
- model: Option<String>,
- },
- Permissions {
- mode: Option<String>,
- },
- Clear {
- confirm: bool,
- },
- Cost,
- Resume {
- session_path: Option<String>,
- },
- Config {
- section: Option<String>,
- },
- Memory,
- Init,
- Diff,
- Version,
- Export {
- path: Option<String>,
- },
- Session {
- action: Option<String>,
- target: Option<String>,
- },
- Unknown(String),
- }
- impl SlashCommand {
- #[must_use]
- pub fn parse(input: &str) -> Option<Self> {
- 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 {
- confirm: parts.next() == Some("--confirm"),
- },
- "cost" => Self::Cost,
- "resume" => Self::Resume {
- session_path: parts.next().map(ToOwned::to_owned),
- },
- "config" => Self::Config {
- section: parts.next().map(ToOwned::to_owned),
- },
- "memory" => Self::Memory,
- "init" => Self::Init,
- "diff" => Self::Diff,
- "version" => Self::Version,
- "export" => Self::Export {
- path: parts.next().map(ToOwned::to_owned),
- },
- "session" => Self::Session {
- action: parts.next().map(ToOwned::to_owned),
- target: parts.next().map(ToOwned::to_owned),
- },
- 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![
- "Slash commands".to_string(),
- " [resume] means the command also works with --resume SESSION.json".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<SlashCommandResult> {
- 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::Diff
- | SlashCommand::Version
- | SlashCommand::Export { .. }
- | SlashCommand::Session { .. }
- | 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 { confirm: false })
- );
- assert_eq!(
- SlashCommand::parse("/clear --confirm"),
- Some(SlashCommand::Clear { confirm: true })
- );
- 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 { section: None })
- );
- assert_eq!(
- SlashCommand::parse("/config env"),
- Some(SlashCommand::Config {
- section: Some("env".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
- assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
- assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
- assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
- assert_eq!(
- SlashCommand::parse("/export notes.txt"),
- Some(SlashCommand::Export {
- path: Some("notes.txt".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/session switch abc123"),
- Some(SlashCommand::Session {
- action: Some("switch".to_string()),
- target: Some("abc123".to_string())
- })
- );
- }
- #[test]
- fn renders_help_from_shared_specs() {
- let help = render_slash_command_help();
- assert!(help.contains("works with --resume SESSION.json"));
- 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 [--confirm]"));
- assert!(help.contains("/cost"));
- assert!(help.contains("/resume <session-path>"));
- assert!(help.contains("/config [env|hooks|model]"));
- assert!(help.contains("/memory"));
- assert!(help.contains("/init"));
- assert!(help.contains("/diff"));
- assert!(help.contains("/version"));
- assert!(help.contains("/export [file]"));
- assert!(help.contains("/session [list|switch <session-id>]"));
- assert_eq!(slash_command_specs().len(), 15);
- assert_eq!(resume_supported_slash_commands().len(), 11);
- }
- #[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("Slash 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("/clear --confirm", &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());
- assert!(
- handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
- );
- assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/export note.txt", &session, CompactionConfig::default())
- .is_none()
- );
- assert!(
- handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
- );
- }
- }
|