mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 23:54:50 +08:00
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
331 lines
9.5 KiB
Rust
331 lines
9.5 KiB
Rust
use std::borrow::Cow;
|
|
use std::cell::RefCell;
|
|
use std::collections::BTreeSet;
|
|
use std::io::{self, IsTerminal, Write};
|
|
|
|
use rustyline::completion::{Completer, Pair};
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::highlight::{CmdKind, Highlighter};
|
|
use rustyline::hint::Hinter;
|
|
use rustyline::history::DefaultHistory;
|
|
use rustyline::validate::Validator;
|
|
use rustyline::{
|
|
Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ReadOutcome {
|
|
Submit(String),
|
|
Cancel,
|
|
Exit,
|
|
}
|
|
|
|
struct SlashCommandHelper {
|
|
completions: Vec<String>,
|
|
current_line: RefCell<String>,
|
|
}
|
|
|
|
impl SlashCommandHelper {
|
|
fn new(completions: Vec<String>) -> Self {
|
|
Self {
|
|
completions: normalize_completions(completions),
|
|
current_line: RefCell::new(String::new()),
|
|
}
|
|
}
|
|
|
|
fn reset_current_line(&self) {
|
|
self.current_line.borrow_mut().clear();
|
|
}
|
|
|
|
fn current_line(&self) -> String {
|
|
self.current_line.borrow().clone()
|
|
}
|
|
|
|
fn set_current_line(&self, line: &str) {
|
|
let mut current = self.current_line.borrow_mut();
|
|
current.clear();
|
|
current.push_str(line);
|
|
}
|
|
|
|
fn set_completions(&mut self, completions: Vec<String>) {
|
|
self.completions = normalize_completions(completions);
|
|
}
|
|
}
|
|
|
|
impl Completer for SlashCommandHelper {
|
|
type Candidate = Pair;
|
|
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
pos: usize,
|
|
_ctx: &Context<'_>,
|
|
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
|
|
let Some(prefix) = slash_command_prefix(line, pos) else {
|
|
return Ok((0, Vec::new()));
|
|
};
|
|
|
|
let matches = self
|
|
.completions
|
|
.iter()
|
|
.filter(|candidate| candidate.starts_with(prefix))
|
|
.map(|candidate| Pair {
|
|
display: candidate.clone(),
|
|
replacement: candidate.clone(),
|
|
})
|
|
.collect();
|
|
|
|
Ok((0, matches))
|
|
}
|
|
}
|
|
|
|
impl Hinter for SlashCommandHelper {
|
|
type Hint = String;
|
|
}
|
|
|
|
impl Highlighter for SlashCommandHelper {
|
|
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
|
self.set_current_line(line);
|
|
Cow::Borrowed(line)
|
|
}
|
|
|
|
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
|
self.set_current_line(line);
|
|
false
|
|
}
|
|
}
|
|
|
|
impl Validator for SlashCommandHelper {}
|
|
impl Helper for SlashCommandHelper {}
|
|
|
|
pub struct LineEditor {
|
|
prompt: String,
|
|
editor: Editor<SlashCommandHelper, DefaultHistory>,
|
|
}
|
|
|
|
impl LineEditor {
|
|
#[must_use]
|
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
|
let config = Config::builder()
|
|
.completion_type(CompletionType::List)
|
|
.edit_mode(EditMode::Emacs)
|
|
.build();
|
|
let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
|
|
.expect("rustyline editor should initialize");
|
|
editor.set_helper(Some(SlashCommandHelper::new(completions)));
|
|
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
|
|
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
|
|
|
|
Self {
|
|
prompt: prompt.into(),
|
|
editor,
|
|
}
|
|
}
|
|
|
|
pub fn push_history(&mut self, entry: impl Into<String>) {
|
|
let entry = entry.into();
|
|
if entry.trim().is_empty() {
|
|
return;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
if let Some(helper) = self.editor.helper_mut() {
|
|
helper.reset_current_line();
|
|
}
|
|
|
|
match self.editor.readline(&self.prompt) {
|
|
Ok(line) => Ok(ReadOutcome::Submit(line)),
|
|
Err(ReadlineError::Interrupted) => {
|
|
let has_input = !self.current_line().is_empty();
|
|
self.finish_interrupted_read()?;
|
|
if has_input {
|
|
Ok(ReadOutcome::Cancel)
|
|
} else {
|
|
Ok(ReadOutcome::Exit)
|
|
}
|
|
}
|
|
Err(ReadlineError::Eof) => {
|
|
self.finish_interrupted_read()?;
|
|
Ok(ReadOutcome::Exit)
|
|
}
|
|
Err(error) => Err(io::Error::other(error)),
|
|
}
|
|
}
|
|
|
|
fn current_line(&self) -> String {
|
|
self.editor
|
|
.helper()
|
|
.map_or_else(String::new, SlashCommandHelper::current_line)
|
|
}
|
|
|
|
fn finish_interrupted_read(&mut self) -> io::Result<()> {
|
|
if let Some(helper) = self.editor.helper_mut() {
|
|
helper.reset_current_line();
|
|
}
|
|
let mut stdout = io::stdout();
|
|
writeln!(stdout)
|
|
}
|
|
|
|
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
|
|
let mut stdout = io::stdout();
|
|
write!(stdout, "{}", self.prompt)?;
|
|
stdout.flush()?;
|
|
|
|
let mut buffer = String::new();
|
|
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
|
if bytes_read == 0 {
|
|
return Ok(ReadOutcome::Exit);
|
|
}
|
|
|
|
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
|
buffer.pop();
|
|
}
|
|
Ok(ReadOutcome::Submit(buffer))
|
|
}
|
|
}
|
|
|
|
fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
|
if pos != line.len() {
|
|
return None;
|
|
}
|
|
|
|
let prefix = &line[..pos];
|
|
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};
|
|
use rustyline::completion::Completer;
|
|
use rustyline::highlight::Highlighter;
|
|
use rustyline::history::{DefaultHistory, History};
|
|
use rustyline::Context;
|
|
|
|
#[test]
|
|
fn extracts_terminal_slash_command_prefixes_with_arguments() {
|
|
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn completes_matching_slash_commands() {
|
|
let helper = SlashCommandHelper::new(vec![
|
|
"/help".to_string(),
|
|
"/hello".to_string(),
|
|
"/status".to_string(),
|
|
]);
|
|
let history = DefaultHistory::new();
|
|
let ctx = Context::new(&history);
|
|
let (start, matches) = helper
|
|
.complete("/he", 3, &ctx)
|
|
.expect("completion should work");
|
|
|
|
assert_eq!(start, 0);
|
|
assert_eq!(
|
|
matches
|
|
.into_iter()
|
|
.map(|candidate| candidate.replacement)
|
|
.collect::<Vec<_>>(),
|
|
vec!["/help".to_string(), "/hello".to_string()]
|
|
);
|
|
}
|
|
|
|
#[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()]);
|
|
let history = DefaultHistory::new();
|
|
let ctx = Context::new(&history);
|
|
let (_, matches) = helper
|
|
.complete("hello", 5, &ctx)
|
|
.expect("completion should work");
|
|
|
|
assert!(matches.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tracks_current_buffer_through_highlighter() {
|
|
let helper = SlashCommandHelper::new(Vec::new());
|
|
let _ = helper.highlight("draft", 5);
|
|
|
|
assert_eq!(helper.current_line(), "draft");
|
|
}
|
|
|
|
#[test]
|
|
fn push_history_ignores_blank_entries() {
|
|
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
|
|
editor.push_history(" ");
|
|
editor.push_history("/help");
|
|
|
|
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()]);
|
|
}
|
|
}
|