Make the CLI feel guided and navigable before release

This redesign pass tightens the first-run and interactive experience
without changing the core execution model. The startup banner is now a
compact readiness summary instead of a large logo block, help output is
layered into quick-start and grouped slash-command sections, status and
permissions views read like operator dashboards, and direct/interactive
error surfaces now point users toward the next useful action.

The REPL also gains cycling slash-command completion so discoverability
improves even before a user has memorized the command set. Shared slash
command metadata now drives grouped help rendering and lightweight
command suggestions, which keeps interactive and non-interactive copy in
sync.

Constraint: Pre-release UX pass had to stay inside the existing Rust workspace with no new dependencies
Constraint: Existing slash command behavior and tests had to remain compatible while improving presentation
Rejected: Introduce a full-screen TUI command palette | too large and risky for this release pass
Rejected: Add trailing-space smart completion for argument-taking commands | conflicted with reliable completion cycling
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep startup hints, grouped slash help, and completion behavior aligned with slash_command_specs as commands evolve
Tested: cargo check
Tested: cargo test
Tested: Manual QA of `claw --help`, piped REPL `/help` `/status` `/permissions` `/session list` `/wat`, direct `/wat`, and interactive Tab cycling in the REPL
Not-tested: Live network-backed conversation turns and long streaming sessions
This commit is contained in:
Yeachan-Heo
2026-04-01 13:36:17 +00:00
parent 3220db2d6f
commit 864a9124fc
3 changed files with 624 additions and 182 deletions

View File

@@ -244,6 +244,14 @@ 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 {
@@ -255,6 +263,7 @@ impl LineEditor {
history: Vec::new(),
yank_buffer: YankBuffer::default(),
vim_enabled: false,
completion_state: None,
}
}
@@ -357,6 +366,10 @@ 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') => {
@@ -673,22 +686,62 @@ impl LineEditor {
session.cursor = insert_at + self.yank_buffer.text.len();
}
fn complete_slash_command(&self, session: &mut EditSession) {
fn complete_slash_command(&mut 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 Some(candidate) = self
let matches = self
.completions
.iter()
.find(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
else {
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
.cloned()
.collect::<Vec<_>>();
if matches.is_empty() {
self.completion_state = None;
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();
}
@@ -1086,7 +1139,7 @@ mod tests {
#[test]
fn tab_completes_matching_slash_commands() {
// given
let editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
let mut 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();
@@ -1099,6 +1152,29 @@ 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, Read, Write};
use std::io::{self, IsTerminal, Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -16,14 +16,15 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use api::{
resolve_startup_auth_source, ClawApiClient, AuthSource, ContentBlockDelta, InputContentBlock,
resolve_startup_auth_source, AuthSource, ClawApiClient, 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, SlashCommand,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
@@ -59,15 +60,25 @@ type AllowedToolSet = BTreeSet<String>;
fn main() {
if let Err(error) = run() {
eprintln!(
"error: {error}
Run `claw --help` for usage."
);
eprintln!("{}", render_cli_error(&error.to_string()));
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)? {
@@ -321,17 +332,36 @@ 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!(
"unsupported direct slash command outside the REPL: {command_name}",
command_name = match command {
Some(command) => Err(format_direct_slash_command_error(
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",
@@ -670,13 +700,17 @@ struct StatusUsage {
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current model {model}
Session messages {message_count}
Session turns {turns}
Current {model}
Session {message_count} messages · {turns} turns
Usage
Inspect current model with /model
Switch models with /model <name>"
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"
)
}
@@ -685,7 +719,8 @@ fn format_model_switch_report(previous: &str, next: &str, message_count: usize)
"Model updated
Previous {previous}
Current {next}
Preserved msgs {message_count}"
Preserved {message_count} messages
Tip Existing conversation context stayed attached"
)
}
@@ -718,28 +753,34 @@ 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}
Mode status live session default
Effect {effect}
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
Next
/permissions Show the current mode
/permissions <mode> Switch modes for subsequent tool calls"
)
}
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
Usage /permissions to inspect current mode"
Applies to Subsequent tool calls in this REPL
Tip Run /permissions to review all available modes"
)
}
@@ -750,7 +791,11 @@ fn format_cost_report(usage: TokenUsage) -> String {
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
Total tokens {}
Next
/status See session + workspace context
/compact Trim local history if the session is getting large",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
@@ -763,8 +808,8 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
History {message_count} messages · {turns} turns
Next /status · /diff · /export"
)
}
@@ -773,7 +818,7 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
format!(
"Compact
Result skipped
Reason session below compaction threshold
Reason Session is already below the compaction threshold
Messages kept {resulting_messages}"
)
} else {
@@ -781,7 +826,8 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
Messages kept {resulting_messages}
Tip Use /status to review the trimmed session"
)
}
}
@@ -1052,28 +1098,65 @@ impl LiveCli {
}
fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_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(),
|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!(
"\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,
)
"{} {}",
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")
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -1246,19 +1329,28 @@ impl LiveCli {
false
}
SlashCommand::Branch { .. } => {
eprintln!("git branch commands not yet wired to REPL");
eprintln!(
"{}",
render_mode_unavailable("branch", "git branch commands")
);
false
}
SlashCommand::Worktree { .. } => {
eprintln!("git worktree commands not yet wired to REPL");
eprintln!(
"{}",
render_mode_unavailable("worktree", "git worktree commands")
);
false
}
SlashCommand::CommitPushPr { .. } => {
eprintln!("commit-push-pr not yet wired to REPL");
eprintln!(
"{}",
render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
);
false
}
SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}");
eprintln!("{}", render_unknown_slash_command(&name));
false
}
})
@@ -1837,6 +1929,20 @@ 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![
@@ -1854,26 +1960,28 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
"○ saved"
};
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
" {id:<20} {marker:<10} {msgs:>3} msgs · updated {modified}",
id = session.id,
msgs = session.message_count,
modified = session.modified_epoch_secs,
path = session.path.display(),
modified = format_relative_timestamp(session.modified_epoch_secs),
));
lines.push(format!(" {}", session.path.display()));
}
Ok(lines.join("\n"))
}
fn render_repl_help() -> 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(),
"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(),
String::new(),
render_slash_command_help(),
]
@@ -1883,6 +1991,41 @@ 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_slash_command(name: &str) -> String {
let mut lines = vec![
"Unknown slash command".to_string(),
format!(" Command /{name}"),
];
append_slash_command_suggestions(&mut lines, name);
lines.join("\n")
}
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>> {
@@ -1912,33 +2055,41 @@ fn format_status_report(
) -> String {
[
format!(
"Status
"Session
Model {model}
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
Permissions {permission_mode}
Activity {} messages · {} turns
Tokens est {} · latest {} · total {}",
usage.message_count,
usage.turns,
usage.estimated_tokens,
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Usage
Cumulative input {}
Cumulative output {}
Cache create {}
Cache read {}",
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.cache_creation_input_tokens,
usage.cumulative.cache_read_input_tokens,
),
format!(
"Workspace
Cwd {}
Folder {}
Project root {}
Git branch {}
Session {}
Session file {}
Config files loaded {}/{}
Memory files {}",
Memory files {}
Next
/help Browse commands
/session list Inspect saved sessions
/diff Review current workspace changes",
context.cwd.display(),
context
.project_root
@@ -2053,8 +2204,7 @@ 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());
@@ -2321,7 +2471,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}"
"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"
)
}
@@ -2806,7 +2956,8 @@ 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,
@@ -3730,49 +3881,99 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
}
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, "claw v{VERSION}")?;
writeln!(out, "Claw Code CLI v{VERSION}")?;
writeln!(
out,
" Interactive coding assistant for the current workspace."
)?;
writeln!(out)?;
writeln!(out, "Usage:")?;
writeln!(out, "Quick start")?;
writeln!(
out,
" 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] [...]"
" claw Start the interactive REPL"
)?;
writeln!(
out,
" Inspect or maintain a saved session without entering the REPL"
" 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"
)?;
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,
" claw login Start the OAuth login flow"
)?;
writeln!(
out,
" claw logout Clear saved OAuth credentials"
)?;
writeln!(
out,
" claw init Scaffold CLAW.md + local files"
)?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!(out, "Flags")?;
writeln!(
out,
" --model MODEL Override the active model"
)?;
writeln!(
out,
" --output-format FORMAT Non-interactive output format: text or json"
" --output-format FORMAT Non-interactive output: text or json"
)?;
writeln!(
out,
@@ -3782,13 +3983,16 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
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"
" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"
)?;
writeln!(
out,
" --version, -V Print version and build information"
)?;
writeln!(out)?;
writeln!(out, "Interactive slash commands:")?;
writeln!(out, "Slash command reference")?;
writeln!(out, "{}", render_slash_command_help())?;
writeln!(out)?;
let resume_commands = resume_supported_slash_commands()
@@ -3800,7 +4004,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,
@@ -4072,7 +4276,8 @@ mod tests {
);
let error = parse_args(&["/status".to_string()])
.expect_err("/status should remain REPL-only when invoked directly");
assert!(error.contains("unsupported direct slash command"));
assert!(error.contains("Direct slash command unavailable"));
assert!(error.contains("/status"));
}
#[test]
@@ -4149,13 +4354,14 @@ 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("works with --resume SESSION.json"));
}
#[test]
fn repl_help_includes_shared_commands_and_exit() {
let help = render_repl_help();
assert!(help.contains("REPL"));
assert!(help.contains("Interactive REPL"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/model [model]"));
@@ -4177,6 +4383,7 @@ mod tests {
assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert!(help.contains("/exit"));
assert!(help.contains("Tab cycles slash command matches"));
}
#[test]
@@ -4199,8 +4406,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("Messages 14"));
assert!(report.contains("Turns 6"));
assert!(report.contains("History 14 messages · 6 turns"));
assert!(report.contains("/status · /diff · /export"));
}
#[test]
@@ -4209,6 +4416,7 @@ 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"));
}
@@ -4227,6 +4435,7 @@ 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]
@@ -4234,6 +4443,7 @@ 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"));
@@ -4244,10 +4454,9 @@ 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"));
assert!(report.contains("Applies to Subsequent tool calls in this REPL"));
}
#[test]
@@ -4265,9 +4474,10 @@ mod tests {
fn model_report_uses_sectioned_layout() {
let report = format_model_report("sonnet", 12, 4);
assert!(report.contains("Model"));
assert!(report.contains("Current model sonnet"));
assert!(report.contains("Session messages 12"));
assert!(report.contains("Switch models with /model <name>"));
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"));
}
#[test]
@@ -4276,7 +4486,7 @@ mod tests {
assert!(report.contains("Model updated"));
assert!(report.contains("Previous sonnet"));
assert!(report.contains("Current opus"));
assert!(report.contains("Preserved msgs 9"));
assert!(report.contains("Preserved 9 messages"));
}
#[test]
@@ -4311,18 +4521,18 @@ mod tests {
git_branch: Some("main".to_string()),
},
);
assert!(status.contains("Status"));
assert!(status.contains("Session"));
assert!(status.contains("Model sonnet"));
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("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("Project root /tmp"));
assert!(status.contains("Git branch main"));
assert!(status.contains("Session session.json"));
assert!(status.contains("Session file session.json"));
assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4"));
assert!(status.contains("/session list"));
}
#[test]
@@ -4458,8 +4668,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"));
assert!(help.contains("Shift+Enter/Ctrl+J"));
assert!(help.contains("Tab cycles"));
assert!(help.contains("Shift+Enter or Ctrl+J"));
}
#[test]

View File

@@ -39,6 +39,27 @@ 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,
@@ -46,6 +67,7 @@ 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] = &[
@@ -55,6 +77,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show available slash commands",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "status",
@@ -62,6 +85,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show current session status",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "compact",
@@ -69,6 +93,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Compact local session history",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Core,
},
SlashCommandSpec {
name: "model",
@@ -76,6 +101,7 @@ 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",
@@ -83,6 +109,7 @@ 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",
@@ -90,6 +117,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Start a fresh local session",
argument_hint: Some("[--confirm]"),
resume_supported: true,
category: SlashCommandCategory::Session,
},
SlashCommandSpec {
name: "cost",
@@ -97,6 +125,7 @@ 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",
@@ -104,6 +133,7 @@ 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",
@@ -111,6 +141,7 @@ 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",
@@ -118,6 +149,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Inspect loaded Claw instruction memory files",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "init",
@@ -125,6 +157,7 @@ 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",
@@ -132,6 +165,7 @@ 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",
@@ -139,6 +173,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show CLI version and build information",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Workspace,
},
SlashCommandSpec {
name: "bughunter",
@@ -146,6 +181,7 @@ 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",
@@ -153,6 +189,7 @@ 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",
@@ -160,6 +197,7 @@ 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",
@@ -167,6 +205,7 @@ 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",
@@ -174,6 +213,7 @@ 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",
@@ -181,6 +221,7 @@ 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",
@@ -188,6 +229,7 @@ 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",
@@ -195,6 +237,7 @@ 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",
@@ -202,6 +245,7 @@ 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",
@@ -209,6 +253,7 @@ 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",
@@ -216,6 +261,7 @@ 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",
@@ -223,6 +269,7 @@ 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",
@@ -232,6 +279,7 @@ 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",
@@ -239,6 +287,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List configured agents",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "skills",
@@ -246,6 +295,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "List available skills",
argument_hint: None,
resume_supported: true,
category: SlashCommandCategory::Automation,
},
];
@@ -437,13 +487,31 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
pub fn render_slash_command_help() -> String {
let mut lines = vec![
"Slash commands".to_string(),
" [resume] means the command also works with --resume SESSION.json".to_string(),
" Tab completes commands inside the REPL.".to_string(),
" [resume] works with --resume SESSION.json.".to_string(),
];
for spec in slash_command_specs() {
let name = match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
};
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),
);
}
lines.join("\n")
}
fn render_slash_command_entry(spec: &SlashCommandSpec) -> String {
let alias_suffix = if spec.aliases.is_empty() {
String::new()
} else {
@@ -461,12 +529,87 @@ pub fn render_slash_command_help() -> String {
} else {
""
};
lines.push(format!(
" {name:<20} {}{alias_suffix}{resume}",
spec.summary
));
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),
}
lines.join("\n")
}
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)]
@@ -1652,7 +1795,8 @@ 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,
CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot,
SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
@@ -1965,6 +2109,11 @@ mod tests {
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
assert!(help.contains("works with --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("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/compact"));
@@ -2000,6 +2149,13 @@ 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 {