mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
feat(cli): add resume and config inspection commands
Add in-REPL session restoration and read-only config inspection so the CLI can recover saved conversations and expose Claude settings without leaving interactive mode. /resume now reloads a session file into the live runtime, and /config shows discovered settings files plus the merged effective JSON. The new commands stay on the shared slash-command surface and rebuild runtime state using the current model, system prompt, and permission mode so existing REPL behavior remains stable. Constraint: /resume must update the live REPL session rather than only supporting top-level --resume Constraint: /config should inspect existing settings without mutating user files Rejected: Add editable /config writes in this slice | read-only inspection is safer and sufficient for immediate parity work Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep resume/config behavior on the shared slash command surface so non-REPL entrypoints can reuse it later Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual interactive restore against real saved session files outside automated fixtures
This commit is contained in:
@@ -73,6 +73,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
summary: "Show cumulative token usage for this session",
|
summary: "Show cumulative token usage for this session",
|
||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "resume",
|
||||||
|
summary: "Load a saved session into the REPL",
|
||||||
|
argument_hint: Some("<session-path>"),
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "config",
|
||||||
|
summary: "Inspect discovered Claude config files",
|
||||||
|
argument_hint: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -84,6 +94,8 @@ pub enum SlashCommand {
|
|||||||
Permissions { mode: Option<String> },
|
Permissions { mode: Option<String> },
|
||||||
Clear,
|
Clear,
|
||||||
Cost,
|
Cost,
|
||||||
|
Resume { session_path: Option<String> },
|
||||||
|
Config,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +121,10 @@ impl SlashCommand {
|
|||||||
},
|
},
|
||||||
"clear" => Self::Clear,
|
"clear" => Self::Clear,
|
||||||
"cost" => Self::Cost,
|
"cost" => Self::Cost,
|
||||||
|
"resume" => Self::Resume {
|
||||||
|
session_path: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
"config" => Self::Config,
|
||||||
other => Self::Unknown(other.to_string()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -169,6 +185,8 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Clear
|
| SlashCommand::Clear
|
||||||
| SlashCommand::Cost
|
| SlashCommand::Cost
|
||||||
|
| SlashCommand::Resume { .. }
|
||||||
|
| SlashCommand::Config
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +220,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear));
|
assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear));
|
||||||
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -214,7 +239,9 @@ mod tests {
|
|||||||
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||||
assert!(help.contains("/clear"));
|
assert!(help.contains("/clear"));
|
||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert_eq!(slash_command_specs().len(), 7);
|
assert!(help.contains("/resume <session-path>"));
|
||||||
|
assert!(help.contains("/config"));
|
||||||
|
assert_eq!(slash_command_specs().len(), 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -272,5 +299,12 @@ mod tests {
|
|||||||
.is_none());
|
.is_none());
|
||||||
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/cost", &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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ use commands::{handle_slash_command, render_slash_command_help, 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::{
|
||||||
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ContentBlock,
|
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
|
||||||
ConversationMessage, ConversationRuntime, MessageRole, PermissionMode, PermissionPolicy,
|
ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
|
||||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use tools::{execute_tool, mvp_tool_specs};
|
use tools::{execute_tool, mvp_tool_specs};
|
||||||
|
|
||||||
@@ -326,6 +326,8 @@ impl LiveCli {
|
|||||||
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
|
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
|
||||||
SlashCommand::Clear => self.clear_session()?,
|
SlashCommand::Clear => self.clear_session()?,
|
||||||
SlashCommand::Cost => self.print_cost(),
|
SlashCommand::Cost => self.print_cost(),
|
||||||
|
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
|
||||||
|
SlashCommand::Config => Self::print_config()?,
|
||||||
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -419,6 +421,60 @@ impl LiveCli {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resume_session(
|
||||||
|
&mut self,
|
||||||
|
session_path: Option<String>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let Some(session_path) = session_path else {
|
||||||
|
println!("Usage: /resume <session-path>");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = Session::load_from_path(&session_path)?;
|
||||||
|
let message_count = session.messages.len();
|
||||||
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
|
session,
|
||||||
|
self.model.clone(),
|
||||||
|
self.system_prompt.clone(),
|
||||||
|
true,
|
||||||
|
permission_mode_label(),
|
||||||
|
)?;
|
||||||
|
println!("Resumed session from {session_path} ({message_count} messages).");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_config() -> Result<(), 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()?;
|
||||||
|
|
||||||
|
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());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -798,7 +854,7 @@ fn print_help() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction,
|
format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction,
|
||||||
DEFAULT_MODEL,
|
SlashCommand, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -872,6 +928,8 @@ mod tests {
|
|||||||
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||||
assert!(help.contains("/clear"));
|
assert!(help.contains("/clear"));
|
||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
|
assert!(help.contains("/resume <session-path>"));
|
||||||
|
assert!(help.contains("/config"));
|
||||||
assert!(help.contains("/exit"));
|
assert!(help.contains("/exit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +975,17 @@ mod tests {
|
|||||||
assert_eq!(normalize_permission_mode("unknown"), None);
|
assert_eq!(normalize_permission_mode("unknown"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_resume_and_config_slash_commands() {
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/resume saved-session.json"),
|
||||||
|
Some(SlashCommand::Resume {
|
||||||
|
session_path: Some("saved-session.json".to_string())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn converts_tool_roundtrip_messages() {
|
fn converts_tool_roundtrip_messages() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
|
|||||||
Reference in New Issue
Block a user