mirror of
https://github.com/instructkr/claude-code.git
synced 2026-04-03 11:49:00 +03:00
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
This commit is contained in:
@@ -1015,22 +1015,22 @@ fn run_repl(
|
|||||||
loop {
|
loop {
|
||||||
match editor.read_line()? {
|
match editor.read_line()? {
|
||||||
input::ReadOutcome::Submit(input) => {
|
input::ReadOutcome::Submit(input) => {
|
||||||
let trimmed = input.trim().to_string();
|
let trimmed = input.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
if matches!(trimmed, "/exit" | "/quit") {
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(command) = SlashCommand::parse(&trimmed) {
|
if let Some(command) = SlashCommand::parse(trimmed) {
|
||||||
if cli.handle_repl_command(command)? {
|
if cli.handle_repl_command(command)? {
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
editor.push_history(input);
|
editor.push_history(&input);
|
||||||
cli.run_turn(&trimmed)?;
|
cli.run_turn(&input)?;
|
||||||
}
|
}
|
||||||
input::ReadOutcome::Cancel => {}
|
input::ReadOutcome::Cancel => {}
|
||||||
input::ReadOutcome::Exit => {
|
input::ReadOutcome::Exit => {
|
||||||
@@ -1350,7 +1350,7 @@ impl LiveCli {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
SlashCommand::Unknown(name) => {
|
SlashCommand::Unknown(name) => {
|
||||||
eprintln!("{}", render_unknown_slash_command(&name));
|
eprintln!("{}", render_unknown_repl_command(&name));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2007,15 +2007,31 @@ fn append_slash_command_suggestions(lines: &mut Vec<String>, name: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_unknown_slash_command(name: &str) -> String {
|
fn render_unknown_repl_command(name: &str) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Unknown slash command".to_string(),
|
"Unknown slash command".to_string(),
|
||||||
format!(" Command /{name}"),
|
format!(" Command /{name}"),
|
||||||
];
|
];
|
||||||
append_slash_command_suggestions(&mut lines, name);
|
append_repl_command_suggestions(&mut lines, name);
|
||||||
lines.join("\n")
|
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 {
|
fn render_mode_unavailable(command: &str, label: &str) -> String {
|
||||||
[
|
[
|
||||||
"Command unavailable in this REPL mode".to_string(),
|
"Command unavailable in this REPL mode".to_string(),
|
||||||
@@ -3277,10 +3293,70 @@ fn slash_command_completion_candidates() -> Vec<String> {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
candidates.push("/vim".to_string());
|
candidates.extend([
|
||||||
|
String::from("/vim"),
|
||||||
|
String::from("/exit"),
|
||||||
|
String::from("/quit"),
|
||||||
|
]);
|
||||||
|
candidates.sort();
|
||||||
|
candidates.dedup();
|
||||||
candidates
|
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 {
|
fn format_tool_call_start(name: &str, input: &str) -> String {
|
||||||
let parsed: serde_json::Value =
|
let parsed: serde_json::Value =
|
||||||
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
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,
|
format_status_report, format_tool_call_start, format_tool_result,
|
||||||
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
|
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
|
||||||
print_help_to, push_output_block, render_config_report, render_memory_report,
|
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,
|
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
||||||
status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent,
|
resume_supported_slash_commands, slash_command_completion_candidates, status_context,
|
||||||
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
||||||
|
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
||||||
@@ -4355,7 +4432,7 @@ mod tests {
|
|||||||
let help = commands::render_slash_command_help();
|
let help = commands::render_slash_command_help();
|
||||||
assert!(help.contains("Slash commands"));
|
assert!(help.contains("Slash commands"));
|
||||||
assert!(help.contains("Tab completes commands inside the REPL."));
|
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]
|
#[test]
|
||||||
@@ -4386,6 +4463,23 @@ mod tests {
|
|||||||
assert!(help.contains("Tab cycles slash command matches"));
|
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]
|
#[test]
|
||||||
fn resume_supported_command_list_matches_expected_surface() {
|
fn resume_supported_command_list_matches_expected_surface() {
|
||||||
let names = resume_supported_slash_commands()
|
let names = resume_supported_slash_commands()
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ pub fn render_slash_command_help() -> String {
|
|||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Slash commands".to_string(),
|
"Slash commands".to_string(),
|
||||||
" Tab completes commands inside the REPL.".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 [
|
for category in [
|
||||||
@@ -2108,7 +2108,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn renders_help_from_shared_specs() {
|
fn renders_help_from_shared_specs() {
|
||||||
let help = render_slash_command_help();
|
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("Core flow"));
|
||||||
assert!(help.contains("Workspace & memory"));
|
assert!(help.contains("Workspace & memory"));
|
||||||
assert!(help.contains("Sessions & output"));
|
assert!(help.contains("Sessions & output"));
|
||||||
|
|||||||
Reference in New Issue
Block a user