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
This commit is contained in:
Yeachan-Heo
2026-04-02 07:19:14 +00:00
parent fd0a299e19
commit 8f737b13d2
3 changed files with 243 additions and 59 deletions

View File

@@ -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 |

View File

@@ -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<String>) -> 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<String>) {
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<String>) {
if let Some(helper) = self.editor.helper_mut() {
helper.set_completions(completions);
}
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
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<String>) -> Vec<String> {
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<_>>(),
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()]);
}
}

View File

@@ -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<dyn std::error::Error>> {
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<Vec<String>, Box<dyn std::error::Error>> {
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<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
let (id, message_count, parent_session_id, branch_name) = Session::load_from_path(&path)
.map(|session| {
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,
)
})
.unwrap_or_else(|_| {
(
let (id, message_count, parent_session_id, branch_name) =
match Session::load_from_path(&path) {
Ok(session) => {
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<Vec<ManagedSessionSummary>, Box<dyn std::er
0,
None,
None,
)
});
),
};
sessions.push(ManagedSessionSummary {
id,
path,
@@ -2143,7 +2155,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(),
@@ -3146,7 +3158,8 @@ fn build_runtime(
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> {
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
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<AuthSource, Box<dyn std::error::Error>> {
@@ -3515,16 +3527,78 @@ fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json
.collect()
}
fn slash_command_completion_candidates() -> Vec<String> {
slash_command_specs()
.iter()
.flat_map(|spec| {
std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
})
.collect()
fn slash_command_completion_candidates_with_sessions(
model: &str,
active_session_id: Option<&str>,
recent_session_ids: Vec<String>,
) -> Vec<String> {
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<Assistant
}
}
fn prompt_cache_record_to_runtime_event(record: api::PromptCacheRecord) -> Option<PromptCacheEvent> {
fn prompt_cache_record_to_runtime_event(
record: api::PromptCacheRecord,
) -> Option<PromptCacheEvent> {
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");