mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
Merge remote-tracking branch 'origin/rcc/cli' into dev/rust
This commit is contained in:
@@ -89,8 +89,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "config",
|
name: "config",
|
||||||
summary: "Inspect discovered Claude config files",
|
summary: "Inspect Claude config files or merged sections",
|
||||||
argument_hint: None,
|
argument_hint: Some("[env|hooks|model]"),
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
@@ -117,7 +117,7 @@ pub enum SlashCommand {
|
|||||||
Clear { confirm: bool },
|
Clear { confirm: bool },
|
||||||
Cost,
|
Cost,
|
||||||
Resume { session_path: Option<String> },
|
Resume { session_path: Option<String> },
|
||||||
Config,
|
Config { section: Option<String> },
|
||||||
Memory,
|
Memory,
|
||||||
Init,
|
Init,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
@@ -150,7 +150,9 @@ impl SlashCommand {
|
|||||||
"resume" => Self::Resume {
|
"resume" => Self::Resume {
|
||||||
session_path: parts.next().map(ToOwned::to_owned),
|
session_path: parts.next().map(ToOwned::to_owned),
|
||||||
},
|
},
|
||||||
"config" => Self::Config,
|
"config" => Self::Config {
|
||||||
|
section: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
"memory" => Self::Memory,
|
"memory" => Self::Memory,
|
||||||
"init" => Self::Init,
|
"init" => Self::Init,
|
||||||
other => Self::Unknown(other.to_string()),
|
other => Self::Unknown(other.to_string()),
|
||||||
@@ -174,8 +176,8 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_slash_command_help() -> String {
|
pub fn render_slash_command_help() -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Available commands:".to_string(),
|
"Slash commands".to_string(),
|
||||||
" (resume-safe commands are marked with [resume])".to_string(),
|
" [resume] means the command also works with --resume SESSION.json".to_string(),
|
||||||
];
|
];
|
||||||
for spec in slash_command_specs() {
|
for spec in slash_command_specs() {
|
||||||
let name = match spec.argument_hint {
|
let name = match spec.argument_hint {
|
||||||
@@ -230,7 +232,7 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Clear { .. }
|
| SlashCommand::Clear { .. }
|
||||||
| SlashCommand::Cost
|
| SlashCommand::Cost
|
||||||
| SlashCommand::Resume { .. }
|
| SlashCommand::Resume { .. }
|
||||||
| SlashCommand::Config
|
| SlashCommand::Config { .. }
|
||||||
| SlashCommand::Memory
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
@@ -280,7 +282,16 @@ mod tests {
|
|||||||
session_path: Some("session.json".to_string()),
|
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("/memory"), Some(SlashCommand::Memory));
|
||||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||||
}
|
}
|
||||||
@@ -288,7 +299,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn renders_help_from_shared_specs() {
|
fn renders_help_from_shared_specs() {
|
||||||
let help = render_slash_command_help();
|
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("/help"));
|
||||||
assert!(help.contains("/status"));
|
assert!(help.contains("/status"));
|
||||||
assert!(help.contains("/compact"));
|
assert!(help.contains("/compact"));
|
||||||
@@ -297,7 +308,7 @@ mod tests {
|
|||||||
assert!(help.contains("/clear [--confirm]"));
|
assert!(help.contains("/clear [--confirm]"));
|
||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-path>"));
|
||||||
assert!(help.contains("/config"));
|
assert!(help.contains("/config [env|hooks|model]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert_eq!(slash_command_specs().len(), 11);
|
assert_eq!(slash_command_specs().len(), 11);
|
||||||
@@ -340,7 +351,7 @@ mod tests {
|
|||||||
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
||||||
.expect("help command should be handled");
|
.expect("help command should be handled");
|
||||||
assert_eq!(result.session, session);
|
assert_eq!(result.session, session);
|
||||||
assert!(result.message.contains("Available commands:"));
|
assert!(result.message.contains("Slash commands"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -370,5 +381,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.is_none());
|
.is_none());
|
||||||
assert!(handle_slash_command("/config", &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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ use api::{
|
|||||||
ToolResultContentBlock,
|
ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
|
||||||
handle_slash_command, render_slash_command_help, resume_supported_slash_commands, SlashCommand,
|
|
||||||
};
|
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use render::{Spinner, TerminalRenderer};
|
use render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -258,6 +256,8 @@ struct StatusContext {
|
|||||||
loaded_config_files: usize,
|
loaded_config_files: usize,
|
||||||
discovered_config_files: usize,
|
discovered_config_files: usize,
|
||||||
memory_file_count: usize,
|
memory_file_count: usize,
|
||||||
|
project_root: Option<PathBuf>,
|
||||||
|
git_branch: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[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<PathBuf>, Option<String>) {
|
||||||
|
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<PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
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(
|
fn run_resume_command(
|
||||||
session_path: &Path,
|
session_path: &Path,
|
||||||
session: &Session,
|
session: &Session,
|
||||||
@@ -302,23 +418,20 @@ fn run_resume_command(
|
|||||||
message: Some(render_repl_help()),
|
message: Some(render_repl_help()),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Compact => {
|
SlashCommand::Compact => {
|
||||||
let Some(result) = handle_slash_command(
|
let result = runtime::compact_session(
|
||||||
"/compact",
|
|
||||||
session,
|
session,
|
||||||
CompactionConfig {
|
CompactionConfig {
|
||||||
max_estimated_tokens: 0,
|
max_estimated_tokens: 0,
|
||||||
..CompactionConfig::default()
|
..CompactionConfig::default()
|
||||||
},
|
},
|
||||||
) else {
|
);
|
||||||
return Ok(ResumeCommandOutcome {
|
let removed = result.removed_message_count;
|
||||||
session: session.clone(),
|
let kept = result.compacted_session.messages.len();
|
||||||
message: None,
|
let skipped = removed == 0;
|
||||||
});
|
result.compacted_session.save_to_path(session_path)?;
|
||||||
};
|
|
||||||
result.session.save_to_path(session_path)?;
|
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: result.session,
|
session: result.compacted_session,
|
||||||
message: Some(result.message),
|
message: Some(format_compact_report(removed, kept, skipped)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Clear { confirm } => {
|
SlashCommand::Clear { confirm } => {
|
||||||
@@ -363,19 +476,12 @@ fn run_resume_command(
|
|||||||
let usage = UsageTracker::from_session(session).cumulative_usage();
|
let usage = UsageTracker::from_session(session).cumulative_usage();
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format!(
|
message: Some(format_cost_report(usage)),
|
||||||
"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(ResumeCommandOutcome {
|
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_config_report()?),
|
message: Some(render_config_report(section.as_deref())?),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
@@ -481,7 +587,7 @@ impl LiveCli {
|
|||||||
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
|
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
|
||||||
SlashCommand::Cost => self.print_cost(),
|
SlashCommand::Cost => self.print_cost(),
|
||||||
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
|
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::Memory => Self::print_memory()?,
|
||||||
SlashCommand::Init => Self::run_init()?,
|
SlashCommand::Init => Self::run_init()?,
|
||||||
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
||||||
@@ -548,7 +654,7 @@ impl LiveCli {
|
|||||||
|
|
||||||
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let Some(mode) = mode else {
|
let Some(mode) = mode else {
|
||||||
println!("Current permission mode: {}", permission_mode_label());
|
println!("{}", format_permissions_report(permission_mode_label()));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -559,10 +665,11 @@ impl LiveCli {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
if normalized == permission_mode_label() {
|
if normalized == permission_mode_label() {
|
||||||
println!("Permission mode already set to {normalized}.");
|
println!("{}", format_permissions_report(normalized));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previous = permission_mode_label().to_string();
|
||||||
let session = self.runtime.session().clone();
|
let session = self.runtime.session().clone();
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
session,
|
session,
|
||||||
@@ -571,7 +678,10 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
normalized,
|
normalized,
|
||||||
)?;
|
)?;
|
||||||
println!("Switched permission mode to {normalized}.");
|
println!(
|
||||||
|
"{}",
|
||||||
|
format_permissions_switch_report(&previous, normalized)
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,20 +700,20 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
permission_mode_label(),
|
permission_mode_label(),
|
||||||
)?;
|
)?;
|
||||||
println!("Cleared local session history.");
|
println!(
|
||||||
|
"Session cleared
|
||||||
|
Mode fresh session
|
||||||
|
Preserved model {}
|
||||||
|
Permission mode {}",
|
||||||
|
self.model,
|
||||||
|
permission_mode_label()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_cost(&self) {
|
fn print_cost(&self) {
|
||||||
let cumulative = self.runtime.usage().cumulative_usage();
|
let cumulative = self.runtime.usage().cumulative_usage();
|
||||||
println!(
|
println!("{}", format_cost_report(cumulative));
|
||||||
"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(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resume_session(
|
fn resume_session(
|
||||||
@@ -624,12 +734,15 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
permission_mode_label(),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_config() -> Result<(), Box<dyn std::error::Error>> {
|
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!("{}", render_config_report()?);
|
println!("{}", render_config_report(section)?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +759,8 @@ impl LiveCli {
|
|||||||
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let result = self.runtime.compact(CompactionConfig::default());
|
let result = self.runtime.compact(CompactionConfig::default());
|
||||||
let removed = result.removed_message_count;
|
let removed = result.removed_message_count;
|
||||||
|
let kept = result.compacted_session.messages.len();
|
||||||
|
let skipped = removed == 0;
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
result.compacted_session,
|
result.compacted_session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@@ -653,16 +768,22 @@ impl LiveCli {
|
|||||||
true,
|
true,
|
||||||
permission_mode_label(),
|
permission_mode_label(),
|
||||||
)?;
|
)?;
|
||||||
println!("Compacted {removed} messages.");
|
println!("{}", format_compact_report(removed, kept, skipped));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_repl_help() -> String {
|
fn render_repl_help() -> String {
|
||||||
format!(
|
[
|
||||||
"{}
|
"REPL".to_string(),
|
||||||
/exit Quit the REPL",
|
" /exit Quit the REPL".to_string(),
|
||||||
render_slash_command_help()
|
" /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 loader = ConfigLoader::default_for(&cwd);
|
||||||
let discovered_config_files = loader.discover().len();
|
let discovered_config_files = loader.discover().len();
|
||||||
let runtime_config = loader.load()?;
|
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 {
|
Ok(StatusContext {
|
||||||
cwd,
|
cwd,
|
||||||
session_path: session_path.map(Path::to_path_buf),
|
session_path: session_path.map(Path::to_path_buf),
|
||||||
loaded_config_files: runtime_config.loaded_entries().len(),
|
loaded_config_files: runtime_config.loaded_entries().len(),
|
||||||
discovered_config_files,
|
discovered_config_files,
|
||||||
memory_file_count: project_context.instruction_files.len(),
|
memory_file_count: project_context.instruction_files.len(),
|
||||||
|
project_root,
|
||||||
|
git_branch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,10 +838,17 @@ fn format_status_report(
|
|||||||
format!(
|
format!(
|
||||||
"Workspace
|
"Workspace
|
||||||
Cwd {}
|
Cwd {}
|
||||||
|
Project root {}
|
||||||
|
Git branch {}
|
||||||
Session {}
|
Session {}
|
||||||
Config files loaded {}/{}
|
Config files loaded {}/{}
|
||||||
Memory files {}",
|
Memory files {}",
|
||||||
context.cwd.display(),
|
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(
|
context.session_path.as_ref().map_or_else(
|
||||||
|| "live-repl".to_string(),
|
|| "live-repl".to_string(),
|
||||||
|path| path.display().to_string()
|
|path| path.display().to_string()
|
||||||
@@ -733,7 +865,7 @@ fn format_status_report(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
|
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let discovered = loader.discover();
|
let discovered = loader.discover();
|
||||||
@@ -771,6 +903,36 @@ fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
|
|||||||
entry.path.display()
|
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 => "<unset>".to_string(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
return Ok(lines.join(
|
||||||
|
"
|
||||||
|
",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
lines.push("Merged JSON".to_string());
|
lines.push("Merged JSON".to_string());
|
||||||
lines.push(format!(" {}", runtime_config.as_json().render()));
|
lines.push(format!(" {}", runtime_config.as_json().render()));
|
||||||
Ok(lines.join(
|
Ok(lines.join(
|
||||||
@@ -780,27 +942,33 @@ fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
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!(
|
let mut lines = vec![format!(
|
||||||
"memory: files={}",
|
"Memory
|
||||||
|
Working directory {}
|
||||||
|
Instruction files {}",
|
||||||
|
cwd.display(),
|
||||||
project_context.instruction_files.len()
|
project_context.instruction_files.len()
|
||||||
)];
|
)];
|
||||||
if project_context.instruction_files.is_empty() {
|
if project_context.instruction_files.is_empty() {
|
||||||
|
lines.push("Discovered files".to_string());
|
||||||
lines.push(
|
lines.push(
|
||||||
" No CLAUDE instruction files discovered in the current directory ancestry."
|
" No CLAUDE instruction files discovered in the current directory ancestry."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
} else {
|
} 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 = file.content.lines().next().unwrap_or("").trim();
|
||||||
let preview = if preview.is_empty() {
|
let preview = if preview.is_empty() {
|
||||||
"<empty>"
|
"<empty>"
|
||||||
} else {
|
} else {
|
||||||
preview
|
preview
|
||||||
};
|
};
|
||||||
|
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
" {} ({}) {}",
|
" lines={} preview={}",
|
||||||
file.path.display(),
|
|
||||||
file.content.lines().count(),
|
file.content.lines().count(),
|
||||||
preview
|
preview
|
||||||
));
|
));
|
||||||
@@ -816,15 +984,12 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
|
|||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let claude_md = cwd.join("CLAUDE.md");
|
let claude_md = cwd.join("CLAUDE.md");
|
||||||
if claude_md.exists() {
|
if claude_md.exists() {
|
||||||
return Ok(format!(
|
return Ok(format_init_report(&claude_md, false));
|
||||||
"init: skipped because {} already exists",
|
|
||||||
claude_md.display()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = render_init_claude_md(&cwd);
|
let content = render_init_claude_md(&cwd);
|
||||||
fs::write(&claude_md, content)?;
|
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 {
|
fn render_init_claude_md(cwd: &Path) -> String {
|
||||||
@@ -1234,10 +1399,12 @@ fn print_help() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_model_report, format_model_switch_report, format_status_report,
|
format_compact_report, format_cost_report, format_init_report, format_model_report,
|
||||||
normalize_permission_mode, parse_args, render_init_claude_md, render_repl_help,
|
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||||
resume_supported_slash_commands, status_context, CliAction, SlashCommand, StatusUsage,
|
format_resume_report, format_status_report, normalize_permission_mode, parse_args,
|
||||||
DEFAULT_MODEL,
|
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 runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
use std::path::{Path, PathBuf};
|
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]
|
#[test]
|
||||||
fn repl_help_includes_shared_commands_and_exit() {
|
fn repl_help_includes_shared_commands_and_exit() {
|
||||||
let help = render_repl_help();
|
let help = render_repl_help();
|
||||||
|
assert!(help.contains("REPL"));
|
||||||
assert!(help.contains("/help"));
|
assert!(help.contains("/help"));
|
||||||
assert!(help.contains("/status"));
|
assert!(help.contains("/status"));
|
||||||
assert!(help.contains("/model [model]"));
|
assert!(help.contains("/model [model]"));
|
||||||
@@ -1334,7 +1509,7 @@ mod tests {
|
|||||||
assert!(help.contains("/clear [--confirm]"));
|
assert!(help.contains("/clear [--confirm]"));
|
||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-path>"));
|
||||||
assert!(help.contains("/config"));
|
assert!(help.contains("/config [env|hooks|model]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert!(help.contains("/exit"));
|
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]
|
#[test]
|
||||||
fn model_report_uses_sectioned_layout() {
|
fn model_report_uses_sectioned_layout() {
|
||||||
let report = format_model_report("claude-sonnet", 12, 4);
|
let report = format_model_report("claude-sonnet", 12, 4);
|
||||||
@@ -1398,6 +1634,8 @@ mod tests {
|
|||||||
loaded_config_files: 2,
|
loaded_config_files: 2,
|
||||||
discovered_config_files: 3,
|
discovered_config_files: 3,
|
||||||
memory_file_count: 4,
|
memory_file_count: 4,
|
||||||
|
project_root: Some(PathBuf::from("/tmp")),
|
||||||
|
git_branch: Some("main".to_string()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(status.contains("Status"));
|
assert!(status.contains("Status"));
|
||||||
@@ -1407,19 +1645,46 @@ mod tests {
|
|||||||
assert!(status.contains("Latest total 10"));
|
assert!(status.contains("Latest total 10"));
|
||||||
assert!(status.contains("Cumulative total 31"));
|
assert!(status.contains("Cumulative total 31"));
|
||||||
assert!(status.contains("Cwd /tmp/project"));
|
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("Session session.json"));
|
||||||
assert!(status.contains("Config files loaded 2/3"));
|
assert!(status.contains("Config files loaded 2/3"));
|
||||||
assert!(status.contains("Memory files 4"));
|
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]
|
#[test]
|
||||||
fn config_report_uses_sectioned_layout() {
|
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("Config"));
|
||||||
assert!(report.contains("Discovered files"));
|
assert!(report.contains("Discovered files"));
|
||||||
assert!(report.contains("Merged JSON"));
|
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]
|
#[test]
|
||||||
fn status_context_reads_real_workspace_metadata() {
|
fn status_context_reads_real_workspace_metadata() {
|
||||||
let context = status_context(None).expect("status context should load");
|
let context = status_context(None).expect("status context should load");
|
||||||
@@ -1466,7 +1731,16 @@ mod tests {
|
|||||||
SlashCommand::parse("/clear --confirm"),
|
SlashCommand::parse("/clear --confirm"),
|
||||||
Some(SlashCommand::Clear { confirm: true })
|
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("/memory"), Some(SlashCommand::Memory));
|
||||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user