Merge remote-tracking branch 'origin/omx-issue-9103-clawcode-ux-enhance'

# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
Yeachan-Heo
2026-04-02 08:30:05 +00:00

View File

@@ -22,8 +22,7 @@ use api::{
use commands::{ use commands::{
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
suggest_slash_commands, SlashCommand,
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo; use init::initialize_repo;
@@ -56,16 +55,38 @@ const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
const LEGACY_SESSION_EXTENSION: &str = "json"; const LEGACY_SESSION_EXTENSION: &str = "json";
const LATEST_SESSION_REFERENCE: &str = "latest";
const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
const CLI_OPTION_SUGGESTIONS: &[&str] = &[
"--help",
"-h",
"--version",
"-V",
"--model",
"--output-format",
"--permission-mode",
"--dangerously-skip-permissions",
"--allowedTools",
"--allowed-tools",
"--resume",
"--print",
"-p",
];
type AllowedToolSet = BTreeSet<String>; type AllowedToolSet = BTreeSet<String>;
fn main() { fn main() {
if let Err(error) = run() { if let Err(error) = run() {
eprintln!( let message = error.to_string();
"error: {error} if message.contains("`claw --help`") {
eprintln!("error: {message}");
} else {
eprintln!(
"error: {message}
Run `claw --help` for usage." Run `claw --help` for usage."
); );
}
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -165,6 +186,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string(); let mut model = DEFAULT_MODEL.to_string();
let mut output_format = CliOutputFormat::Text; let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode(); let mut permission_mode = default_permission_mode();
let mut wants_help = false;
let mut wants_version = false; let mut wants_version = false;
let mut allowed_tool_values = Vec::new(); let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new(); let mut rest = Vec::new();
@@ -172,6 +194,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
while index < args.len() { while index < args.len() {
match args[index].as_str() { match args[index].as_str() {
"--help" | "-h" if rest.is_empty() => {
wants_help = true;
index += 1;
}
"--version" | "-V" => { "--version" | "-V" => {
wants_version = true; wants_version = true;
index += 1; index += 1;
@@ -232,6 +258,15 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format = CliOutputFormat::Text; output_format = CliOutputFormat::Text;
index += 1; index += 1;
} }
"--resume" if rest.is_empty() => {
rest.push("--resume".to_string());
index += 1;
}
flag if rest.is_empty() && flag.starts_with("--resume=") => {
rest.push("--resume".to_string());
rest.push(flag[9..].to_string());
index += 1;
}
"--allowedTools" | "--allowed-tools" => { "--allowedTools" | "--allowed-tools" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
@@ -247,6 +282,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
allowed_tool_values.push(flag[16..].to_string()); allowed_tool_values.push(flag[16..].to_string());
index += 1; index += 1;
} }
other if rest.is_empty() && other.starts_with('-') => {
return Err(format_unknown_option(other))
}
other => { other => {
rest.push(other.to_string()); rest.push(other.to_string());
index += 1; index += 1;
@@ -254,6 +292,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
} }
} }
if wants_help {
return Ok(CliAction::Help);
}
if wants_version { if wants_version {
return Ok(CliAction::Version); return Ok(CliAction::Version);
} }
@@ -267,9 +309,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode, permission_mode,
}); });
} }
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
return Ok(CliAction::Help);
}
if rest.first().map(String::as_str) == Some("--resume") { if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]); return parse_resume_args(&rest[1..]);
} }
@@ -323,17 +362,128 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
Some(SlashCommand::Help) => Ok(CliAction::Help), Some(SlashCommand::Help) => Ok(CliAction::Help),
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
Some(command) => Err(match command { Some(SlashCommand::Unknown(name)) => Err(format_unknown_direct_slash_command(&name)),
SlashCommand::Unknown(name) => format_unknown_slash_command_message(&name), Some(command) => Err({
_ => format!( let _ = command;
"slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` when the command is marked [resume] in /help.", format!(
command_name = rest[0] "slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
), command_name = rest[0],
latest = LATEST_SESSION_REFERENCE,
)
}), }),
None => Err(format!("unknown subcommand: {}", rest[0])), None => Err(format!("unknown subcommand: {}", rest[0])),
} }
} }
fn format_unknown_option(option: &str) -> String {
let mut message = format!("unknown option: {option}");
if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) {
message.push_str("\nDid you mean ");
message.push_str(suggestion);
message.push('?');
}
message.push_str("\nRun `claw --help` for usage.");
message
}
fn format_unknown_direct_slash_command(name: &str) -> String {
let mut message = format!("unknown slash command outside the REPL: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
message.push_str(&suggestions);
}
message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help.");
message
}
fn format_unknown_slash_command(name: &str) -> String {
let mut message = format!("Unknown slash command: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
message.push_str(&suggestions);
}
message.push_str("\n Help /help lists available slash commands");
message
}
fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option<String> {
(!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),))
}
fn suggest_slash_commands(input: &str) -> Vec<String> {
let mut candidates = slash_command_specs()
.iter()
.flat_map(|spec| {
std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
let candidate_refs = candidates.iter().map(String::as_str).collect::<Vec<_>>();
ranked_suggestions(input.trim_start_matches('/'), &candidate_refs)
.into_iter()
.map(str::to_string)
.collect()
}
fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
ranked_suggestions(input, candidates).into_iter().next()
}
fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
let mut ranked = candidates
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_bonus = usize::from(
!(normalized_candidate.starts_with(&normalized_input)
|| normalized_input.starts_with(&normalized_candidate)),
);
let score = distance + prefix_bonus;
(score <= 4).then_some((score, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
fn levenshtein_distance(left: &str, right: &str) -> usize {
if left.is_empty() {
return right.chars().count();
}
if right.is_empty() {
return left.chars().count();
}
let right_chars = right.chars().collect::<Vec<_>>();
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let substitution_cost = usize::from(left_char != *right_char);
current[right_index + 1] = (previous[right_index + 1] + 1)
.min(current[right_index] + 1)
.min(previous[right_index] + substitution_cost);
}
previous.clone_from(&current);
}
previous[right_chars.len()]
}
fn resolve_model_alias(model: &str) -> &str { fn resolve_model_alias(model: &str) -> &str {
match model { match model {
"opus" => "claude-opus-4-6", "opus" => "claude-opus-4-6",
@@ -421,11 +571,13 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
} }
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> { fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
let session_path = args let (session_path, commands) = match args.first() {
.first() None => (PathBuf::from(LATEST_SESSION_REFERENCE), Vec::new()),
.ok_or_else(|| "missing session path for --resume".to_string()) Some(first) if first.trim_start().starts_with('/') => {
.map(PathBuf::from)?; (PathBuf::from(LATEST_SESSION_REFERENCE), args.to_vec())
let commands = args[1..].to_vec(); }
Some(first) => (PathBuf::from(first), args[1..].to_vec()),
};
if commands if commands
.iter() .iter()
.any(|command| !command.trim_start().starts_with('/')) .any(|command| !command.trim_start().starts_with('/'))
@@ -725,7 +877,7 @@ impl GitWorkspaceSummary {
} }
fn format_unknown_slash_command_message(name: &str) -> String { fn format_unknown_slash_command_message(name: &str) -> String {
let suggestions = suggest_slash_commands(name, 3); let suggestions = suggest_slash_commands(name);
if suggestions.is_empty() { if suggestions.is_empty() {
format!("unknown slash command: /{name}. Use /help to list available commands.") format!("unknown slash command: /{name}. Use /help to list available commands.")
} else { } else {
@@ -837,6 +989,15 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
) )
} }
fn render_resume_usage() -> String {
format!(
"Resume
Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
Tip use /session list to inspect saved sessions"
)
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped { if skipped {
format!( format!(
@@ -1106,6 +1267,7 @@ fn run_resume_command(
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
}) })
} }
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
SlashCommand::Bughunter { .. } SlashCommand::Bughunter { .. }
| SlashCommand::Commit | SlashCommand::Commit
| SlashCommand::Pr { .. } | SlashCommand::Pr { .. }
@@ -1117,8 +1279,7 @@ fn run_resume_command(
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Plugins { .. } | SlashCommand::Plugins { .. } => Err("unsupported resumed slash command".into()),
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
} }
} }
@@ -1174,7 +1335,7 @@ struct SessionHandle {
struct ManagedSessionSummary { struct ManagedSessionSummary {
id: String, id: String,
path: PathBuf, path: PathBuf,
modified_epoch_secs: u64, modified_epoch_millis: u128,
message_count: usize, message_count: usize,
parent_session_id: Option<String>, parent_session_id: Option<String>,
branch_name: Option<String>, branch_name: Option<String>,
@@ -1291,6 +1452,10 @@ impl LiveCli {
|| "unknown".to_string(), || "unknown".to_string(),
|context| context.git_summary.headline(), |context| context.git_summary.headline(),
); );
let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else(
|_| self.session.path.display().to_string(),
|path| path.display().to_string(),
);
format!( format!(
"\x1b[38;5;196m\ "\x1b[38;5;196m\
██████╗██╗ █████╗ ██╗ ██╗\n\ ██████╗██╗ █████╗ ██╗ ██╗\n\
@@ -1304,14 +1469,16 @@ impl LiveCli {
\x1b[2mBranch\x1b[0m {}\n\ \x1b[2mBranch\x1b[0m {}\n\
\x1b[2mWorkspace\x1b[0m {}\n\ \x1b[2mWorkspace\x1b[0m {}\n\
\x1b[2mDirectory\x1b[0m {}\n\ \x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\n\ \x1b[2mSession\x1b[0m {}\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", \x1b[2mAuto-save\x1b[0m {}\n\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
self.model, self.model,
self.permission_mode.as_str(), self.permission_mode.as_str(),
git_branch, git_branch,
workspace, workspace,
cwd, cwd,
self.session.id, self.session.id,
session_path,
) )
} }
@@ -1534,7 +1701,7 @@ impl LiveCli {
false false
} }
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("{}", format_unknown_slash_command_message(&name)); eprintln!("{}", format_unknown_slash_command(&name));
false false
} }
}) })
@@ -1710,7 +1877,7 @@ impl LiveCli {
session_path: Option<String>, session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>"); println!("{}", render_resume_usage());
return Ok(false); return Ok(false);
}; };
@@ -2126,12 +2293,23 @@ fn create_managed_session_handle(
} }
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
if SESSION_REFERENCE_ALIASES
.iter()
.any(|alias| reference.eq_ignore_ascii_case(alias))
{
let latest = latest_managed_session()?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
});
}
let direct = PathBuf::from(reference); let direct = PathBuf::from(reference);
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1; let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
let path = if direct.exists() { let path = if direct.exists() {
direct direct
} else if looks_like_path { } else if looks_like_path {
return Err(format!("session not found: {reference}").into()); return Err(format_missing_session_reference(reference).into());
} else { } else {
resolve_managed_session_path(reference)? resolve_managed_session_path(reference)?
}; };
@@ -2155,7 +2333,7 @@ fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std
return Ok(path); return Ok(path);
} }
} }
Err(format!("session not found: {session_id}").into()) Err(format_missing_session_reference(session_id).into())
} }
fn is_managed_session_file(path: &Path) -> bool { fn is_managed_session_file(path: &Path) -> bool {
@@ -2175,11 +2353,11 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
continue; continue;
} }
let metadata = entry.metadata()?; let metadata = entry.metadata()?;
let modified_epoch_secs = metadata let modified_epoch_millis = metadata
.modified() .modified()
.ok() .ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs()) .map(|duration| duration.as_millis())
.unwrap_or_default(); .unwrap_or_default();
let (id, message_count, parent_session_id, branch_name) = let (id, message_count, parent_session_id, branch_name) =
match Session::load_from_path(&path) { match Session::load_from_path(&path) {
@@ -2212,16 +2390,40 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
sessions.push(ManagedSessionSummary { sessions.push(ManagedSessionSummary {
id, id,
path, path,
modified_epoch_secs, modified_epoch_millis,
message_count, message_count,
parent_session_id, parent_session_id,
branch_name, branch_name,
}); });
} }
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); sessions.sort_by(|left, right| {
right
.modified_epoch_millis
.cmp(&left.modified_epoch_millis)
.then_with(|| right.id.cmp(&left.id))
});
Ok(sessions) Ok(sessions)
} }
fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
list_managed_sessions()?
.into_iter()
.next()
.ok_or_else(|| format_no_managed_sessions().into())
}
fn format_missing_session_reference(reference: &str) -> String {
format!(
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
)
}
fn format_no_managed_sessions() -> String {
format!(
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
)
}
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> { fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions()?; let sessions = list_managed_sessions()?;
let mut lines = vec![ let mut lines = vec![
@@ -2253,7 +2455,7 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}", " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
id = session.id, id = session.id,
msgs = session.message_count, msgs = session.message_count,
modified = session.modified_epoch_secs, modified = format_session_modified_age(session.modified_epoch_millis),
lineage = lineage, lineage = lineage,
path = session.path.display(), path = session.path.display(),
)); ));
@@ -2261,6 +2463,24 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
fn format_session_modified_age(modified_epoch_millis: u128) -> String {
let now = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(modified_epoch_millis, |duration| duration.as_millis());
let delta_seconds = now
.saturating_sub(modified_epoch_millis)
.checked_div(1_000)
.unwrap_or_default();
match delta_seconds {
0..=4 => "just-now".to_string(),
5..=59 => format!("{delta_seconds}s-ago"),
60..=3_599 => format!("{}m-ago", delta_seconds / 60),
3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600),
_ => format!("{}d-ago", delta_seconds / 86_400),
}
}
fn render_repl_help() -> String { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "REPL".to_string(),
@@ -2270,6 +2490,9 @@ fn render_repl_help() -> String {
" Tab Complete commands, modes, and recent sessions".to_string(), " Tab Complete commands, modes, and recent sessions".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(), " Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(), " Shift+Enter/Ctrl+J Insert a newline".to_string(),
" Auto-save .claw/sessions/<session-id>.jsonl".to_string(),
" Resume latest /resume latest".to_string(),
" Browse sessions /session list".to_string(),
String::new(), String::new(),
render_slash_command_help(), render_slash_command_help(),
] ]
@@ -4411,6 +4634,7 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
.collect() .collect()
} }
#[allow(clippy::too_many_lines)]
fn print_help_to(out: &mut impl Write) -> io::Result<()> { fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, "claw v{VERSION}")?; writeln!(out, "claw v{VERSION}")?;
writeln!(out)?; writeln!(out)?;
@@ -4432,7 +4656,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, " Shorthand non-interactive prompt mode")?; writeln!(out, " Shorthand non-interactive prompt mode")?;
writeln!( writeln!(
out, out,
" claw --resume SESSION.jsonl [/status] [/compact] [...]" " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]"
)?; )?;
writeln!( writeln!(
out, out,
@@ -4482,6 +4706,20 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?; writeln!(out, "Resume-safe commands: {resume_commands}")?;
writeln!(out)?;
writeln!(out, "Session shortcuts:")?;
writeln!(
out,
" REPL turns auto-save to .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}"
)?;
writeln!(
out,
" Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session"
)?;
writeln!(
out,
" Use /session list in the REPL to browse managed sessions"
)?;
writeln!(out, "Examples:")?; writeln!(out, "Examples:")?;
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
writeln!( writeln!(
@@ -4492,9 +4730,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
out, out,
" claw --allowedTools read,glob \"summarize Cargo.toml\"" " claw --allowedTools read,glob \"summarize Cargo.toml\""
)?; )?;
writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?;
writeln!( writeln!(
out, out,
" claw --resume session.jsonl /status /diff /export notes.txt" " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
)?; )?;
writeln!(out, " claw agents")?; writeln!(out, " claw agents")?;
writeln!(out, " claw /skills")?; writeln!(out, " claw /skills")?;
@@ -4515,12 +4754,14 @@ mod tests {
format_compact_report, format_cost_report, format_internal_prompt_progress_line, format_compact_report, format_cost_report, format_internal_prompt_progress_line,
format_model_report, format_model_switch_report, format_permissions_report, format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report, format_permissions_switch_report, format_resume_report, format_status_report,
format_tool_call_start, format_tool_result, format_unknown_slash_command_message, format_tool_call_start, format_tool_result, format_unknown_slash_command,
format_unknown_slash_command_message,
normalize_permission_mode, parse_args, parse_git_status_branch, normalize_permission_mode, parse_args, parse_git_status_branch,
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy, parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
print_help_to, push_output_block, render_config_report, render_diff_report, print_help_to, push_output_block, render_config_report, render_diff_report,
render_memory_report, render_repl_help, resolve_model_alias, resolve_session_reference, render_memory_report, render_repl_help, resolve_model_alias, resolve_session_reference,
response_to_events, resume_supported_slash_commands, run_resume_command, render_resume_usage, response_to_events, resume_supported_slash_commands,
run_resume_command,
slash_command_completion_candidates_with_sessions, status_context, CliAction, slash_command_completion_candidates_with_sessions, status_context, CliAction,
CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent, CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent,
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL, InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
@@ -4827,6 +5068,25 @@ mod tests {
); );
} }
#[test]
fn parses_resume_flag_without_path_as_latest_session() {
assert_eq!(
parse_args(&["--resume".to_string()]).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("latest"),
commands: vec![],
}
);
assert_eq!(
parse_args(&["--resume".to_string(), "/status".to_string()])
.expect("resume shortcut should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("latest"),
commands: vec!["/status".to_string()],
}
);
}
#[test] #[test]
fn parses_resume_flag_with_multiple_slash_commands() { fn parses_resume_flag_with_multiple_slash_commands() {
let args = vec![ let args = vec![
@@ -4849,6 +5109,14 @@ mod tests {
); );
} }
#[test]
fn rejects_unknown_options_with_helpful_guidance() {
let error = parse_args(&["--resum".to_string()]).expect_err("unknown option should fail");
assert!(error.contains("unknown option: --resum"));
assert!(error.contains("Did you mean --resume?"));
assert!(error.contains("claw --help"));
}
#[test] #[test]
fn filtered_tool_specs_respect_allowlist() { fn filtered_tool_specs_respect_allowlist() {
let allowed = ["read_file", "grep_search"] let allowed = ["read_file", "grep_search"]
@@ -4920,6 +5188,8 @@ mod tests {
assert!(help.contains("/agents")); assert!(help.contains("/agents"));
assert!(help.contains("/skills")); assert!(help.contains("/skills"));
assert!(help.contains("/exit")); assert!(help.contains("/exit"));
assert!(help.contains("Auto-save .claw/sessions/<session-id>.jsonl"));
assert!(help.contains("Resume latest /resume latest"));
} }
#[test] #[test]
@@ -5416,8 +5686,10 @@ UU conflicted.rs",
let mut help = Vec::new(); let mut help = Vec::new();
print_help_to(&mut help).expect("help should render"); print_help_to(&mut help).expect("help should render");
let help = String::from_utf8(help).expect("help should be utf8"); let help = String::from_utf8(help).expect("help should be utf8");
assert!(help.contains("claw --resume SESSION.jsonl")); assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]"));
assert!(help.contains("claw --resume session.jsonl /status /diff /export notes.txt")); assert!(help.contains("Use `latest` with --resume, /resume, or /session switch"));
assert!(help.contains("claw --resume latest"));
assert!(help.contains("claw --resume latest /status /diff /export notes.txt"));
} }
#[test] #[test]
@@ -5458,6 +5730,55 @@ UU conflicted.rs",
std::fs::remove_dir_all(workspace).expect("workspace should clean up"); std::fs::remove_dir_all(workspace).expect("workspace should clean up");
} }
#[test]
fn latest_session_alias_resolves_most_recent_managed_session() {
let _guard = cwd_lock().lock().expect("cwd lock");
let workspace = temp_workspace("latest-session-alias");
std::fs::create_dir_all(&workspace).expect("workspace should create");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&workspace).expect("switch cwd");
let older = create_managed_session_handle("session-older").expect("older handle");
Session::new()
.with_persistence_path(older.path.clone())
.save_to_path(&older.path)
.expect("older session should save");
std::thread::sleep(Duration::from_millis(20));
let newer = create_managed_session_handle("session-newer").expect("newer handle");
Session::new()
.with_persistence_path(newer.path.clone())
.save_to_path(&newer.path)
.expect("newer session should save");
let resolved = resolve_session_reference("latest").expect("latest session should resolve");
assert_eq!(
resolved
.path
.canonicalize()
.expect("resolved path should exist"),
newer.path.canonicalize().expect("newer path should exist")
);
std::env::set_current_dir(previous).expect("restore cwd");
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
}
#[test]
fn unknown_slash_command_guidance_suggests_nearby_commands() {
let message = format_unknown_slash_command("stats");
assert!(message.contains("Unknown slash command: /stats"));
assert!(message.contains("/status"));
assert!(message.contains("/help"));
}
#[test]
fn resume_usage_mentions_latest_shortcut() {
let usage = render_resume_usage();
assert!(usage.contains("/resume <session-path|session-id|latest>"));
assert!(usage.contains(".claw/sessions/<session-id>.jsonl"));
assert!(usage.contains("/session list"));
}
fn cwd_lock() -> &'static Mutex<()> { fn cwd_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())) LOCK.get_or_init(|| Mutex::new(()))