From 9415d9c9afb40db7be8e6d884585766365a22ce5 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 20:11:13 +0000 Subject: [PATCH] Converge the release REPL hardening onto the redesigned CLI The release branch keeps feat/uiux-redesign as the primary UX surface and only reapplies the hardening changes that still add value there. REPL turns now preserve raw user input, REPL-only unknown slash command guidance can suggest exit shortcuts alongside shared commands, slash completion includes /exit and /quit, and the shared help copy keeps the grouped redesign while making resume guidance a little clearer. The release-facing README and 0.1.0 draft notes already matched the current release-doc wording, so no extra docs delta was needed in this convergence commit. Constraint: Keep the redesigned startup/help/status surfaces intact for release/0.1.0 Constraint: Do not reintroduce blanket prompt trimming before runtime submission Rejected: Port the hardening branch's editor-mode/config path wholesale | it diverged from the redesigned custom line editor and would have regressed the release UX Rejected: Flatten grouped slash help back into per-command blocks | weaker fit for the redesign's operator-style help surface Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep REPL-only suggestions and completion candidates aligned when adding or removing /vim, /exit, or /quit behavior Tested: cargo check Tested: cargo test Not-tested: Live provider-backed REPL turns and interactive terminal manual QA --- rust/crates/claw-cli/src/main.rs | 120 +++++++++++++++++++++++++++---- rust/crates/commands/src/lib.rs | 4 +- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index f0eb2b3..2b7d6f1 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -1015,22 +1015,22 @@ fn run_repl( loop { match editor.read_line()? { input::ReadOutcome::Submit(input) => { - let trimmed = input.trim().to_string(); + let trimmed = input.trim(); if trimmed.is_empty() { continue; } - if matches!(trimmed.as_str(), "/exit" | "/quit") { + if matches!(trimmed, "/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(&trimmed)?; + editor.push_history(&input); + cli.run_turn(&input)?; } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { @@ -1350,7 +1350,7 @@ impl LiveCli { false } SlashCommand::Unknown(name) => { - eprintln!("{}", render_unknown_slash_command(&name)); + eprintln!("{}", render_unknown_repl_command(&name)); false } }) @@ -2007,15 +2007,31 @@ fn append_slash_command_suggestions(lines: &mut Vec, name: &str) { ); } -fn render_unknown_slash_command(name: &str) -> String { +fn render_unknown_repl_command(name: &str) -> String { let mut lines = vec![ "Unknown slash command".to_string(), format!(" Command /{name}"), ]; - append_slash_command_suggestions(&mut lines, name); + append_repl_command_suggestions(&mut lines, name); lines.join("\n") } +fn append_repl_command_suggestions(lines: &mut Vec, 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(), @@ -3277,10 +3293,70 @@ fn slash_command_completion_candidates() -> Vec { .collect::>() }) .collect::>(); - candidates.push("/vim".to_string()); + candidates.extend([ + String::from("/vim"), + String::from("/exit"), + String::from("/quit"), + ]); + candidates.sort(); + candidates.dedup(); candidates } +fn suggest_repl_commands(name: &str) -> Vec { + 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::>(); + 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::>(); + let mut previous = (0..=right_chars.len()).collect::>(); + 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())); @@ -4038,9 +4114,10 @@ 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, resolve_model_alias, response_to_events, resume_supported_slash_commands, - status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, - InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, + 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, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; @@ -4355,7 +4432,7 @@ mod tests { 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")); + assert!(help.contains("available via claw --resume SESSION.json")); } #[test] @@ -4386,6 +4463,23 @@ mod tests { 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] fn resume_supported_command_list_matches_expected_surface() { let names = resume_supported_slash_commands() diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 22ca29e..da7f1a4 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -488,7 +488,7 @@ pub fn render_slash_command_help() -> String { let mut lines = vec![ "Slash commands".to_string(), " Tab completes commands inside the REPL.".to_string(), - " [resume] works with --resume SESSION.json.".to_string(), + " [resume] = also available via claw --resume SESSION.json".to_string(), ]; for category in [ @@ -2108,7 +2108,7 @@ mod tests { #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); - assert!(help.contains("works with --resume SESSION.json")); + assert!(help.contains("available via claw --resume SESSION.json")); assert!(help.contains("Core flow")); assert!(help.contains("Workspace & memory")); assert!(help.contains("Sessions & output"));