mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 19:21:51 +08:00
feat(cli): extend resume commands and add memory inspection
Improve resumed-session parity by letting top-level --resume execute shared read-only commands such as /help, /status, /cost, /config, and /memory in addition to /compact. This makes saved sessions meaningfully inspectable without reopening the interactive REPL. Also add a genuinely useful /memory command that reports the Claude instruction memory already discovered by the runtime from CLAUDE.md-style files in the current directory ancestry. The command stays honest by surfacing file paths, line counts, and a short preview instead of inventing unsupported persistent memory behavior. Constraint: Resume-path improvements must operate safely on saved sessions without requiring a live model runtime Constraint: /memory must expose real repository instruction context rather than placeholder state Rejected: Invent editable or persistent chat memory storage | no such durable feature exists in this repo yet Confidence: high Scope-risk: moderate Reversibility: clean Directive: Reuse shared slash parsing for resume-path features so saved-session commands and REPL commands stay aligned Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual resume against a diverse set of historical session files from real user workflows
This commit is contained in:
@@ -83,6 +83,11 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
summary: "Inspect discovered Claude config files",
|
summary: "Inspect discovered Claude config files",
|
||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "memory",
|
||||||
|
summary: "Inspect loaded Claude instruction memory files",
|
||||||
|
argument_hint: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -96,6 +101,7 @@ pub enum SlashCommand {
|
|||||||
Cost,
|
Cost,
|
||||||
Resume { session_path: Option<String> },
|
Resume { session_path: Option<String> },
|
||||||
Config,
|
Config,
|
||||||
|
Memory,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +131,7 @@ impl SlashCommand {
|
|||||||
session_path: parts.next().map(ToOwned::to_owned),
|
session_path: parts.next().map(ToOwned::to_owned),
|
||||||
},
|
},
|
||||||
"config" => Self::Config,
|
"config" => Self::Config,
|
||||||
|
"memory" => Self::Memory,
|
||||||
other => Self::Unknown(other.to_string()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -187,6 +194,7 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Cost
|
| SlashCommand::Cost
|
||||||
| SlashCommand::Resume { .. }
|
| SlashCommand::Resume { .. }
|
||||||
| SlashCommand::Config
|
| SlashCommand::Config
|
||||||
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,6 +235,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
||||||
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -241,7 +250,8 @@ mod tests {
|
|||||||
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"));
|
||||||
assert_eq!(slash_command_specs().len(), 9);
|
assert!(help.contains("/memory"));
|
||||||
|
assert_eq!(slash_command_specs().len(), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ use render::{Spinner, TerminalRenderer};
|
|||||||
use runtime::{
|
use runtime::{
|
||||||
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
|
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
|
||||||
ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
|
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};
|
use tools::{execute_tool, mvp_tool_specs};
|
||||||
|
|
||||||
@@ -205,27 +206,20 @@ fn resume_session(session_path: &Path, command: Option<String>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match command {
|
match command.as_deref().and_then(SlashCommand::parse) {
|
||||||
Some(command) if command.starts_with('/') => {
|
Some(command) => match run_resume_command(session_path, &session, &command) {
|
||||||
let Some(result) = handle_slash_command(
|
Ok(Some(message)) => println!("{message}"),
|
||||||
&command,
|
Ok(None) => {}
|
||||||
&session,
|
Err(error) => {
|
||||||
CompactionConfig {
|
eprintln!("{error}");
|
||||||
max_estimated_tokens: 0,
|
|
||||||
..CompactionConfig::default()
|
|
||||||
},
|
|
||||||
) else {
|
|
||||||
eprintln!("unknown slash command: {command}");
|
|
||||||
std::process::exit(2);
|
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);
|
},
|
||||||
}
|
None if command.is_some() => {
|
||||||
Some(other) => {
|
eprintln!(
|
||||||
eprintln!("unsupported resumed command: {other}");
|
"unsupported resumed command: {}",
|
||||||
|
command.unwrap_or_default()
|
||||||
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -238,6 +232,60 @@ fn resume_session(session_path: &Path, command: Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_resume_command(
|
||||||
|
session_path: &Path,
|
||||||
|
session: &Session,
|
||||||
|
command: &SlashCommand,
|
||||||
|
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cli = LiveCli::new(model, true)?;
|
let mut cli = LiveCli::new(model, true)?;
|
||||||
let editor = input::LineEditor::new("› ");
|
let editor = input::LineEditor::new("› ");
|
||||||
@@ -328,6 +376,7 @@ impl LiveCli {
|
|||||||
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 => Self::print_config()?,
|
||||||
|
SlashCommand::Memory => Self::print_memory()?,
|
||||||
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -444,34 +493,12 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_config() -> Result<(), Box<dyn std::error::Error>> {
|
fn print_config() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
println!("{}", render_config_report()?);
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
Ok(())
|
||||||
let discovered = loader.discover();
|
|
||||||
let runtime_config = loader.load()?;
|
|
||||||
|
|
||||||
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<dyn std::error::Error>> {
|
||||||
|
println!("{}", render_memory_report()?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +543,77 @@ fn format_status_line(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
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<String, Box<dyn std::error::Error>> {
|
||||||
|
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() {
|
||||||
|
"<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> {
|
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
||||||
match mode.trim() {
|
match mode.trim() {
|
||||||
"read-only" => Some("read-only"),
|
"read-only" => Some("read-only"),
|
||||||
@@ -930,6 +1028,7 @@ mod tests {
|
|||||||
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"));
|
||||||
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/exit"));
|
assert!(help.contains("/exit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,6 +1083,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
||||||
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user