diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b3609bf..3cd9d6d 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -89,8 +89,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ }, SlashCommandSpec { name: "config", - summary: "Inspect discovered Claude config files", - argument_hint: None, + summary: "Inspect Claude config files or merged sections", + argument_hint: Some("[env|hooks|model]"), resume_supported: true, }, SlashCommandSpec { @@ -117,7 +117,7 @@ pub enum SlashCommand { Clear { confirm: bool }, Cost, Resume { session_path: Option }, - Config, + Config { section: Option }, Memory, Init, Unknown(String), @@ -150,7 +150,9 @@ impl SlashCommand { "resume" => Self::Resume { session_path: parts.next().map(ToOwned::to_owned), }, - "config" => Self::Config, + "config" => Self::Config { + section: parts.next().map(ToOwned::to_owned), + }, "memory" => Self::Memory, "init" => Self::Init, other => Self::Unknown(other.to_string()), @@ -174,8 +176,8 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> { #[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(), + "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 { @@ -230,7 +232,7 @@ pub fn handle_slash_command( | SlashCommand::Clear { .. } | SlashCommand::Cost | SlashCommand::Resume { .. } - | SlashCommand::Config + | SlashCommand::Config { .. } | SlashCommand::Memory | SlashCommand::Init | SlashCommand::Unknown(_) => None, @@ -280,7 +282,16 @@ mod tests { session_path: Some("session.json".to_string()), }) ); - assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + 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)); } @@ -288,7 +299,7 @@ mod tests { #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); - assert!(help.contains("resume-safe commands")); + assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); @@ -297,7 +308,7 @@ mod tests { assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); - assert!(help.contains("/config")); + assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert_eq!(slash_command_specs().len(), 11); @@ -340,7 +351,7 @@ mod tests { 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:")); + assert!(result.message.contains("Slash commands")); } #[test] @@ -370,5 +381,8 @@ mod tests { ) .is_none()); assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/config env", &session, CompactionConfig::default()).is_none() + ); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index faa9639..04eeda3 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -12,9 +12,7 @@ use api::{ ToolResultContentBlock, }; -use commands::{ - handle_slash_command, render_slash_command_help, resume_supported_slash_commands, SlashCommand, -}; +use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand}; use compat_harness::{extract_manifest, UpstreamPaths}; use render::{Spinner, TerminalRenderer}; use runtime::{ @@ -258,6 +256,8 @@ struct StatusContext { loaded_config_files: usize, discovered_config_files: usize, memory_file_count: usize, + project_root: Option, + git_branch: Option, } #[derive(Debug, Clone, Copy)] @@ -291,6 +291,122 @@ fn format_model_switch_report(previous: &str, next: &str, message_count: usize) ) } +fn format_permissions_report(mode: &str) -> String { + format!( + "Permissions + Current mode {mode} + +Available modes + read-only Allow read/search tools only + workspace-write Allow editing within the workspace + danger-full-access Allow unrestricted tool access" + ) +} + +fn format_permissions_switch_report(previous: &str, next: &str) -> String { + format!( + "Permissions updated + Previous {previous} + Current {next}" + ) +} + +fn format_cost_report(usage: TokenUsage) -> String { + format!( + "Cost + Input tokens {} + Output tokens {} + Cache create {} + Cache read {} + Total tokens {}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ) +} + +fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { + format!( + "Session resumed + Session file {session_path} + Messages {message_count} + Turns {turns}" + ) +} + +fn format_init_report(path: &Path, created: bool) -> String { + if created { + format!( + "Init + CLAUDE.md {} + Result created + Next step Review and tailor the generated guidance", + path.display() + ) + } else { + format!( + "Init + CLAUDE.md {} + Result skipped (already exists) + Next step Edit the existing file intentionally if workflows changed", + path.display() + ) + } +} + +fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { + if skipped { + format!( + "Compact + Result skipped + Reason session below compaction threshold + Messages kept {resulting_messages}" + ) + } else { + format!( + "Compact + Result compacted + Messages removed {removed} + Messages kept {resulting_messages}" + ) + } +} + +fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { + let Some(status) = status else { + return (None, None); + }; + let branch = status.lines().next().and_then(|line| { + line.strip_prefix("## ") + .map(|line| { + line.split(['.', ' ']) + .next() + .unwrap_or_default() + .to_string() + }) + .filter(|value| !value.is_empty()) + }); + let project_root = find_git_root().ok(); + (project_root, branch) +} + +fn find_git_root() -> Result> { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + return Err("not a git repository".into()); + } + let path = String::from_utf8(output.stdout)?.trim().to_string(); + if path.is_empty() { + return Err("empty git root".into()); + } + Ok(PathBuf::from(path)) +} + fn run_resume_command( session_path: &Path, session: &Session, @@ -302,23 +418,20 @@ fn run_resume_command( message: Some(render_repl_help()), }), SlashCommand::Compact => { - let Some(result) = handle_slash_command( - "/compact", + let result = runtime::compact_session( session, CompactionConfig { max_estimated_tokens: 0, ..CompactionConfig::default() }, - ) else { - return Ok(ResumeCommandOutcome { - session: session.clone(), - message: None, - }); - }; - result.session.save_to_path(session_path)?; + ); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + result.compacted_session.save_to_path(session_path)?; Ok(ResumeCommandOutcome { - session: result.session, - message: Some(result.message), + session: result.compacted_session, + message: Some(format_compact_report(removed, kept, skipped)), }) } SlashCommand::Clear { confirm } => { @@ -363,19 +476,12 @@ fn run_resume_command( let usage = UsageTracker::from_session(session).cumulative_usage(); Ok(ResumeCommandOutcome { session: session.clone(), - message: 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(), - )), + message: Some(format_cost_report(usage)), }) } - SlashCommand::Config => Ok(ResumeCommandOutcome { + SlashCommand::Config { section } => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(render_config_report()?), + message: Some(render_config_report(section.as_deref())?), }), SlashCommand::Memory => Ok(ResumeCommandOutcome { session: session.clone(), @@ -481,7 +587,7 @@ impl LiveCli { SlashCommand::Clear { confirm } => self.clear_session(confirm)?, SlashCommand::Cost => self.print_cost(), SlashCommand::Resume { session_path } => self.resume_session(session_path)?, - SlashCommand::Config => Self::print_config()?, + SlashCommand::Config { section } => Self::print_config(section.as_deref())?, SlashCommand::Memory => Self::print_memory()?, SlashCommand::Init => Self::run_init()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), @@ -548,7 +654,7 @@ impl LiveCli { fn set_permissions(&mut self, mode: Option) -> Result<(), Box> { let Some(mode) = mode else { - println!("Current permission mode: {}", permission_mode_label()); + println!("{}", format_permissions_report(permission_mode_label())); return Ok(()); }; @@ -559,10 +665,11 @@ impl LiveCli { })?; if normalized == permission_mode_label() { - println!("Permission mode already set to {normalized}."); + println!("{}", format_permissions_report(normalized)); return Ok(()); } + let previous = permission_mode_label().to_string(); let session = self.runtime.session().clone(); self.runtime = build_runtime_with_permission_mode( session, @@ -571,7 +678,10 @@ impl LiveCli { true, normalized, )?; - println!("Switched permission mode to {normalized}."); + println!( + "{}", + format_permissions_switch_report(&previous, normalized) + ); Ok(()) } @@ -590,20 +700,20 @@ impl LiveCli { true, permission_mode_label(), )?; - println!("Cleared local session history."); + println!( + "Session cleared + Mode fresh session + Preserved model {} + Permission mode {}", + self.model, + permission_mode_label() + ); Ok(()) } fn print_cost(&self) { let cumulative = self.runtime.usage().cumulative_usage(); - println!( - "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", - cumulative.input_tokens, - cumulative.output_tokens, - cumulative.cache_creation_input_tokens, - cumulative.cache_read_input_tokens, - cumulative.total_tokens(), - ); + println!("{}", format_cost_report(cumulative)); } fn resume_session( @@ -624,12 +734,15 @@ impl LiveCli { true, permission_mode_label(), )?; - println!("Resumed session from {session_path} ({message_count} messages)."); + println!( + "{}", + format_resume_report(&session_path, message_count, self.runtime.usage().turns()) + ); Ok(()) } - fn print_config() -> Result<(), Box> { - println!("{}", render_config_report()?); + fn print_config(section: Option<&str>) -> Result<(), Box> { + println!("{}", render_config_report(section)?); Ok(()) } @@ -646,6 +759,8 @@ impl LiveCli { fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; self.runtime = build_runtime_with_permission_mode( result.compacted_session, self.model.clone(), @@ -653,16 +768,22 @@ impl LiveCli { true, permission_mode_label(), )?; - println!("Compacted {removed} messages."); + println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } } fn render_repl_help() -> String { - format!( - "{} - /exit Quit the REPL", - render_slash_command_help() + [ + "REPL".to_string(), + " /exit Quit the REPL".to_string(), + " /quit Quit the REPL".to_string(), + String::new(), + render_slash_command_help(), + ] + .join( + " +", ) } @@ -673,13 +794,17 @@ fn status_context( let loader = ConfigLoader::default_for(&cwd); let discovered_config_files = loader.discover().len(); let runtime_config = loader.load()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); Ok(StatusContext { cwd, session_path: session_path.map(Path::to_path_buf), loaded_config_files: runtime_config.loaded_entries().len(), discovered_config_files, memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, }) } @@ -713,10 +838,17 @@ fn format_status_report( format!( "Workspace Cwd {} + Project root {} + Git branch {} Session {} Config files loaded {}/{} Memory files {}", context.cwd.display(), + context + .project_root + .as_ref() + .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()), + context.git_branch.as_deref().unwrap_or("unknown"), context.session_path.as_ref().map_or_else( || "live-repl".to_string(), |path| path.display().to_string() @@ -733,7 +865,7 @@ fn format_status_report( ) } -fn render_config_report() -> Result> { +fn render_config_report(section: Option<&str>) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let discovered = loader.discover(); @@ -771,6 +903,36 @@ fn render_config_report() -> Result> { entry.path.display() )); } + + if let Some(section) = section { + lines.push(format!("Merged section: {section}")); + let value = match section { + "env" => runtime_config.get("env"), + "hooks" => runtime_config.get("hooks"), + "model" => runtime_config.get("model"), + other => { + lines.push(format!( + " Unsupported config section '{other}'. Use env, hooks, or model." + )); + return Ok(lines.join( + " +", + )); + } + }; + lines.push(format!( + " {}", + match value { + Some(value) => value.render(), + None => "".to_string(), + } + )); + return Ok(lines.join( + " +", + )); + } + lines.push("Merged JSON".to_string()); lines.push(format!(" {}", runtime_config.as_json().render())); Ok(lines.join( @@ -780,27 +942,33 @@ fn render_config_report() -> Result> { } fn render_memory_report() -> Result> { - let project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?; + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; let mut lines = vec![format!( - "memory: files={}", + "Memory + Working directory {} + Instruction files {}", + cwd.display(), project_context.instruction_files.len() )]; if project_context.instruction_files.is_empty() { + lines.push("Discovered files".to_string()); lines.push( " No CLAUDE instruction files discovered in the current directory ancestry." .to_string(), ); } else { - for file in project_context.instruction_files { + lines.push("Discovered files".to_string()); + for (index, file) in project_context.instruction_files.iter().enumerate() { let preview = file.content.lines().next().unwrap_or("").trim(); let preview = if preview.is_empty() { "" } else { preview }; + lines.push(format!(" {}. {}", index + 1, file.path.display(),)); lines.push(format!( - " {} ({}) {}", - file.path.display(), + " lines={} preview={}", file.content.lines().count(), preview )); @@ -816,15 +984,12 @@ fn init_claude_md() -> Result> { let cwd = env::current_dir()?; let claude_md = cwd.join("CLAUDE.md"); if claude_md.exists() { - return Ok(format!( - "init: skipped because {} already exists", - claude_md.display() - )); + return Ok(format_init_report(&claude_md, false)); } let content = render_init_claude_md(&cwd); fs::write(&claude_md, content)?; - Ok(format!("init: created {}", claude_md.display())) + Ok(format_init_report(&claude_md, true)) } fn render_init_claude_md(cwd: &Path) -> String { @@ -1234,10 +1399,12 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - format_model_report, format_model_switch_report, format_status_report, - normalize_permission_mode, parse_args, render_init_claude_md, render_repl_help, - resume_supported_slash_commands, status_context, CliAction, SlashCommand, StatusUsage, - DEFAULT_MODEL, + format_compact_report, format_cost_report, format_init_report, format_model_report, + format_model_switch_report, format_permissions_report, format_permissions_switch_report, + format_resume_report, format_status_report, normalize_permission_mode, parse_args, + parse_git_status_metadata, render_config_report, render_init_claude_md, + render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, + CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::{Path, PathBuf}; @@ -1324,9 +1491,17 @@ mod tests { ); } + #[test] + fn shared_help_uses_resume_annotation_copy() { + let help = commands::render_slash_command_help(); + assert!(help.contains("Slash commands")); + assert!(help.contains("works with --resume SESSION.json")); + } + #[test] fn repl_help_includes_shared_commands_and_exit() { let help = render_repl_help(); + assert!(help.contains("REPL")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/model [model]")); @@ -1334,7 +1509,7 @@ mod tests { assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); - assert!(help.contains("/config")); + assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/exit")); @@ -1352,6 +1527,67 @@ mod tests { ); } + #[test] + fn resume_report_uses_sectioned_layout() { + let report = format_resume_report("session.json", 14, 6); + assert!(report.contains("Session resumed")); + assert!(report.contains("Session file session.json")); + assert!(report.contains("Messages 14")); + assert!(report.contains("Turns 6")); + } + + #[test] + fn compact_report_uses_structured_output() { + let compacted = format_compact_report(8, 5, false); + assert!(compacted.contains("Compact")); + assert!(compacted.contains("Result compacted")); + assert!(compacted.contains("Messages removed 8")); + let skipped = format_compact_report(0, 3, true); + assert!(skipped.contains("Result skipped")); + } + + #[test] + fn cost_report_uses_sectioned_layout() { + let report = format_cost_report(runtime::TokenUsage { + input_tokens: 20, + output_tokens: 8, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 1, + }); + assert!(report.contains("Cost")); + assert!(report.contains("Input tokens 20")); + assert!(report.contains("Output tokens 8")); + assert!(report.contains("Cache create 3")); + assert!(report.contains("Cache read 1")); + assert!(report.contains("Total tokens 32")); + } + + #[test] + fn permissions_report_uses_sectioned_layout() { + let report = format_permissions_report("workspace-write"); + assert!(report.contains("Permissions")); + assert!(report.contains("Current mode workspace-write")); + assert!(report.contains("Available modes")); + assert!(report.contains("danger-full-access")); + } + + #[test] + fn permissions_switch_report_is_structured() { + let report = format_permissions_switch_report("read-only", "workspace-write"); + assert!(report.contains("Permissions updated")); + assert!(report.contains("Previous read-only")); + assert!(report.contains("Current workspace-write")); + } + + #[test] + fn init_report_uses_structured_output() { + let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true); + assert!(created.contains("Init")); + assert!(created.contains("Result created")); + let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false); + assert!(skipped.contains("skipped (already exists)")); + } + #[test] fn model_report_uses_sectioned_layout() { let report = format_model_report("claude-sonnet", 12, 4); @@ -1398,6 +1634,8 @@ mod tests { loaded_config_files: 2, discovered_config_files: 3, memory_file_count: 4, + project_root: Some(PathBuf::from("/tmp")), + git_branch: Some("main".to_string()), }, ); assert!(status.contains("Status")); @@ -1407,19 +1645,46 @@ mod tests { assert!(status.contains("Latest total 10")); assert!(status.contains("Cumulative total 31")); assert!(status.contains("Cwd /tmp/project")); + assert!(status.contains("Project root /tmp")); + assert!(status.contains("Git branch main")); assert!(status.contains("Session session.json")); assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Memory files 4")); } + #[test] + fn config_report_supports_section_views() { + let report = render_config_report(Some("env")).expect("config report should render"); + assert!(report.contains("Merged section: env")); + } + + #[test] + fn memory_report_uses_sectioned_layout() { + let report = render_memory_report().expect("memory report should render"); + assert!(report.contains("Memory")); + assert!(report.contains("Working directory")); + assert!(report.contains("Instruction files")); + assert!(report.contains("Discovered files")); + } + #[test] fn config_report_uses_sectioned_layout() { - let report = super::render_config_report().expect("config report should render"); + let report = render_config_report(None).expect("config report should render"); assert!(report.contains("Config")); assert!(report.contains("Discovered files")); assert!(report.contains("Merged JSON")); } + #[test] + fn parses_git_status_metadata() { + let (root, branch) = parse_git_status_metadata(Some( + "## rcc/cli...origin/rcc/cli + M src/main.rs", + )); + assert_eq!(branch.as_deref(), Some("rcc/cli")); + let _ = root; + } + #[test] fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); @@ -1466,7 +1731,16 @@ mod tests { SlashCommand::parse("/clear --confirm"), Some(SlashCommand::Clear { confirm: true }) ); - assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + 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)); }