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())