From a13b1c2825e1dc50522a2886cf3300530eb1ac96 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 13:05:32 +0000 Subject: [PATCH] Make the REPL feel more reliable and discoverable This pass hardens the interactive UX instead of chasing feature breadth. It preserves raw REPL input whitespace, honors the configured editorMode for vim-oriented sessions, improves slash-command help readability, and turns unknown slash commands into actionable guidance instead of noisy stderr output. Constraint: Keep the existing slash-command surface and avoid new dependencies Rejected: Full TUI/input rewrite | too broad for a polish-and-reliability pass Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve user prompt text exactly in the REPL path; do not reintroduce blanket trimming before runtime submission Tested: cargo check Tested: cargo test Tested: Manual QA of /help, /status, /statu suggestion flow, and editorMode=vim banner/help/status behavior Not-tested: Live network-backed assistant turns against a real provider --- rust/crates/claw-cli/src/app.rs | 4 +- rust/crates/claw-cli/src/input.rs | 58 +++++++++- rust/crates/claw-cli/src/main.rs | 184 +++++++++++++++++++++++++----- rust/crates/commands/src/lib.rs | 44 ++++--- rust/crates/runtime/src/config.rs | 5 + 5 files changed, 235 insertions(+), 60 deletions(-) diff --git a/rust/crates/claw-cli/src/app.rs b/rust/crates/claw-cli/src/app.rs index e012f27..34cddb6 100644 --- a/rust/crates/claw-cli/src/app.rs +++ b/rust/crates/claw-cli/src/app.rs @@ -2,7 +2,7 @@ use std::io::{self, Write}; use std::path::PathBuf; use crate::args::{OutputFormat, PermissionMode}; -use crate::input::{LineEditor, ReadOutcome}; +use crate::input::{EditorMode, LineEditor, ReadOutcome}; use crate::render::{Spinner, TerminalRenderer}; use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary}; @@ -111,7 +111,7 @@ impl CliApp { } pub fn run_repl(&mut self) -> io::Result<()> { - let mut editor = LineEditor::new("› ", Vec::new()); + let mut editor = LineEditor::new("› ", Vec::new(), EditorMode::Emacs); println!("Claw Code interactive mode"); println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); diff --git a/rust/crates/claw-cli/src/input.rs b/rust/crates/claw-cli/src/input.rs index 1cf6029..2faadcf 100644 --- a/rust/crates/claw-cli/src/input.rs +++ b/rust/crates/claw-cli/src/input.rs @@ -19,6 +19,38 @@ pub enum ReadOutcome { Exit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditorMode { + Emacs, + Vim, +} + +impl EditorMode { + #[must_use] + pub fn from_config_value(value: Option<&str>) -> Self { + match value { + Some("vim") => Self::Vim, + Some("emacs") | Some("default") | None => Self::Emacs, + Some(_) => Self::Emacs, + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Emacs => "emacs", + Self::Vim => "vim", + } + } + + const fn rustyline_mode(self) -> EditMode { + match self { + Self::Emacs => EditMode::Emacs, + Self::Vim => EditMode::Vi, + } + } +} + struct SlashCommandHelper { completions: Vec, current_line: RefCell, @@ -100,10 +132,10 @@ pub struct LineEditor { impl LineEditor { #[must_use] - pub fn new(prompt: impl Into, completions: Vec) -> Self { + pub fn new(prompt: impl Into, completions: Vec, mode: EditorMode) -> Self { let config = Config::builder() .completion_type(CompletionType::List) - .edit_mode(EditMode::Emacs) + .edit_mode(mode.rustyline_mode()) .build(); let mut editor = Editor::::with_config(config) .expect("rustyline editor should initialize"); @@ -201,7 +233,7 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> { #[cfg(test)] mod tests { - use super::{slash_command_prefix, LineEditor, SlashCommandHelper}; + use super::{slash_command_prefix, EditorMode, LineEditor, SlashCommandHelper}; use rustyline::completion::Completer; use rustyline::highlight::Highlighter; use rustyline::history::{DefaultHistory, History}; @@ -260,10 +292,28 @@ mod tests { #[test] fn push_history_ignores_blank_entries() { - let mut editor = LineEditor::new("> ", vec!["/help".to_string()]); + let mut editor = LineEditor::new("> ", vec!["/help".to_string()], EditorMode::Emacs); editor.push_history(" "); editor.push_history("/help"); assert_eq!(editor.editor.history().len(), 1); } + + #[test] + fn resolves_editor_mode_from_config_values() { + assert_eq!(EditorMode::from_config_value(Some("vim")), EditorMode::Vim); + assert_eq!( + EditorMode::from_config_value(Some("emacs")), + EditorMode::Emacs + ); + assert_eq!( + EditorMode::from_config_value(Some("default")), + EditorMode::Emacs + ); + assert_eq!( + EditorMode::from_config_value(Some("wat")), + EditorMode::Emacs + ); + assert_eq!(EditorMode::from_config_value(None), EditorMode::Emacs); + } } diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index 0abaad5..0b17553 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -16,7 +16,7 @@ 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, }; @@ -828,7 +828,7 @@ fn run_resume_command( match command { SlashCommand::Help => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(render_repl_help()), + message: Some(render_repl_help(resolve_editor_mode())), }), SlashCommand::Compact => { let result = runtime::compact_session( @@ -881,6 +881,7 @@ fn run_resume_command( estimated_tokens: 0, }, default_permission_mode().as_str(), + resolve_editor_mode().label(), &status_context(Some(session_path))?, )), }) @@ -960,28 +961,29 @@ fn run_repl( permission_mode: PermissionMode, ) -> Result<(), Box> { let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; - let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates()); + let mut editor = + input::LineEditor::new("> ", slash_command_completion_candidates(), cli.editor_mode); println!("{}", cli.startup_banner()); 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 => { @@ -1012,6 +1014,7 @@ struct LiveCli { model: String, allowed_tools: Option, permission_mode: PermissionMode, + editor_mode: input::EditorMode, system_prompt: Vec, runtime: ConversationRuntime, session: SessionHandle, @@ -1025,6 +1028,7 @@ impl LiveCli { permission_mode: PermissionMode, ) -> Result> { let system_prompt = build_system_prompt()?; + let editor_mode = resolve_editor_mode(); let session = create_managed_session_handle()?; let runtime = build_runtime( Session::new(), @@ -1040,6 +1044,7 @@ impl LiveCli { model, allowed_tools, permission_mode, + editor_mode, system_prompt, runtime, session, @@ -1060,14 +1065,16 @@ impl LiveCli { ██║ ██║ ███████║██║ █╗ ██║\n\ ██║ ██║ ██╔══██║██║███╗██║\n\ ╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ - ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ +╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ \x1b[2mModel\x1b[0m {}\n\ \x1b[2mPermissions\x1b[0m {}\n\ + \x1b[2mInput mode\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", + Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/exit\x1b[0m to quit · \x1b[2mShift+Enter\x1b[0m for newline", self.model, self.permission_mode.as_str(), + self.editor_mode.label(), cwd, self.session.id, ) @@ -1157,7 +1164,7 @@ impl LiveCli { ) -> Result> { Ok(match command { SlashCommand::Help => { - println!("{}", render_repl_help()); + println!("{}", render_repl_help(self.editor_mode)); false } SlashCommand::Status => { @@ -1243,7 +1250,7 @@ impl LiveCli { false } SlashCommand::Unknown(name) => { - eprintln!("unknown slash command: /{name}"); + println!("{}", render_unknown_repl_command(&name)); false } }) @@ -1269,6 +1276,7 @@ impl LiveCli { estimated_tokens: self.runtime.estimated_tokens(), }, self.permission_mode.as_str(), + self.editor_mode.label(), &status_context(Some(&self.session.path)).expect("status context should load"), ) ); @@ -1849,22 +1857,24 @@ fn render_session_list(active_session_id: &str) -> Result String { - [ +fn render_repl_help(editor_mode: input::EditorMode) -> String { + let mut lines = vec![ "REPL".to_string(), + format!(" Input mode {}", editor_mode.label()), " /exit Quit the REPL".to_string(), " /quit Quit the REPL".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(), - ] - .join( - " -", - ) + ]; + if editor_mode == input::EditorMode::Vim { + lines.push(" Esc Switch to normal mode".to_string()); + lines.push(" i / a Return to insert mode".to_string()); + } + lines.push(String::new()); + lines.push(render_slash_command_help()); + lines.join("\n") } fn status_context( @@ -1892,6 +1902,7 @@ fn format_status_report( model: &str, usage: StatusUsage, permission_mode: &str, + editor_mode: &str, context: &StatusContext, ) -> String { [ @@ -1899,6 +1910,7 @@ fn format_status_report( "Status Model {model} Permission mode {permission_mode} + Input mode {editor_mode} Messages {} Turns {} Estimated tokens {}", @@ -2037,8 +2049,7 @@ fn render_memory_report() -> Result> { 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()); @@ -2790,7 +2801,8 @@ fn build_runtime( allowed_tools: Option, permission_mode: PermissionMode, progress_reporter: Option, -) -> Result, Box> { +) -> Result, Box> +{ let (feature_config, tool_registry) = build_runtime_plugin_state()?; Ok(ConversationRuntime::new_with_features( session, @@ -3101,7 +3113,7 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec Vec { - slash_command_specs() + let mut candidates = slash_command_specs() .iter() .flat_map(|spec| { std::iter::once(spec.name) @@ -3109,9 +3121,90 @@ fn slash_command_completion_candidates() -> Vec { .map(|name| format!("/{name}")) .collect::>() }) + .collect::>(); + candidates.extend([String::from("/exit"), String::from("/quit")]); + candidates.sort(); + candidates.dedup(); + candidates +} + +fn resolve_editor_mode() -> input::EditorMode { + let cwd = match env::current_dir() { + Ok(cwd) => cwd, + Err(_) => return input::EditorMode::Emacs, + }; + let loader = ConfigLoader::default_for(cwd); + loader + .load() + .ok() + .map(|config| input::EditorMode::from_config_value(config.get_string("editorMode"))) + .unwrap_or(input::EditorMode::Emacs) +} + +fn render_unknown_repl_command(name: &str) -> String { + let suggestions = suggest_repl_commands(name); + let mut lines = vec![format!("Unknown slash command: /{name}")]; + if !suggestions.is_empty() { + lines.push(format!(" Did you mean {}?", suggestions.join(", "))); + } + lines.push(" Type /help to list available commands.".to_string()); + lines.join("\n") +} + +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())); @@ -3816,10 +3909,12 @@ 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 crate::input::EditorMode; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; @@ -4131,13 +4226,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("works with --resume SESSION.json")); + assert!(help.contains("available via claw --resume SESSION.json")); } #[test] fn repl_help_includes_shared_commands_and_exit() { - let help = render_repl_help(); + let help = render_repl_help(EditorMode::Emacs); assert!(help.contains("REPL")); + assert!(help.contains("Input mode emacs")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/model [model]")); @@ -4161,6 +4257,30 @@ mod tests { assert!(help.contains("/exit")); } + #[test] + fn repl_help_includes_vim_key_hints_in_vim_mode() { + let help = render_repl_help(EditorMode::Vim); + assert!(help.contains("Input mode vim")); + assert!(help.contains("Esc Switch to normal mode")); + assert!(help.contains("i / a Return to insert mode")); + } + + #[test] + fn completion_candidates_include_repl_exit_commands() { + let candidates = slash_command_completion_candidates(); + assert!(candidates.contains(&"/exit".to_string())); + assert!(candidates.contains(&"/quit".to_string())); + assert!(candidates.contains(&"/help".to_string())); + } + + #[test] + fn unknown_repl_command_reports_helpful_suggestions() { + let rendered = render_unknown_repl_command("statu"); + assert!(rendered.contains("Unknown slash command: /statu")); + assert!(rendered.contains("/status")); + assert!(rendered.contains("Type /help")); + } + #[test] fn resume_supported_command_list_matches_expected_surface() { let names = resume_supported_slash_commands() @@ -4283,6 +4403,7 @@ mod tests { estimated_tokens: 128, }, "workspace-write", + "vim", &super::StatusContext { cwd: PathBuf::from("/tmp/project"), session_path: Some(PathBuf::from("session.json")), @@ -4296,6 +4417,7 @@ mod tests { assert!(status.contains("Status")); assert!(status.contains("Model sonnet")); assert!(status.contains("Permission mode workspace-write")); + assert!(status.contains("Input mode vim")); assert!(status.contains("Messages 7")); assert!(status.contains("Latest total 10")); assert!(status.contains("Cumulative total 31")); @@ -4438,7 +4560,7 @@ mod tests { } #[test] fn repl_help_mentions_history_completion_and_multiline() { - let help = render_repl_help(); + let help = render_repl_help(EditorMode::Emacs); assert!(help.contains("Up/Down")); assert!(help.contains("Tab")); assert!(help.contains("Shift+Enter/Ctrl+J")); diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 6e01f71..5cfda67 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -389,34 +389,32 @@ 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(), + " [resume] = also available via claw --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), }; - let alias_suffix = if spec.aliases.is_empty() { - String::new() - } else { - format!( - " (aliases: {})", - spec.aliases - .iter() - .map(|alias| format!("/{alias}")) - .collect::>() - .join(", ") - ) - }; - let resume = if spec.resume_supported { - " [resume]" - } else { - "" - }; - lines.push(format!( - " {name:<20} {}{alias_suffix}{resume}", - spec.summary - )); + lines.push(format!(" {name}")); + lines.push(format!(" {}", spec.summary)); + if !spec.aliases.is_empty() || spec.resume_supported { + let mut details = Vec::new(); + if !spec.aliases.is_empty() { + details.push(format!( + "aliases: {}", + spec.aliases + .iter() + .map(|alias| format!("/{alias}")) + .collect::>() + .join(", ") + )); + } + if spec.resume_supported { + details.push("[resume]".to_string()); + } + lines.push(format!(" {}", details.join(" · "))); + } } lines.join("\n") } @@ -1413,7 +1411,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("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 11ec21d..68b9370 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -284,6 +284,11 @@ impl RuntimeConfig { self.merged.get(key) } + #[must_use] + pub fn get_string(&self, key: &str) -> Option<&str> { + self.get(key).and_then(JsonValue::as_str) + } + #[must_use] pub fn as_json(&self) -> JsonValue { JsonValue::Object(self.merged.clone())