Compare commits

..

2 Commits

Author SHA1 Message Date
Yeachan-Heo
83bbf5c7cb Present Claw Code as the current Rust product
The release-prep docs still framed the workspace as a Rust variant,
which understated the owner's current product position. This update
rewrites the README title and positioning so Claw Code is presented
as the current product surface, while keeping the legal framing clear:
Claude Code inspired, implemented clean-room in Rust, and not a direct
port or copy. The draft 0.1.0 release notes now mirror that language.

Constraint: Docs must reflect the current owner positioning without introducing unsupported product claims
Constraint: Legal framing must stay explicit that this is a clean-room Rust implementation, not a direct port or copy
Rejected: Leave release notes unchanged | would keep product-positioning language inconsistent across release-facing docs
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future release-facing docs aligned on product naming and clean-room positioning before tagging releases
Tested: Reviewed README and docs/releases/0.1.0.md after edits; verified only intended docs files were staged
Not-tested: cargo check and cargo test (docs-only pass; no code changes)
2026-04-01 16:46:27 +00:00
Yeachan-Heo
85f0e892c5 Clarify the Rust 0.1.0 public release surface
The workspace already reports version 0.1.0 and exposes a broad CLI,
but the top-level README was outdated on installation, capabilities,
and current release status. This pass rewrites the README around
verified source-build flows and adds a draft 0.1.0 release-notes file
so the branch is ready for a public-release prep review.

Constraint: Release-prep pass must stay docs-only and avoid runtime behavior changes
Constraint: Public docs should describe only verified commands, paths, and current distribution status
Rejected: Add packaging automation in this pass | outside the requested release-facing docs scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep README and release notes aligned with cargo metadata, CLI help output, and CI coverage before tagging future releases
Tested: Verified version/package metadata with cargo metadata; verified CLI help and command paths with cargo run --quiet --bin claw -- --help; verified CI coverage from .github/workflows/ci.yml
Not-tested: cargo check and cargo test (docs-only pass; no code changes)
2026-04-01 16:15:31 +00:00
3 changed files with 188 additions and 724 deletions

View File

@@ -244,14 +244,6 @@ pub struct LineEditor {
history: Vec<String>,
yank_buffer: YankBuffer,
vim_enabled: bool,
completion_state: Option<CompletionState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompletionState {
prefix: String,
matches: Vec<String>,
next_index: usize,
}
impl LineEditor {
@@ -263,7 +255,6 @@ impl LineEditor {
history: Vec::new(),
yank_buffer: YankBuffer::default(),
vim_enabled: false,
completion_state: None,
}
}
@@ -366,10 +357,6 @@ impl LineEditor {
}
fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction {
if key.code != KeyCode::Tab {
self.completion_state = None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') | KeyCode::Char('C') => {
@@ -686,62 +673,22 @@ impl LineEditor {
session.cursor = insert_at + self.yank_buffer.text.len();
}
fn complete_slash_command(&mut self, session: &mut EditSession) {
fn complete_slash_command(&self, session: &mut EditSession) {
if session.mode == EditorMode::Command {
self.completion_state = None;
return;
}
if let Some(state) = self
.completion_state
.as_mut()
.filter(|_| session.cursor == session.text.len())
.filter(|state| {
state
.matches
.iter()
.any(|candidate| candidate == &session.text)
})
{
let candidate = state.matches[state.next_index % state.matches.len()].clone();
state.next_index += 1;
session.text.replace_range(..session.cursor, &candidate);
session.cursor = candidate.len();
return;
}
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
self.completion_state = None;
return;
};
let matches = self
let Some(candidate) = self
.completions
.iter()
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
.cloned()
.collect::<Vec<_>>();
if matches.is_empty() {
self.completion_state = None;
.find(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
else {
return;
}
let candidate = if let Some(state) = self
.completion_state
.as_mut()
.filter(|state| state.prefix == prefix && state.matches == matches)
{
let index = state.next_index % state.matches.len();
state.next_index += 1;
state.matches[index].clone()
} else {
let candidate = matches[0].clone();
self.completion_state = Some(CompletionState {
prefix: prefix.to_string(),
matches,
next_index: 1,
});
candidate
};
session.text.replace_range(..session.cursor, &candidate);
session.text.replace_range(..session.cursor, candidate);
session.cursor = candidate.len();
}
@@ -1139,7 +1086,7 @@ mod tests {
#[test]
fn tab_completes_matching_slash_commands() {
// given
let mut editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
let editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
let mut session = EditSession::new(false);
session.text = "/he".to_string();
session.cursor = session.text.len();
@@ -1152,29 +1099,6 @@ mod tests {
assert_eq!(session.cursor, 5);
}
#[test]
fn tab_cycles_between_matching_slash_commands() {
// given
let mut editor = LineEditor::new(
"> ",
vec!["/permissions".to_string(), "/plugin".to_string()],
);
let mut session = EditSession::new(false);
session.text = "/p".to_string();
session.cursor = session.text.len();
// when
editor.complete_slash_command(&mut session);
let first = session.text.clone();
session.cursor = session.text.len();
editor.complete_slash_command(&mut session);
let second = session.text.clone();
// then
assert_eq!(first, "/permissions");
assert_eq!(second, "/plugin");
}
#[test]
fn ctrl_c_cancels_when_input_exists() {
// given

View File

@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::io::{self, Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -16,15 +16,14 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use api::{
resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
resolve_startup_auth_source, ClawApiClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, SlashCommand,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
@@ -60,25 +59,15 @@ type AllowedToolSet = BTreeSet<String>;
fn main() {
if let Err(error) = run() {
eprintln!("{}", render_cli_error(&error.to_string()));
eprintln!(
"error: {error}
Run `claw --help` for usage."
);
std::process::exit(1);
}
}
fn render_cli_error(problem: &str) -> String {
let mut lines = vec!["Error".to_string()];
for (index, line) in problem.lines().enumerate() {
let label = if index == 0 {
" Problem "
} else {
" "
};
lines.push(format!("{label}{line}"));
}
lines.push(" Help claw --help".to_string());
lines.join("\n")
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
@@ -332,36 +321,17 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
Some(SlashCommand::Help) => Ok(CliAction::Help),
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
Some(command) => Err(format_direct_slash_command_error(
match &command {
Some(command) => Err(format!(
"unsupported direct slash command outside the REPL: {command_name}",
command_name = match command {
SlashCommand::Unknown(name) => format!("/{name}"),
_ => rest[0].clone(),
}
.as_str(),
matches!(command, SlashCommand::Unknown(_)),
)),
None => Err(format!("unknown subcommand: {}", rest[0])),
}
}
fn format_direct_slash_command_error(command: &str, is_unknown: bool) -> String {
let trimmed = command.trim().trim_start_matches('/');
let mut lines = vec![
"Direct slash command unavailable".to_string(),
format!(" Command /{trimmed}"),
];
if is_unknown {
append_slash_command_suggestions(&mut lines, trimmed);
} else {
lines.push(" Try Start `claw` to use interactive slash commands".to_string());
lines.push(
" Tip Resume-safe commands also work with `claw --resume SESSION.json ...`"
.to_string(),
);
}
lines.join("\n")
}
fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "claude-opus-4-6",
@@ -700,17 +670,13 @@ struct StatusUsage {
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current {model}
Session {message_count} messages · {turns} turns
Current model {model}
Session messages {message_count}
Session turns {turns}
Aliases
opus claude-opus-4-6
sonnet claude-sonnet-4-6
haiku claude-haiku-4-5-20251213
Next
/model Show the current model
/model <name> Switch models for this REPL session"
Usage
Inspect current model with /model
Switch models with /model <name>"
)
}
@@ -719,8 +685,7 @@ fn format_model_switch_report(previous: &str, next: &str, message_count: usize)
"Model updated
Previous {previous}
Current {next}
Preserved {message_count} messages
Tip Existing conversation context stayed attached"
Preserved msgs {message_count}"
)
}
@@ -753,34 +718,28 @@ fn format_permissions_report(mode: &str) -> String {
",
);
let effect = match mode {
"read-only" => "Only read/search tools can run automatically",
"workspace-write" => "Editing tools can modify files in the workspace",
"danger-full-access" => "All tools can run without additional sandbox limits",
_ => "Unknown permission mode",
};
format!(
"Permissions
Active mode {mode}
Effect {effect}
Mode status live session default
Modes
{modes}
Next
/permissions Show the current mode
/permissions <mode> Switch modes for subsequent tool calls"
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Result mode switched
Previous mode {previous}
Active mode {next}
Applies to Subsequent tool calls in this REPL
Tip Run /permissions to review all available modes"
Applies to subsequent tool calls
Usage /permissions to inspect current mode"
)
}
@@ -791,11 +750,7 @@ fn format_cost_report(usage: TokenUsage) -> String {
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}
Next
/status See session + workspace context
/compact Trim local history if the session is getting large",
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
@@ -808,8 +763,8 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
format!(
"Session resumed
Session file {session_path}
History {message_count} messages · {turns} turns
Next /status · /diff · /export"
Messages {message_count}
Turns {turns}"
)
}
@@ -818,7 +773,7 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
format!(
"Compact
Result skipped
Reason Session is already below the compaction threshold
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
@@ -826,8 +781,7 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}
Tip Use /status to review the trimmed session"
Messages kept {resulting_messages}"
)
}
}
@@ -1015,22 +969,22 @@ fn run_repl(
loop {
match editor.read_line()? {
input::ReadOutcome::Submit(input) => {
let trimmed = input.trim();
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed, "/exit" | "/quit") {
if matches!(trimmed.as_str(), "/exit" | "/quit") {
cli.persist_session()?;
break;
}
if let Some(command) = SlashCommand::parse(trimmed) {
if let Some(command) = SlashCommand::parse(&trimmed) {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
editor.push_history(&input);
cli.run_turn(&input)?;
editor.push_history(input);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
@@ -1098,65 +1052,28 @@ impl LiveCli {
}
fn startup_banner(&self) -> String {
let color = io::stdout().is_terminal();
let cwd = env::current_dir().ok();
let cwd_display = cwd.as_ref().map_or_else(
|| "<unknown>".to_string(),
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
let workspace_name = cwd
.as_ref()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
.unwrap_or("workspace");
let git_branch = status_context(Some(&self.session.path))
.ok()
.and_then(|context| context.git_branch);
let workspace_summary = git_branch.as_deref().map_or_else(
|| workspace_name.to_string(),
|branch| format!("{workspace_name} · {branch}"),
);
let has_claw_md = cwd
.as_ref()
.is_some_and(|path| path.join("CLAW.md").is_file());
let mut lines = vec![
format!(
"{} {}",
if color {
"\x1b[1;38;5;45m🦞 Claw Code\x1b[0m"
} else {
"Claw Code"
},
if color {
"\x1b[2m· ready\x1b[0m"
} else {
"· ready"
}
),
format!(" Workspace {workspace_summary}"),
format!(" Directory {cwd_display}"),
format!(" Model {}", self.model),
format!(" Permissions {}", self.permission_mode.as_str()),
format!(" Session {}", self.session.id),
format!(
" Quick start {}",
if has_claw_md {
"/help · /status · ask for a task"
} else {
"/init · /help · /status"
}
),
" Editor Tab completes slash commands · /vim toggles modal editing"
.to_string(),
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
];
if !has_claw_md {
lines.push(
" First run /init scaffolds CLAW.md, .claw.json, and local session files"
.to_string(),
);
}
lines.join("\n")
format!(
"\x1b[38;5;196m\
██████╗██╗ █████╗ ██╗ ██╗\n\
██╔════╝██║ ██╔══██╗██║ ██║\n\
██║ ██║ ███████║██║ █╗ ██║\n\
██║ ██║ ██╔══██║██║███╗██║\n\
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
\x1b[2mModel\x1b[0m {}\n\
\x1b[2mPermissions\x1b[0m {}\n\
\x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline",
self.model,
self.permission_mode.as_str(),
cwd,
self.session.id,
)
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -1329,28 +1246,19 @@ impl LiveCli {
false
}
SlashCommand::Branch { .. } => {
eprintln!(
"{}",
render_mode_unavailable("branch", "git branch commands")
);
eprintln!("git branch commands not yet wired to REPL");
false
}
SlashCommand::Worktree { .. } => {
eprintln!(
"{}",
render_mode_unavailable("worktree", "git worktree commands")
);
eprintln!("git worktree commands not yet wired to REPL");
false
}
SlashCommand::CommitPushPr { .. } => {
eprintln!(
"{}",
render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
);
eprintln!("commit-push-pr not yet wired to REPL");
false
}
SlashCommand::Unknown(name) => {
eprintln!("{}", render_unknown_repl_command(&name));
eprintln!("unknown slash command: /{name}");
false
}
})
@@ -1929,20 +1837,6 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
Ok(sessions)
}
fn format_relative_timestamp(epoch_secs: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(epoch_secs);
let elapsed = now.saturating_sub(epoch_secs);
match elapsed {
0..=59 => format!("{elapsed}s ago"),
60..=3_599 => format!("{}m ago", elapsed / 60),
3_600..=86_399 => format!("{}h ago", elapsed / 3_600),
_ => format!("{}d ago", elapsed / 86_400),
}
}
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions()?;
let mut lines = vec![
@@ -1960,28 +1854,26 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
"○ saved"
};
lines.push(format!(
" {id:<20} {marker:<10} {msgs:>3} msgs · updated {modified}",
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
id = session.id,
msgs = session.message_count,
modified = format_relative_timestamp(session.modified_epoch_secs),
modified = session.modified_epoch_secs,
path = session.path.display(),
));
lines.push(format!(" {}", session.path.display()));
}
Ok(lines.join("\n"))
}
fn render_repl_help() -> String {
[
"Interactive REPL".to_string(),
" Quick start Ask a task in plain English or use one of the core commands below."
.to_string(),
" Core commands /help · /status · /model · /permissions · /compact".to_string(),
" Exit /exit or /quit".to_string(),
" Vim mode /vim toggles modal editing".to_string(),
" History Up/Down recalls previous prompts".to_string(),
" Completion Tab cycles slash command matches".to_string(),
" Cancel Ctrl-C clears input (or exits on an empty prompt)".to_string(),
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
" /vim Toggle Vim keybindings".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Tab Complete slash commands".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
String::new(),
render_slash_command_help(),
]
@@ -1991,57 +1883,6 @@ fn render_repl_help() -> String {
)
}
fn append_slash_command_suggestions(lines: &mut Vec<String>, name: &str) {
let suggestions = suggest_slash_commands(name, 3);
if suggestions.is_empty() {
lines.push(" Try /help shows the full slash command map".to_string());
return;
}
lines.push(" Try /help shows the full slash command map".to_string());
lines.push("Suggestions".to_string());
lines.extend(
suggestions
.into_iter()
.map(|suggestion| format!(" {suggestion}")),
);
}
fn render_unknown_repl_command(name: &str) -> String {
let mut lines = vec![
"Unknown slash command".to_string(),
format!(" Command /{name}"),
];
append_repl_command_suggestions(&mut lines, name);
lines.join("\n")
}
fn append_repl_command_suggestions(lines: &mut Vec<String>, name: &str) {
let suggestions = suggest_repl_commands(name);
if suggestions.is_empty() {
lines.push(" Try /help shows the full slash command map".to_string());
return;
}
lines.push(" Try /help shows the full slash command map".to_string());
lines.push("Suggestions".to_string());
lines.extend(
suggestions
.into_iter()
.map(|suggestion| format!(" {suggestion}")),
);
}
fn render_mode_unavailable(command: &str, label: &str) -> String {
[
"Command unavailable in this REPL mode".to_string(),
format!(" Command /{command}"),
format!(" Feature {label}"),
" Tip Use /help to find currently wired REPL commands".to_string(),
]
.join("\n")
}
fn status_context(
session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> {
@@ -2071,41 +1912,33 @@ fn format_status_report(
) -> String {
[
format!(
"Session
"Status
Model {model}
Permissions {permission_mode}
Activity {} messages · {} turns
Tokens est {} · latest {} · total {}",
usage.message_count,
usage.turns,
usage.estimated_tokens,
usage.latest.total_tokens(),
usage.cumulative.total_tokens(),
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cache create {}
Cache read {}",
Cumulative total {}",
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.cache_creation_input_tokens,
usage.cumulative.cache_read_input_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Workspace
Folder {}
Cwd {}
Project root {}
Git branch {}
Session file {}
Session {}
Config files loaded {}/{}
Memory files {}
Next
/help Browse commands
/session list Inspect saved sessions
/diff Review current workspace changes",
Memory files {}",
context.cwd.display(),
context
.project_root
@@ -2220,7 +2053,8 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No CLAW instruction files discovered in the current directory ancestry.".to_string(),
" No CLAW instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
lines.push("Discovered files".to_string());
@@ -2487,7 +2321,7 @@ fn render_version_report() -> String {
let git_sha = GIT_SHA.unwrap_or("unknown");
let target = BUILD_TARGET.unwrap_or("unknown");
format!(
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}\n\nSupport\n Help claw --help\n REPL /help"
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
)
}
@@ -2972,8 +2806,7 @@ fn build_runtime(
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
) -> Result<ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> {
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
Ok(ConversationRuntime::new_with_features(
session,
@@ -3293,70 +3126,10 @@ fn slash_command_completion_candidates() -> Vec<String> {
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
candidates.extend([
String::from("/vim"),
String::from("/exit"),
String::from("/quit"),
]);
candidates.sort();
candidates.dedup();
candidates.push("/vim".to_string());
candidates
}
fn suggest_repl_commands(name: &str) -> Vec<String> {
let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase();
if normalized.is_empty() {
return Vec::new();
}
let mut ranked = slash_command_completion_candidates()
.into_iter()
.filter_map(|candidate| {
let raw = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = edit_distance(&normalized, &raw);
let prefix_match = raw.starts_with(&normalized) || normalized.starts_with(&raw);
let near_match = distance <= 2;
(prefix_match || near_match).then_some((distance, candidate))
})
.collect::<Vec<_>>();
ranked.sort();
ranked.dedup_by(|left, right| left.1 == right.1);
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
fn edit_distance(left: &str, right: &str) -> usize {
if left == right {
return 0;
}
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);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right_chars.len()]
}
fn format_tool_call_start(name: &str, input: &str) -> String {
let parsed: serde_json::Value =
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
@@ -3957,118 +3730,65 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
}
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, "Claw Code CLI v{VERSION}")?;
writeln!(
out,
" Interactive coding assistant for the current workspace."
)?;
writeln!(out, "claw v{VERSION}")?;
writeln!(out)?;
writeln!(out, "Quick start")?;
writeln!(out, "Usage:")?;
writeln!(
out,
" claw Start the interactive REPL"
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
)?;
writeln!(out, " Start the interactive REPL")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] prompt TEXT"
)?;
writeln!(out, " Send one prompt and exit")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] TEXT"
)?;
writeln!(out, " Shorthand non-interactive prompt mode")?;
writeln!(
out,
" claw --resume SESSION.json [/status] [/compact] [...]"
)?;
writeln!(
out,
" claw \"summarize this repo\" Run one prompt and exit"
)?;
writeln!(
out,
" claw prompt \"explain src/main.rs\" Explicit one-shot prompt"
)?;
writeln!(
out,
" claw --resume SESSION.json /status Inspect a saved session"
)?;
writeln!(out)?;
writeln!(out, "Interactive essentials")?;
writeln!(
out,
" /help Browse the full slash command map"
)?;
writeln!(
out,
" /status Inspect session + workspace state"
)?;
writeln!(
out,
" /model <name> Switch models mid-session"
)?;
writeln!(
out,
" /permissions <mode> Adjust tool access"
)?;
writeln!(
out,
" Tab Complete slash commands"
)?;
writeln!(
out,
" /vim Toggle modal editing"
)?;
writeln!(
out,
" Shift+Enter / Ctrl+J Insert a newline"
)?;
writeln!(out)?;
writeln!(out, "Commands")?;
writeln!(
out,
" claw dump-manifests Read upstream TS sources and print extracted counts"
)?;
writeln!(
out,
" claw bootstrap-plan Print the bootstrap phase skeleton"
)?;
writeln!(
out,
" claw agents List configured agents"
)?;
writeln!(
out,
" claw skills List installed skills"
" Inspect or maintain a saved session without entering the REPL"
)?;
writeln!(out, " claw dump-manifests")?;
writeln!(out, " claw bootstrap-plan")?;
writeln!(out, " claw agents")?;
writeln!(out, " claw skills")?;
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
writeln!(out, " claw login")?;
writeln!(out, " claw logout")?;
writeln!(out, " claw init")?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!(
out,
" claw login Start the OAuth login flow"
" --model MODEL Override the active model"
)?;
writeln!(
out,
" claw logout Clear saved OAuth credentials"
" --output-format FORMAT Non-interactive output format: text or json"
)?;
writeln!(
out,
" claw init Scaffold CLAW.md + local files"
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
)?;
writeln!(
out,
" --dangerously-skip-permissions Skip all permission checks"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
out,
" --version, -V Print version and build information locally"
)?;
writeln!(out)?;
writeln!(out, "Flags")?;
writeln!(
out,
" --model MODEL Override the active model"
)?;
writeln!(
out,
" --output-format FORMAT Non-interactive output: text or json"
)?;
writeln!(
out,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
)?;
writeln!(
out,
" --dangerously-skip-permissions Skip all permission checks"
)?;
writeln!(
out,
" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"
)?;
writeln!(
out,
" --version, -V Print version and build information"
)?;
writeln!(out)?;
writeln!(out, "Slash command reference")?;
writeln!(out, "Interactive slash commands:")?;
writeln!(out, "{}", render_slash_command_help())?;
writeln!(out)?;
let resume_commands = resume_supported_slash_commands()
@@ -4080,7 +3800,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
.collect::<Vec<_>>()
.join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?;
writeln!(out, "Examples")?;
writeln!(out, "Examples:")?;
writeln!(out, " claw --model opus \"summarize this repo\"")?;
writeln!(
out,
@@ -4114,10 +3834,9 @@ mod tests {
format_status_report, format_tool_call_start, format_tool_result,
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
print_help_to, push_output_block, render_config_report, render_memory_report,
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
resume_supported_slash_commands, slash_command_completion_candidates, status_context,
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
SlashCommand, StatusUsage, DEFAULT_MODEL,
render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands,
status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent,
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
@@ -4353,8 +4072,7 @@ mod tests {
);
let error = parse_args(&["/status".to_string()])
.expect_err("/status should remain REPL-only when invoked directly");
assert!(error.contains("Direct slash command unavailable"));
assert!(error.contains("/status"));
assert!(error.contains("unsupported direct slash command"));
}
#[test]
@@ -4431,14 +4149,13 @@ mod tests {
fn shared_help_uses_resume_annotation_copy() {
let help = commands::render_slash_command_help();
assert!(help.contains("Slash commands"));
assert!(help.contains("Tab completes commands inside the REPL."));
assert!(help.contains("available via claw --resume SESSION.json"));
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("Interactive REPL"));
assert!(help.contains("REPL"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/model [model]"));
@@ -4460,24 +4177,6 @@ mod tests {
assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert!(help.contains("/exit"));
assert!(help.contains("Tab cycles slash command matches"));
}
#[test]
fn completion_candidates_include_repl_only_exit_commands() {
let candidates = slash_command_completion_candidates();
assert!(candidates.contains(&"/help".to_string()));
assert!(candidates.contains(&"/vim".to_string()));
assert!(candidates.contains(&"/exit".to_string()));
assert!(candidates.contains(&"/quit".to_string()));
}
#[test]
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
let rendered = render_unknown_repl_command("exi");
assert!(rendered.contains("Unknown slash command"));
assert!(rendered.contains("/exit"));
assert!(rendered.contains("/help"));
}
#[test]
@@ -4500,8 +4199,8 @@ mod tests {
let report = format_resume_report("session.json", 14, 6);
assert!(report.contains("Session resumed"));
assert!(report.contains("Session file session.json"));
assert!(report.contains("History 14 messages · 6 turns"));
assert!(report.contains("/status · /diff · /export"));
assert!(report.contains("Messages 14"));
assert!(report.contains("Turns 6"));
}
#[test]
@@ -4510,7 +4209,6 @@ mod tests {
assert!(compacted.contains("Compact"));
assert!(compacted.contains("Result compacted"));
assert!(compacted.contains("Messages removed 8"));
assert!(compacted.contains("Use /status"));
let skipped = format_compact_report(0, 3, true);
assert!(skipped.contains("Result skipped"));
}
@@ -4529,7 +4227,6 @@ mod tests {
assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32"));
assert!(report.contains("/compact"));
}
#[test]
@@ -4537,7 +4234,6 @@ mod tests {
let report = format_permissions_report("workspace-write");
assert!(report.contains("Permissions"));
assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Effect Editing tools can modify files in the workspace"));
assert!(report.contains("Modes"));
assert!(report.contains("read-only ○ available Read/search tools only"));
assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
@@ -4548,9 +4244,10 @@ mod tests {
fn permissions_switch_report_is_structured() {
let report = format_permissions_switch_report("read-only", "workspace-write");
assert!(report.contains("Permissions updated"));
assert!(report.contains("Result mode switched"));
assert!(report.contains("Previous mode read-only"));
assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Applies to Subsequent tool calls in this REPL"));
assert!(report.contains("Applies to subsequent tool calls"));
}
#[test]
@@ -4568,10 +4265,9 @@ mod tests {
fn model_report_uses_sectioned_layout() {
let report = format_model_report("sonnet", 12, 4);
assert!(report.contains("Model"));
assert!(report.contains("Current sonnet"));
assert!(report.contains("Session 12 messages · 4 turns"));
assert!(report.contains("Aliases"));
assert!(report.contains("/model <name> Switch models for this REPL session"));
assert!(report.contains("Current model sonnet"));
assert!(report.contains("Session messages 12"));
assert!(report.contains("Switch models with /model <name>"));
}
#[test]
@@ -4580,7 +4276,7 @@ mod tests {
assert!(report.contains("Model updated"));
assert!(report.contains("Previous sonnet"));
assert!(report.contains("Current opus"));
assert!(report.contains("Preserved 9 messages"));
assert!(report.contains("Preserved msgs 9"));
}
#[test]
@@ -4615,18 +4311,18 @@ mod tests {
git_branch: Some("main".to_string()),
},
);
assert!(status.contains("Session"));
assert!(status.contains("Status"));
assert!(status.contains("Model sonnet"));
assert!(status.contains("Permissions workspace-write"));
assert!(status.contains("Activity 7 messages · 3 turns"));
assert!(status.contains("Tokens est 128 · latest 10 · total 31"));
assert!(status.contains("Folder /tmp/project"));
assert!(status.contains("Permission mode workspace-write"));
assert!(status.contains("Messages 7"));
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 file session.json"));
assert!(status.contains("Session session.json"));
assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4"));
assert!(status.contains("/session list"));
}
#[test]
@@ -4762,8 +4458,8 @@ mod tests {
fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help();
assert!(help.contains("Up/Down"));
assert!(help.contains("Tab cycles"));
assert!(help.contains("Shift+Enter or Ctrl+J"));
assert!(help.contains("Tab"));
assert!(help.contains("Shift+Enter/Ctrl+J"));
}
#[test]

View File

@@ -39,27 +39,6 @@ impl CommandRegistry {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlashCommandCategory {
Core,
Workspace,
Session,
Git,
Automation,
}
impl SlashCommandCategory {
const fn title(self) -> &'static str {
match self {
Self::Core => "Core flow",
Self::Workspace => "Workspace & memory",
Self::Session => "Sessions & output",
Self::Git => "Git & GitHub",
Self::Automation => "Automation & discovery",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlashCommandSpec {
pub name: &'static str,
@@ -67,7 +46,6 @@ pub struct SlashCommandSpec {
pub summary: &'static str,
pub argument_hint: Option<&'static str>,
pub resume_supported: bool,
pub category: SlashCommandCategory,
}
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
@@ -77,7 +55,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show available slash commands",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "status",
@@ -85,7 +62,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show current session status",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "compact",
@@ -93,7 +69,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Compact local session history",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "model",
@@ -101,7 +76,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show or switch the active model",
argument_hint: Some("[model]"),
resume_supported: false,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "permissions",
@@ -109,7 +83,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show or switch the active permission mode",
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
resume_supported: false,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "clear",
@@ -117,7 +90,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Start a fresh local session",
argument_hint: Some("[--confirm]"),
resume_supported: true,
category: SlashCommandCategory::Session,
},
SlashCommandSpec {
name: "cost",
@@ -125,7 +97,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show cumulative token usage for this session",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "resume",
@@ -133,7 +104,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Load a saved session into the REPL",
argument_hint: Some("<session-path>"),
resume_supported: false,
category: SlashCommandCategory::Session,
},
SlashCommandSpec {
name: "config",
@@ -141,7 +111,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Inspect Claw config files or merged sections",
argument_hint: Some("[env|hooks|model|plugins]"),
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "memory",
@@ -149,7 +118,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Inspect loaded Claw instruction memory files",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "init",
@@ -157,7 +125,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Create a starter CLAW.md for this repo",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "diff",
@@ -165,7 +132,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show git diff for current workspace changes",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "version",
@@ -173,7 +139,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show CLI version and build information",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "bughunter",
@@ -181,7 +146,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Inspect the codebase for likely bugs",
argument_hint: Some("[scope]"),
resume_supported: false,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "branch",
@@ -189,7 +153,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List, create, or switch git branches",
argument_hint: Some("[list|create <name>|switch <name>]"),
resume_supported: false,
category: SlashCommandCategory::Git,
},
SlashCommandSpec {
name: "worktree",
@@ -197,7 +160,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List, add, remove, or prune git worktrees",
argument_hint: Some("[list|add <path> [branch]|remove <path>|prune]"),
resume_supported: false,
category: SlashCommandCategory::Git,
},
SlashCommandSpec {
name: "commit",
@@ -205,7 +167,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Generate a commit message and create a git commit",
argument_hint: None,
resume_supported: false,
category: SlashCommandCategory::Git,
},
SlashCommandSpec {
name: "commit-push-pr",
@@ -213,7 +174,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Commit workspace changes, push the branch, and open a PR",
argument_hint: Some("[context]"),
resume_supported: false,
category: SlashCommandCategory::Git,
},
SlashCommandSpec {
name: "pr",
@@ -221,7 +181,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Draft or create a pull request from the conversation",
argument_hint: Some("[context]"),
resume_supported: false,
category: SlashCommandCategory::Git,
},
SlashCommandSpec {
name: "issue",
@@ -229,7 +188,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Draft or create a GitHub issue from the conversation",
argument_hint: Some("[context]"),
resume_supported: false,
category: SlashCommandCategory::Git,
},
SlashCommandSpec {
name: "ultraplan",
@@ -237,7 +195,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Run a deep planning prompt with multi-step reasoning",
argument_hint: Some("[task]"),
resume_supported: false,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "teleport",
@@ -245,7 +202,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Jump to a file or symbol by searching the workspace",
argument_hint: Some("<symbol-or-path>"),
resume_supported: false,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "debug-tool-call",
@@ -253,7 +209,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Replay the last tool call with debug details",
argument_hint: None,
resume_supported: false,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "export",
@@ -261,7 +216,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Export the current conversation to a file",
argument_hint: Some("[file]"),
resume_supported: true,
category: SlashCommandCategory::Session,
},
SlashCommandSpec {
name: "session",
@@ -269,7 +223,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List or switch managed local sessions",
argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false,
category: SlashCommandCategory::Session,
},
SlashCommandSpec {
name: "plugin",
@@ -279,7 +232,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
"[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
),
resume_supported: false,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "agents",
@@ -287,7 +239,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List configured agents",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "skills",
@@ -295,7 +246,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List available skills",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Automation,
},
];
@@ -487,131 +437,38 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
pub fn render_slash_command_help() -> String {
let mut lines = vec![
"Slash commands".to_string(),
" Tab completes commands inside the REPL.".to_string(),
" [resume] = also available via claw --resume SESSION.json".to_string(),
" [resume] means the command also works with --resume SESSION.json".to_string(),
];
for category in [
SlashCommandCategory::Core,
SlashCommandCategory::Workspace,
SlashCommandCategory::Session,
SlashCommandCategory::Git,
SlashCommandCategory::Automation,
] {
lines.push(String::new());
lines.push(category.title().to_string());
lines.extend(
slash_command_specs()
.iter()
.filter(|spec| spec.category == category)
.map(render_slash_command_entry),
);
for spec in slash_command_specs() {
let name = match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
};
let alias_suffix = if spec.aliases.is_empty() {
String::new()
} else {
format!(
" (aliases: {})",
spec.aliases
.iter()
.map(|alias| format!("/{alias}"))
.collect::<Vec<_>>()
.join(", ")
)
};
let resume = if spec.resume_supported {
" [resume]"
} else {
""
};
lines.push(format!(
" {name:<20} {}{alias_suffix}{resume}",
spec.summary
));
}
lines.join("\n")
}
fn render_slash_command_entry(spec: &SlashCommandSpec) -> String {
let alias_suffix = if spec.aliases.is_empty() {
String::new()
} else {
format!(
" (aliases: {})",
spec.aliases
.iter()
.map(|alias| format!("/{alias}"))
.collect::<Vec<_>>()
.join(", ")
)
};
let resume = if spec.resume_supported {
" [resume]"
} else {
""
};
format!(
" {name:<46} {}{alias_suffix}{resume}",
spec.summary,
name = render_slash_command_name(spec),
)
}
fn render_slash_command_name(spec: &SlashCommandSpec) -> String {
match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
}
}
fn levenshtein_distance(left: &str, right: &str) -> usize {
if left == right {
return 0;
}
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 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] + cost);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right_chars.len()]
}
#[must_use]
pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
let normalized = input.trim().trim_start_matches('/').to_ascii_lowercase();
if normalized.is_empty() || limit == 0 {
return Vec::new();
}
let mut ranked = slash_command_specs()
.iter()
.filter_map(|spec| {
let score = std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(str::to_ascii_lowercase)
.filter_map(|alias| {
if alias == normalized {
Some((0_usize, alias.len()))
} else if alias.starts_with(&normalized) {
Some((1, alias.len()))
} else if alias.contains(&normalized) {
Some((2, alias.len()))
} else {
let distance = levenshtein_distance(&alias, &normalized);
(distance <= 2).then_some((3 + distance, alias.len()))
}
})
.min();
score.map(|(bucket, len)| (bucket, len, render_slash_command_name(spec)))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right));
ranked.dedup_by(|left, right| left.2 == right.2);
ranked
.into_iter()
.take(limit)
.map(|(_, _, display)| display)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlashCommandResult {
pub message: String,
@@ -1795,8 +1652,7 @@ mod tests {
handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots,
render_agents_report, render_plugins_report, render_skills_report,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot,
SlashCommand,
CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
@@ -2108,12 +1964,7 @@ mod tests {
#[test]
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
assert!(help.contains("available via claw --resume SESSION.json"));
assert!(help.contains("Core flow"));
assert!(help.contains("Workspace & memory"));
assert!(help.contains("Sessions & output"));
assert!(help.contains("Git & GitHub"));
assert!(help.contains("Automation & discovery"));
assert!(help.contains("works with --resume SESSION.json"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/compact"));
@@ -2149,13 +2000,6 @@ mod tests {
assert_eq!(resume_supported_slash_commands().len(), 13);
}
#[test]
fn suggests_close_slash_commands() {
let suggestions = suggest_slash_commands("stats", 3);
assert!(!suggestions.is_empty());
assert_eq!(suggestions[0], "/status");
}
#[test]
fn compacts_sessions_via_slash_command() {
let session = Session {