merge: omx-issue-9102-opencode-ux-compare into main

# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
Yeachan-Heo
2026-04-02 08:23:21 +00:00
3 changed files with 213 additions and 36 deletions

View File

@@ -96,6 +96,8 @@ Commands:
## Slash Commands (REPL) ## 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 | | Command | Description |
|---------|-------------| |---------|-------------|
| `/help` | Show help | | `/help` | Show help |

View File

@@ -1,5 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::BTreeSet;
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use rustyline::completion::{Completer, Pair}; use rustyline::completion::{Completer, Pair};
@@ -27,7 +28,7 @@ struct SlashCommandHelper {
impl SlashCommandHelper { impl SlashCommandHelper {
fn new(completions: Vec<String>) -> Self { fn new(completions: Vec<String>) -> Self {
Self { Self {
completions, completions: normalize_completions(completions),
current_line: RefCell::new(String::new()), current_line: RefCell::new(String::new()),
} }
} }
@@ -45,6 +46,10 @@ impl SlashCommandHelper {
current.clear(); current.clear();
current.push_str(line); current.push_str(line);
} }
fn set_completions(&mut self, completions: Vec<String>) {
self.completions = normalize_completions(completions);
}
} }
impl Completer for SlashCommandHelper { impl Completer for SlashCommandHelper {
@@ -126,6 +131,12 @@ impl LineEditor {
let _ = self.editor.add_history_entry(entry); 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> { pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() { if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return self.read_line_fallback(); return self.read_line_fallback();
@@ -192,13 +203,22 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
} }
let prefix = &line[..pos]; let prefix = &line[..pos];
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') { if !prefix.starts_with('/') {
return None; return None;
} }
Some(prefix) 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)] #[cfg(test)]
mod tests { mod tests {
use super::{slash_command_prefix, LineEditor, SlashCommandHelper}; use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
@@ -208,9 +228,13 @@ mod tests {
use rustyline::Context; use rustyline::Context;
#[test] #[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("/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("hello", 5), None);
assert_eq!(slash_command_prefix("/help", 2), 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] #[test]
fn ignores_non_slash_command_completion_requests() { fn ignores_non_slash_command_completion_requests() {
let helper = SlashCommandHelper::new(vec!["/help".to_string()]); let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
@@ -266,4 +314,17 @@ mod tests {
assert_eq!(editor.editor.history().len(), 1); 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

@@ -1128,10 +1128,12 @@ fn run_repl(
permission_mode: PermissionMode, permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; 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()); println!("{}", cli.startup_banner());
loop { loop {
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
match editor.read_line()? { match editor.read_line()? {
input::ReadOutcome::Submit(input) => { input::ReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string(); let trimmed = input.trim().to_string();
@@ -1303,7 +1305,7 @@ impl LiveCli {
\x1b[2mWorkspace\x1b[0m {}\n\ \x1b[2mWorkspace\x1b[0m {}\n\
\x1b[2mDirectory\x1b[0m {}\n\ \x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\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.model,
self.permission_mode.as_str(), self.permission_mode.as_str(),
git_branch, git_branch,
@@ -1313,6 +1315,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( fn prepare_turn_runtime(
&self, &self,
emit_output: bool, emit_output: bool,
@@ -2168,20 +2181,9 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or_default(); .unwrap_or_default();
let (id, message_count, parent_session_id, branch_name) = Session::load_from_path(&path) let (id, message_count, parent_session_id, branch_name) =
.map_or_else( match Session::load_from_path(&path) {
|_| { Ok(session) => {
(
path.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
0,
None,
None,
)
},
|session| {
let parent_session_id = session let parent_session_id = session
.fork .fork
.as_ref() .as_ref()
@@ -2196,8 +2198,17 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
parent_session_id, parent_session_id,
branch_name, branch_name,
) )
}, }
); Err(_) => (
path.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
0,
None,
None,
),
};
sessions.push(ManagedSessionSummary { sessions.push(ManagedSessionSummary {
id, id,
path, path,
@@ -2256,7 +2267,7 @@ fn render_repl_help() -> String {
" /exit Quit the REPL".to_string(), " /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(), " /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".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(), " Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(), " Shift+Enter/Ctrl+J Insert a newline".to_string(),
String::new(), String::new(),
@@ -3705,16 +3716,78 @@ fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json
.collect() .collect()
} }
fn slash_command_completion_candidates() -> Vec<String> { fn slash_command_completion_candidates_with_sessions(
slash_command_specs() model: &str,
.iter() active_session_id: Option<&str>,
.flat_map(|spec| { recent_session_ids: Vec<String>,
std::iter::once(spec.name) ) -> Vec<String> {
.chain(spec.aliases.iter().copied()) let mut completions = BTreeSet::new();
.map(|name| format!("/{name}"))
.collect::<Vec<_>>() for spec in slash_command_specs() {
}) completions.insert(format!("/{}", spec.name));
.collect() 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 { 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, parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
print_help_to, push_output_block, render_config_report, render_diff_report, print_help_to, push_output_block, render_config_report, render_diff_report,
render_memory_report, render_repl_help, resolve_model_alias, resolve_session_reference, render_memory_report, render_repl_help, resolve_model_alias, resolve_session_reference,
response_to_events, resume_supported_slash_commands, run_resume_command, status_context, response_to_events, resume_supported_slash_commands, run_resume_command,
CliAction, CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent, slash_command_completion_candidates_with_sessions, status_context, CliAction,
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent,
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use api::{MessageResponse, OutputContentBlock, Usage}; use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
@@ -4824,6 +4898,7 @@ mod tests {
let help = render_repl_help(); let help = render_repl_help();
assert!(help.contains("REPL")); assert!(help.contains("REPL"));
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("Complete commands, modes, and recent sessions"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/sandbox")); assert!(help.contains("/sandbox"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
@@ -4847,6 +4922,45 @@ mod tests {
assert!(help.contains("/exit")); 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] #[test]
fn resume_supported_command_list_matches_expected_surface() { fn resume_supported_command_list_matches_expected_surface() {
let names = resume_supported_slash_commands() let names = resume_supported_slash_commands()