diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index a60975e..2d3c264 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -83,6 +83,11 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Inspect discovered Claude config files", argument_hint: None, }, + SlashCommandSpec { + name: "memory", + summary: "Inspect loaded Claude instruction memory files", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -96,6 +101,7 @@ pub enum SlashCommand { Cost, Resume { session_path: Option }, Config, + Memory, Unknown(String), } @@ -125,6 +131,7 @@ impl SlashCommand { session_path: parts.next().map(ToOwned::to_owned), }, "config" => Self::Config, + "memory" => Self::Memory, other => Self::Unknown(other.to_string()), }) } @@ -187,6 +194,7 @@ pub fn handle_slash_command( | SlashCommand::Cost | SlashCommand::Resume { .. } | SlashCommand::Config + | SlashCommand::Memory | SlashCommand::Unknown(_) => None, } } @@ -227,6 +235,7 @@ mod tests { }) ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); } #[test] @@ -241,7 +250,8 @@ mod tests { assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); - assert_eq!(slash_command_specs().len(), 9); + assert!(help.contains("/memory")); + assert_eq!(slash_command_specs().len(), 10); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1c998ae..3ba1a32 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -17,7 +17,8 @@ use render::{Spinner, TerminalRenderer}; use runtime::{ load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, - PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, + ToolExecutor, UsageTracker, }; use tools::{execute_tool, mvp_tool_specs}; @@ -205,27 +206,20 @@ fn resume_session(session_path: &Path, command: Option) { } }; - match command { - Some(command) if command.starts_with('/') => { - let Some(result) = handle_slash_command( - &command, - &session, - CompactionConfig { - max_estimated_tokens: 0, - ..CompactionConfig::default() - }, - ) else { - eprintln!("unknown slash command: {command}"); + match command.as_deref().and_then(SlashCommand::parse) { + Some(command) => match run_resume_command(session_path, &session, &command) { + Ok(Some(message)) => println!("{message}"), + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); std::process::exit(2); - }; - if let Err(error) = result.session.save_to_path(session_path) { - eprintln!("failed to persist resumed session: {error}"); - std::process::exit(1); } - println!("{}", result.message); - } - Some(other) => { - eprintln!("unsupported resumed command: {other}"); + }, + None if command.is_some() => { + eprintln!( + "unsupported resumed command: {}", + command.unwrap_or_default() + ); std::process::exit(2); } None => { @@ -238,6 +232,60 @@ fn resume_session(session_path: &Path, command: Option) { } } +fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result, Box> { + match command { + SlashCommand::Help => Ok(Some(render_repl_help())), + SlashCommand::Compact => { + let Some(result) = handle_slash_command( + "/compact", + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ) else { + return Ok(None); + }; + result.session.save_to_path(session_path)?; + Ok(Some(result.message)) + } + SlashCommand::Status => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(Some(format_status_line( + "restored-session", + session.messages.len(), + UsageTracker::from_session(session).turns(), + UsageTracker::from_session(session).current_turn_usage(), + usage, + 0, + permission_mode_label(), + ))) + } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(Some(format!( + "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ))) + } + SlashCommand::Config => Ok(Some(render_config_report()?)), + SlashCommand::Memory => Ok(Some(render_memory_report()?)), + SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Clear + | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), + } +} + fn run_repl(model: String) -> Result<(), Box> { let mut cli = LiveCli::new(model, true)?; let editor = input::LineEditor::new("› "); @@ -328,6 +376,7 @@ impl LiveCli { SlashCommand::Cost => self.print_cost(), SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Config => Self::print_config()?, + SlashCommand::Memory => Self::print_memory()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -444,34 +493,12 @@ impl LiveCli { } fn print_config() -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; + println!("{}", render_config_report()?); + Ok(()) + } - println!( - "config: loaded_files={} merged_keys={}", - runtime_config.loaded_entries().len(), - runtime_config.merged().len() - ); - for entry in discovered { - let source = match entry.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let status = if runtime_config - .loaded_entries() - .iter() - .any(|loaded_entry| loaded_entry.path == entry.path) - { - "loaded" - } else { - "missing" - }; - println!(" {source:<7} {status:<7} {}", entry.path.display()); - } - println!(" merged {}", runtime_config.as_json().render()); + fn print_memory() -> Result<(), Box> { + println!("{}", render_memory_report()?); Ok(()) } @@ -516,6 +543,77 @@ fn format_status_line( ) } +fn render_config_report() -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let mut lines = vec![format!( + "config: loaded_files={} merged_keys={}", + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + )]; + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + lines.push(format!( + " {source:<7} {status:<7} {}", + entry.path.display() + )); + } + lines.push(format!(" merged {}", runtime_config.as_json().render())); + Ok(lines.join( + " +", + )) +} + +fn render_memory_report() -> Result> { + let project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?; + let mut lines = vec![format!( + "memory: files={}", + project_context.instruction_files.len() + )]; + if project_context.instruction_files.is_empty() { + lines.push( + " No CLAUDE instruction files discovered in the current directory ancestry." + .to_string(), + ); + } else { + for file in project_context.instruction_files { + let preview = file.content.lines().next().unwrap_or("").trim(); + let preview = if preview.is_empty() { + "" + } else { + preview + }; + lines.push(format!( + " {} ({}) {}", + file.path.display(), + file.content.lines().count(), + preview + )); + } + } + Ok(lines.join( + " +", + )) +} + fn normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "read-only" => Some("read-only"), @@ -930,6 +1028,7 @@ mod tests { assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); + assert!(help.contains("/memory")); assert!(help.contains("/exit")); } @@ -984,6 +1083,7 @@ mod tests { }) ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); } #[test]