mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
merge: omx-issue-9102-opencode-ux-compare into main
# Conflicts: # rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user