diff --git a/rust/README.md b/rust/README.md index eff924b..2d7925a 100644 --- a/rust/README.md +++ b/rust/README.md @@ -96,6 +96,8 @@ Commands: ## Slash Commands (REPL) +Tab completion now expands not just slash command names, but also common workflow arguments like model aliases, permission modes, and recent session IDs. + | Command | Description | |---------|-------------| | `/help` | Show help | diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index 1cf6029..b0664da 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; use std::cell::RefCell; +use std::collections::BTreeSet; use std::io::{self, IsTerminal, Write}; use rustyline::completion::{Completer, Pair}; @@ -27,7 +28,7 @@ struct SlashCommandHelper { impl SlashCommandHelper { fn new(completions: Vec) -> Self { Self { - completions, + completions: normalize_completions(completions), current_line: RefCell::new(String::new()), } } @@ -45,6 +46,10 @@ impl SlashCommandHelper { current.clear(); current.push_str(line); } + + fn set_completions(&mut self, completions: Vec) { + self.completions = normalize_completions(completions); + } } impl Completer for SlashCommandHelper { @@ -126,6 +131,12 @@ impl LineEditor { let _ = self.editor.add_history_entry(entry); } + pub fn set_completions(&mut self, completions: Vec) { + if let Some(helper) = self.editor.helper_mut() { + helper.set_completions(completions); + } + } + pub fn read_line(&mut self) -> io::Result { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return self.read_line_fallback(); @@ -192,13 +203,22 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> { } let prefix = &line[..pos]; - if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') { + if !prefix.starts_with('/') { return None; } Some(prefix) } +fn normalize_completions(completions: Vec) -> Vec { + let mut seen = BTreeSet::new(); + completions + .into_iter() + .filter(|candidate| candidate.starts_with('/')) + .filter(|candidate| seen.insert(candidate.clone())) + .collect() +} + #[cfg(test)] mod tests { use super::{slash_command_prefix, LineEditor, SlashCommandHelper}; @@ -208,9 +228,13 @@ mod tests { use rustyline::Context; #[test] - fn extracts_only_terminal_slash_command_prefixes() { + fn extracts_terminal_slash_command_prefixes_with_arguments() { assert_eq!(slash_command_prefix("/he", 3), Some("/he")); - assert_eq!(slash_command_prefix("/help me", 5), None); + assert_eq!(slash_command_prefix("/help me", 8), Some("/help me")); + assert_eq!( + slash_command_prefix("/session switch ses", 19), + Some("/session switch ses") + ); assert_eq!(slash_command_prefix("hello", 5), None); assert_eq!(slash_command_prefix("/help", 2), None); } @@ -238,6 +262,30 @@ mod tests { ); } + #[test] + fn completes_matching_slash_command_arguments() { + let helper = SlashCommandHelper::new(vec![ + "/model".to_string(), + "/model opus".to_string(), + "/model sonnet".to_string(), + "/session switch alpha".to_string(), + ]); + let history = DefaultHistory::new(); + let ctx = Context::new(&history); + let (start, matches) = helper + .complete("/model o", 8, &ctx) + .expect("completion should work"); + + assert_eq!(start, 0); + assert_eq!( + matches + .into_iter() + .map(|candidate| candidate.replacement) + .collect::>(), + vec!["/model opus".to_string()] + ); + } + #[test] fn ignores_non_slash_command_completion_requests() { let helper = SlashCommandHelper::new(vec!["/help".to_string()]); @@ -266,4 +314,17 @@ mod tests { assert_eq!(editor.editor.history().len(), 1); } + + #[test] + fn set_completions_replaces_and_normalizes_candidates() { + let mut editor = LineEditor::new("> ", vec!["/help".to_string()]); + editor.set_completions(vec![ + "/model opus".to_string(), + "/model opus".to_string(), + "status".to_string(), + ]); + + let helper = editor.editor.helper().expect("helper should exist"); + assert_eq!(helper.completions, vec!["/model opus".to_string()]); + } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 17d795a..5120571 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1128,10 +1128,12 @@ 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("> ", cli.repl_completion_candidates().unwrap_or_default()); println!("{}", cli.startup_banner()); loop { + editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); match editor.read_line()? { input::ReadOutcome::Submit(input) => { let trimmed = input.trim().to_string(); @@ -1303,7 +1305,7 @@ impl LiveCli { \x1b[2mWorkspace\x1b[0m {}\n\ \x1b[2mDirectory\x1b[0m {}\n\ \x1b[2mSession\x1b[0m {}\n\n\ - Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mShift+Enter\x1b[0m for newline", + Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", self.model, self.permission_mode.as_str(), git_branch, @@ -1313,6 +1315,17 @@ impl LiveCli { ) } + fn repl_completion_candidates(&self) -> Result, Box> { + Ok(slash_command_completion_candidates_with_sessions( + &self.model, + Some(&self.session.id), + list_managed_sessions()? + .into_iter() + .map(|session| session.id) + .collect(), + )) + } + fn prepare_turn_runtime( &self, emit_output: bool, @@ -2168,20 +2181,9 @@ fn list_managed_sessions() -> Result, Box { let parent_session_id = session .fork .as_ref() @@ -2196,8 +2198,17 @@ fn list_managed_sessions() -> Result, Box ( + path.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(), + 0, + None, + None, + ), + }; sessions.push(ManagedSessionSummary { id, path, @@ -2256,7 +2267,7 @@ fn render_repl_help() -> String { " /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(), + " Tab Complete commands, modes, and recent sessions".to_string(), " Ctrl-C Clear input (or exit on empty prompt)".to_string(), " Shift+Enter/Ctrl+J Insert a newline".to_string(), String::new(), @@ -3705,16 +3716,78 @@ fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec Vec { - slash_command_specs() - .iter() - .flat_map(|spec| { - std::iter::once(spec.name) - .chain(spec.aliases.iter().copied()) - .map(|name| format!("/{name}")) - .collect::>() - }) - .collect() +fn slash_command_completion_candidates_with_sessions( + model: &str, + active_session_id: Option<&str>, + recent_session_ids: Vec, +) -> Vec { + let mut completions = BTreeSet::new(); + + for spec in slash_command_specs() { + completions.insert(format!("/{}", spec.name)); + for alias in spec.aliases { + completions.insert(format!("/{alias}")); + } + } + + for candidate in [ + "/bughunter ", + "/clear --confirm", + "/config ", + "/config env", + "/config hooks", + "/config model", + "/config plugins", + "/export ", + "/issue ", + "/model ", + "/model opus", + "/model sonnet", + "/model haiku", + "/permissions ", + "/permissions read-only", + "/permissions workspace-write", + "/permissions danger-full-access", + "/plugin list", + "/plugin install ", + "/plugin enable ", + "/plugin disable ", + "/plugin uninstall ", + "/plugin update ", + "/plugins list", + "/pr ", + "/resume ", + "/session list", + "/session switch ", + "/session fork ", + "/teleport ", + "/ultraplan ", + "/agents help", + "/skills help", + ] { + completions.insert(candidate.to_string()); + } + + if !model.trim().is_empty() { + completions.insert(format!("/model {}", resolve_model_alias(model))); + completions.insert(format!("/model {model}")); + } + + if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { + completions.insert(format!("/resume {active_session_id}")); + completions.insert(format!("/session switch {active_session_id}")); + } + + for session_id in recent_session_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + .take(10) + { + completions.insert(format!("/resume {session_id}")); + completions.insert(format!("/session switch {session_id}")); + } + + completions.into_iter().collect() } fn format_tool_call_start(name: &str, input: &str) -> String { @@ -4447,9 +4520,10 @@ mod tests { parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy, print_help_to, push_output_block, render_config_report, render_diff_report, render_memory_report, render_repl_help, resolve_model_alias, resolve_session_reference, - response_to_events, resume_supported_slash_commands, run_resume_command, status_context, - CliAction, CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent, - InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, + response_to_events, resume_supported_slash_commands, run_resume_command, + slash_command_completion_candidates_with_sessions, status_context, CliAction, + CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent, + InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; @@ -4824,6 +4898,7 @@ mod tests { let help = render_repl_help(); assert!(help.contains("REPL")); assert!(help.contains("/help")); + assert!(help.contains("Complete commands, modes, and recent sessions")); assert!(help.contains("/status")); assert!(help.contains("/sandbox")); assert!(help.contains("/model [model]")); @@ -4847,6 +4922,45 @@ mod tests { assert!(help.contains("/exit")); } + #[test] + fn completion_candidates_include_workflow_shortcuts_and_dynamic_sessions() { + let completions = slash_command_completion_candidates_with_sessions( + "sonnet", + Some("session-current"), + vec!["session-old".to_string()], + ); + + assert!(completions.contains(&"/model claude-sonnet-4-6".to_string())); + assert!(completions.contains(&"/permissions workspace-write".to_string())); + assert!(completions.contains(&"/session list".to_string())); + assert!(completions.contains(&"/session switch session-current".to_string())); + assert!(completions.contains(&"/resume session-old".to_string())); + assert!(completions.contains(&"/ultraplan ".to_string())); + } + + #[test] + fn startup_banner_mentions_workflow_completions() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + + let banner = with_current_dir(&root, || { + LiveCli::new( + "claude-sonnet-4-6".to_string(), + true, + None, + PermissionMode::DangerFullAccess, + ) + .expect("cli should initialize") + .startup_banner() + }); + + assert!(banner.contains("Tab")); + assert!(banner.contains("workflow completions")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn resume_supported_command_list_matches_expected_surface() { let names = resume_supported_slash_commands()