From 8f737b13d2b6e441b0f707e68c89886192d4a69d Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 2 Apr 2026 07:19:14 +0000 Subject: [PATCH] Reduce REPL overhead for orchestration-heavy workflows Claw already exposes useful orchestration primitives such as session forking, resume, ultraplan, agents, and skills, but compared with OmO/OMX they were still high-friction to discover and re-type during live operator loops. This change makes the REPL act more like an orchestration console by refreshing context-aware tab completions before each prompt, allowing completion after slash-command arguments, and surfacing common workflow paths such as model aliases, permission modes, and recent session IDs. The startup banner and REPL help now advertise that guidance so the capability is visible instead of hidden. Constraint: Keep the improvement low-risk and REPL-local without adding dependencies or new command semantics Rejected: Add a brand new orchestration slash command | higher UX surface area and more docs burden than a discoverability fix Rejected: Implement a persistent HUD/status bar first | higher implementation risk than improving existing command ergonomics Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep dynamic completion candidates aligned with slash-command behavior and session management semantics Tested: cargo test -p rusty-claude-cli Not-tested: Interactive TTY tab-completion behavior in a live terminal session; full clippy remains blocked by pre-existing runtime crate lints --- rust/README.md | 2 + rust/crates/rusty-claude-cli/src/input.rs | 69 ++++++- rust/crates/rusty-claude-cli/src/main.rs | 231 ++++++++++++++++------ 3 files changed, 243 insertions(+), 59 deletions(-) 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");