From bcaf6e077160dbc6cd1b5a4fc05ef0d70c08091a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 21:57:13 +0000 Subject: [PATCH] Bring slash-command UX closer to the TypeScript terminal UI Port the Rust REPL toward the TypeScript UI patterns by adding ranked slash command suggestions, canonical alias completion, trailing-space acceptance, argument hints, and clearer entry/help copy for discoverability. Constraint: Keep this worktree scoped to UI-only parity; discard unrelated plugin-loading edits Constraint: Rust terminal UI remains line-editor based, so the parity pass focuses on practical affordances instead of React modal surfaces Rejected: Rework the REPL into a full multi-pane typeahead overlay | too large for this UI-only parity slice Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep slash metadata and completion behavior aligned; new slash commands should update both descriptors and help text together Tested: cargo check; cargo test Not-tested: Interactive manual terminal pass in a live TTY --- rust/crates/claw-cli/src/input.rs | 285 +++++++++++++++++++++++++++--- rust/crates/claw-cli/src/main.rs | 99 ++++++++--- 2 files changed, 339 insertions(+), 45 deletions(-) diff --git a/rust/crates/claw-cli/src/input.rs b/rust/crates/claw-cli/src/input.rs index a718cd7..37b2ff5 100644 --- a/rust/crates/claw-cli/src/input.rs +++ b/rust/crates/claw-cli/src/input.rs @@ -6,6 +6,31 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifier use crossterm::queue; use crossterm::terminal::{self, Clear, ClearType}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlashCommandDescriptor { + pub command: String, + pub description: Option, + pub argument_hint: Option, + pub aliases: Vec, +} + +impl SlashCommandDescriptor { + #[allow(dead_code)] + #[must_use] + pub fn simple(command: impl Into) -> Self { + Self { + command: command.into(), + description: None, + argument_hint: None, + aliases: Vec::new(), + } + } + + fn triggers(&self) -> impl Iterator { + std::iter::once(self.command.as_str()).chain(self.aliases.iter().map(String::as_str)) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReadOutcome { Submit(String), @@ -178,14 +203,21 @@ impl EditSession { out: &mut impl Write, base_prompt: &str, vim_enabled: bool, + assist_lines: &[String], ) -> io::Result<()> { self.clear_render(out)?; let prompt = self.prompt(base_prompt, vim_enabled); let buffer = self.visible_buffer(); write!(out, "{prompt}{buffer}")?; + if !assist_lines.is_empty() { + for line in assist_lines { + write!(out, "\r\n{line}")?; + } + } - let (cursor_row, cursor_col, total_lines) = self.cursor_layout(prompt.as_ref()); + let (cursor_row, cursor_col, total_lines) = + self.cursor_layout(prompt.as_ref(), assist_lines.len()); let rows_to_move_up = total_lines.saturating_sub(cursor_row + 1); if rows_to_move_up > 0 { queue!(out, MoveUp(to_u16(rows_to_move_up)?))?; @@ -211,7 +243,7 @@ impl EditSession { writeln!(out) } - fn cursor_layout(&self, prompt: &str) -> (usize, usize, usize) { + fn cursor_layout(&self, prompt: &str, assist_line_count: usize) -> (usize, usize, usize) { let active_text = self.active_text(); let cursor = if self.mode == EditorMode::Command { self.command_cursor @@ -225,7 +257,8 @@ impl EditSession { Some((_, suffix)) => suffix.chars().count(), None => prompt.chars().count() + cursor_prefix.chars().count(), }; - let total_lines = active_text.bytes().filter(|byte| *byte == b'\n').count() + 1; + let total_lines = + active_text.bytes().filter(|byte| *byte == b'\n').count() + 1 + assist_line_count; (cursor_row, cursor_col, total_lines) } } @@ -240,7 +273,7 @@ enum KeyAction { pub struct LineEditor { prompt: String, - completions: Vec, + slash_commands: Vec, history: Vec, yank_buffer: YankBuffer, vim_enabled: bool, @@ -255,11 +288,24 @@ struct CompletionState { } impl LineEditor { + #[allow(dead_code)] #[must_use] pub fn new(prompt: impl Into, completions: Vec) -> Self { + let slash_commands = completions + .into_iter() + .map(SlashCommandDescriptor::simple) + .collect(); + Self::with_slash_commands(prompt, slash_commands) + } + + #[must_use] + pub fn with_slash_commands( + prompt: impl Into, + slash_commands: Vec, + ) -> Self { Self { prompt: prompt.into(), - completions, + slash_commands, history: Vec::new(), yank_buffer: YankBuffer::default(), vim_enabled: false, @@ -284,7 +330,12 @@ impl LineEditor { let _raw_mode = RawModeGuard::new()?; let mut stdout = io::stdout(); let mut session = EditSession::new(self.vim_enabled); - session.render(&mut stdout, &self.prompt, self.vim_enabled)?; + session.render( + &mut stdout, + &self.prompt, + self.vim_enabled, + &self.command_assist_lines(&session), + )?; loop { let Event::Key(key) = event::read()? else { @@ -296,7 +347,12 @@ impl LineEditor { match self.handle_key_event(&mut session, key) { KeyAction::Continue => { - session.render(&mut stdout, &self.prompt, self.vim_enabled)?; + session.render( + &mut stdout, + &self.prompt, + self.vim_enabled, + &self.command_assist_lines(&session), + )?; } KeyAction::Submit(line) => { session.finalize_render(&mut stdout, &self.prompt, self.vim_enabled)?; @@ -325,7 +381,12 @@ impl LineEditor { } )?; session = EditSession::new(self.vim_enabled); - session.render(&mut stdout, &self.prompt, self.vim_enabled)?; + session.render( + &mut stdout, + &self.prompt, + self.vim_enabled, + &self.command_assist_lines(&session), + )?; } } } @@ -699,25 +760,21 @@ impl LineEditor { state .matches .iter() - .any(|candidate| candidate == &session.text) + .any(|candidate| session.text == *candidate || session.text == format!("{candidate} ")) }) { 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(); + let replacement = completed_command(&candidate); + session.text.replace_range(..session.cursor, &replacement); + session.cursor = replacement.len(); return; } let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else { self.completion_state = None; return; }; - let matches = self - .completions - .iter() - .filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix) - .cloned() - .collect::>(); + let matches = self.matching_commands(prefix); if matches.is_empty() { self.completion_state = None; return; @@ -741,8 +798,111 @@ impl LineEditor { candidate }; - session.text.replace_range(..session.cursor, &candidate); - session.cursor = candidate.len(); + let replacement = completed_command(&candidate); + session.text.replace_range(..session.cursor, &replacement); + session.cursor = replacement.len(); + } + + fn matching_commands(&self, prefix: &str) -> Vec { + let normalized = prefix.to_ascii_lowercase(); + let mut ranked = self + .slash_commands + .iter() + .filter_map(|descriptor| { + let command = descriptor.command.clone(); + let mut best_rank = None::<(u8, usize)>; + for trigger in descriptor.triggers() { + let trigger_lower = trigger.to_ascii_lowercase(); + let rank = if trigger_lower == normalized { + if trigger == descriptor.command { + Some((0, trigger.len())) + } else { + Some((1, trigger.len())) + } + } else if trigger_lower.starts_with(&normalized) { + if trigger == descriptor.command { + Some((2, trigger.len())) + } else { + Some((3, trigger.len())) + } + } else if trigger_lower.contains(&normalized) { + Some((4, trigger.len())) + } else { + None + }; + if let Some(rank) = rank { + best_rank = Some(best_rank.map_or(rank, |current| current.min(rank))); + } + } + best_rank.map(|(bucket, len)| (bucket, len, command)) + }) + .collect::>(); + + ranked.sort_by(|left, right| left.cmp(right)); + ranked.dedup_by(|left, right| left.2 == right.2); + ranked.into_iter().map(|(_, _, command)| command).collect() + } + + fn command_assist_lines(&self, session: &EditSession) -> Vec { + if session.mode == EditorMode::Command || session.cursor != session.text.len() { + return Vec::new(); + } + + let input = session.text.as_str(); + if !input.starts_with('/') { + return Vec::new(); + } + + if let Some((command, args)) = command_and_args(input) { + if input.ends_with(' ') && args.is_empty() { + if let Some(descriptor) = self.find_command_descriptor(command) { + let mut lines = Vec::new(); + if let Some(argument_hint) = &descriptor.argument_hint { + lines.push(dimmed_line(format!("Arguments: {argument_hint}"))); + } + if let Some(description) = &descriptor.description { + lines.push(dimmed_line(description)); + } + if !lines.is_empty() { + return lines; + } + } + } + } + + if input.contains(char::is_whitespace) { + return Vec::new(); + } + + let matches = self.matching_commands(input); + if matches.is_empty() { + return Vec::new(); + } + + let mut lines = vec![dimmed_line("Suggestions")]; + lines.extend(matches.into_iter().take(3).map(|command| { + let description = self + .find_command_descriptor(command.trim_start_matches('/')) + .and_then(|descriptor| descriptor.description.as_deref()) + .unwrap_or_default(); + if description.is_empty() { + dimmed_line(format!(" {command}")) + } else { + dimmed_line(format!(" {command:<18} {description}")) + } + })); + lines + } + + fn find_command_descriptor(&self, name: &str) -> Option<&SlashCommandDescriptor> { + let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase(); + self.slash_commands.iter().find(|descriptor| { + descriptor.command.trim_start_matches('/').eq_ignore_ascii_case(&normalized) + || descriptor + .aliases + .iter() + .any(|alias| alias.trim_start_matches('/').eq_ignore_ascii_case(&normalized)) + }) } fn history_up(&self, session: &mut EditSession) { @@ -964,6 +1124,27 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> { Some(prefix) } +fn command_and_args(input: &str) -> Option<(&str, &str)> { + let trimmed = input.trim_start(); + let without_slash = trimmed.strip_prefix('/')?; + let (command, args) = without_slash + .split_once(' ') + .map_or((without_slash, ""), |(command, args)| (command, args)); + Some((command, args)) +} + +fn completed_command(command: &str) -> String { + if command.ends_with(' ') { + command.to_string() + } else { + format!("{command} ") + } +} + +fn dimmed_line(text: impl AsRef) -> String { + format!("\x1b[2m{}\x1b[0m", text.as_ref()) +} + fn to_u16(value: usize) -> io::Result { u16::try_from(value).map_err(|_| { io::Error::new( @@ -977,6 +1158,7 @@ fn to_u16(value: usize) -> io::Result { mod tests { use super::{ selection_bounds, slash_command_prefix, EditSession, EditorMode, KeyAction, LineEditor, + SlashCommandDescriptor, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -1148,8 +1330,8 @@ mod tests { editor.complete_slash_command(&mut session); // then - assert_eq!(session.text, "/help"); - assert_eq!(session.cursor, 5); + assert_eq!(session.text, "/help "); + assert_eq!(session.cursor, 6); } #[test] @@ -1171,8 +1353,65 @@ mod tests { let second = session.text.clone(); // then - assert_eq!(first, "/permissions"); - assert_eq!(second, "/plugin"); + assert_eq!(first, "/plugin "); + assert_eq!(second, "/permissions "); + } + + #[test] + fn tab_completion_prefers_canonical_command_over_alias() { + let mut editor = LineEditor::with_slash_commands( + "> ", + vec![SlashCommandDescriptor { + command: "/plugin".to_string(), + description: Some("Manage plugins".to_string()), + argument_hint: Some("[list]".to_string()), + aliases: vec!["/plugins".to_string(), "/marketplace".to_string()], + }], + ); + let mut session = EditSession::new(false); + session.text = "/plugins".to_string(); + session.cursor = session.text.len(); + + editor.complete_slash_command(&mut session); + + assert_eq!(session.text, "/plugin "); + } + + #[test] + fn command_assist_lines_show_suggestions_and_argument_hints() { + let editor = LineEditor::with_slash_commands( + "> ", + vec![ + SlashCommandDescriptor { + command: "/help".to_string(), + description: Some("Show help and available commands".to_string()), + argument_hint: None, + aliases: Vec::new(), + }, + SlashCommandDescriptor { + command: "/model".to_string(), + description: Some("Show or switch the active model".to_string()), + argument_hint: Some("[model]".to_string()), + aliases: Vec::new(), + }, + ], + ); + + let mut prefix_session = EditSession::new(false); + prefix_session.text = "/h".to_string(); + prefix_session.cursor = prefix_session.text.len(); + let prefix_lines = editor.command_assist_lines(&prefix_session); + assert!(prefix_lines.iter().any(|line| line.contains("Suggestions"))); + assert!(prefix_lines.iter().any(|line| line.contains("/help"))); + + let mut hint_session = EditSession::new(false); + hint_session.text = "/model ".to_string(); + hint_session.cursor = hint_session.text.len(); + let hint_lines = editor.command_assist_lines(&hint_session); + assert!(hint_lines.iter().any(|line| line.contains("Arguments: [model]"))); + assert!(hint_lines + .iter() + .any(|line| line.contains("Show or switch the active model"))); } #[test] diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index 2b7d6f1..b95447a 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -28,6 +28,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; +use input::SlashCommandDescriptor; use plugins::{PluginManager, PluginManagerConfig}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ @@ -1009,7 +1010,7 @@ 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::with_slash_commands("> ", slash_command_descriptors()); println!("{}", cli.startup_banner()); loop { @@ -1141,13 +1142,14 @@ impl LiveCli { format!( " Quick start {}", if has_claw_md { - "/help · /status · ask for a task" + "Type / to browse commands · /help for shortcuts · ask for a task" } else { - "/init · /help · /status" + "/init · then type / to browse commands" } ), - " Editor Tab completes slash commands · /vim toggles modal editing" + " Autocomplete Type / for command suggestions · Tab accepts or cycles" .to_string(), + " Editor /vim toggles modal editing · Esc clears menus first".to_string(), " Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(), ]; if !has_claw_md { @@ -1973,14 +1975,15 @@ fn render_session_list(active_session_id: &str) -> Result String { [ "Interactive REPL".to_string(), - " Quick start Ask a task in plain English or use one of the core commands below." + " Quick start Ask a task in plain English, or type / to browse slash commands." .to_string(), " Core commands /help · /status · /model · /permissions · /compact".to_string(), " Exit /exit or /quit".to_string(), + " Autocomplete Type / for suggestions · Tab accepts or cycles matches".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(), + " Cancel Esc dismisses menus first · Ctrl-C clears input (or exits on empty)" + .to_string(), " Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(), String::new(), render_slash_command_help(), @@ -3283,21 +3286,44 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec Vec { - let mut candidates = slash_command_specs() +fn slash_command_descriptors() -> Vec { + let mut descriptors = slash_command_specs() .iter() - .flat_map(|spec| { - std::iter::once(spec.name) - .chain(spec.aliases.iter().copied()) - .map(|name| format!("/{name}")) + .map(|spec| SlashCommandDescriptor { + command: format!("/{}", spec.name), + description: Some(spec.summary.to_string()), + argument_hint: spec.argument_hint.map(ToOwned::to_owned), + aliases: spec.aliases.iter().map(|alias| format!("/{alias}")).collect(), + }) + .collect::>(); + descriptors.extend([ + SlashCommandDescriptor { + command: "/vim".to_string(), + description: Some("Toggle modal editing".to_string()), + argument_hint: None, + aliases: Vec::new(), + }, + SlashCommandDescriptor { + command: "/exit".to_string(), + description: Some("Exit the interactive REPL".to_string()), + argument_hint: None, + aliases: vec!["/quit".to_string()], + }, + ]); + descriptors.sort_by(|left, right| left.command.cmp(&right.command)); + descriptors.dedup_by(|left, right| left.command == right.command); + descriptors +} + +fn slash_command_completion_candidates() -> Vec { + let mut candidates = slash_command_descriptors() + .into_iter() + .flat_map(|descriptor| { + std::iter::once(descriptor.command) + .chain(descriptor.aliases) .collect::>() }) .collect::>(); - candidates.extend([ - String::from("/vim"), - String::from("/exit"), - String::from("/quit"), - ]); candidates.sort(); candidates.dedup(); candidates @@ -3986,6 +4012,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " /help Browse the full slash command map" )?; + writeln!( + out, + " / Open slash suggestions in the REPL" + )?; writeln!( out, " /status Inspect session + workspace state" @@ -4000,7 +4030,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!( out, - " Tab Complete slash commands" + " Tab Accept or cycle slash command suggestions" )?; writeln!( out, @@ -4115,7 +4145,8 @@ mod tests { 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, render_unknown_repl_command, resolve_model_alias, response_to_events, - resume_supported_slash_commands, slash_command_completion_candidates, status_context, + resume_supported_slash_commands, slash_command_completion_candidates, + slash_command_descriptors, status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, }; @@ -4439,6 +4470,7 @@ mod tests { fn repl_help_includes_shared_commands_and_exit() { let help = render_repl_help(); assert!(help.contains("Interactive REPL")); + assert!(help.contains("type / to browse slash commands")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/model [model]")); @@ -4460,7 +4492,8 @@ mod tests { assert!(help.contains("/agents")); assert!(help.contains("/skills")); assert!(help.contains("/exit")); - assert!(help.contains("Tab cycles slash command matches")); + assert!(help.contains("Type / for suggestions")); + assert!(help.contains("Tab accepts or cycles matches")); } #[test] @@ -4472,6 +4505,27 @@ mod tests { assert!(candidates.contains(&"/quit".to_string())); } + #[test] + fn slash_command_descriptors_include_descriptions_and_aliases() { + let descriptors = slash_command_descriptors(); + let plugin = descriptors + .iter() + .find(|descriptor| descriptor.command == "/plugin") + .expect("plugin descriptor should exist"); + assert_eq!( + plugin.description.as_deref(), + Some("Manage Claw Code plugins") + ); + assert!(plugin.aliases.contains(&"/plugins".to_string())); + assert!(plugin.aliases.contains(&"/marketplace".to_string())); + + let exit = descriptors + .iter() + .find(|descriptor| descriptor.command == "/exit") + .expect("exit descriptor should exist"); + assert!(exit.aliases.contains(&"/quit".to_string())); + } + #[test] fn unknown_repl_command_suggestions_include_repl_shortcuts() { let rendered = render_unknown_repl_command("exi"); @@ -4559,6 +4613,7 @@ mod tests { print_help_to(&mut help).expect("help should render"); let help = String::from_utf8(help).expect("help should be utf8"); assert!(help.contains("claw init")); + assert!(help.contains("Open slash suggestions in the REPL")); assert!(help.contains("claw agents")); assert!(help.contains("claw skills")); assert!(help.contains("claw /skills")); @@ -4762,7 +4817,7 @@ mod tests { fn repl_help_mentions_history_completion_and_multiline() { let help = render_repl_help(); assert!(help.contains("Up/Down")); - assert!(help.contains("Tab cycles")); + assert!(help.contains("Tab accepts or cycles")); assert!(help.contains("Shift+Enter or Ctrl+J")); }