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 4f8362a..22d6ac9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -30,12 +30,11 @@ use plugins::{PluginManager, PluginManagerConfig}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, - parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, - ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, - ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, PromptCacheEvent, - OAuthAuthorizationRequest, OAuthConfig, - OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, - Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, ApiClient, + ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, + ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, + OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, + RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::GlobalToolRegistry; @@ -1036,10 +1035,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(); @@ -1200,7 +1201,7 @@ impl LiveCli { \x1b[2mPermissions\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[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", self.model, self.permission_mode.as_str(), cwd, @@ -1208,6 +1209,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, @@ -2057,25 +2069,25 @@ fn list_managed_sessions() -> Result, Box { + let parent_session_id = session + .fork + .as_ref() + .map(|fork| fork.parent_session_id.clone()); + let branch_name = session + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()); + ( + session.session_id, + session.messages.len(), + parent_session_id, + branch_name, + ) + } + Err(_) => ( path.file_stem() .and_then(|value| value.to_str()) .unwrap_or("unknown") @@ -2083,8 +2095,8 @@ fn list_managed_sessions() -> Result, Box 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(), @@ -3146,7 +3158,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()?; let mut runtime = ConversationRuntime::new_with_features( session, @@ -3286,7 +3299,6 @@ impl AnthropicRuntimeClient { progress_reporter, }) } - } fn resolve_cli_auth_source() -> Result> { @@ -3515,16 +3527,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 { @@ -4023,7 +4097,9 @@ fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec Option { +fn prompt_cache_record_to_runtime_event( + record: api::PromptCacheRecord, +) -> Option { let cache_break = record.cache_break?; Some(PromptCacheEvent { unexpected: cache_break.unexpected, @@ -4245,18 +4321,18 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report, - format_internal_prompt_progress_line, format_model_report, format_model_switch_report, - format_permissions_report, + create_managed_session_handle, describe_tool_progress, filter_tool_specs, + format_compact_report, format_cost_report, format_internal_prompt_progress_line, + format_model_report, format_model_switch_report, format_permissions_report, format_permissions_switch_report, format_resume_report, format_status_report, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, - parse_git_status_branch, parse_git_status_metadata_for, permission_policy, - print_help_to, push_output_block, render_config_report, render_diff_report, - render_memory_report, render_repl_help, resolve_model_alias, response_to_events, - resume_supported_slash_commands, run_resume_command, status_context, CliAction, - CliOutputFormat, InternalPromptProgressEvent, - InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, - create_managed_session_handle, resolve_session_reference, + parse_git_status_branch, parse_git_status_metadata_for, 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, + slash_command_completion_candidates_with_sessions, status_context, CliAction, + CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, + SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; @@ -4622,6 +4698,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]")); @@ -4645,6 +4722,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() @@ -5051,8 +5167,13 @@ mod tests { let resolved = resolve_session_reference("legacy").expect("legacy session should resolve"); assert_eq!( - resolved.path.canonicalize().expect("resolved path should exist"), - legacy_path.canonicalize().expect("legacy path should exist") + resolved + .path + .canonicalize() + .expect("resolved path should exist"), + legacy_path + .canonicalize() + .expect("legacy path should exist") ); std::env::set_current_dir(previous).expect("restore cwd");