mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 18:04:49 +08:00
The REPL now wraps rustyline::Editor instead of maintaining a custom raw-mode input stack. This preserves the existing LineEditor surface while delegating history, completion, and interactive editing to a maintained library. The CLI argument parser and /model command path also normalize shorthand model names to our current canonical Anthropic identifiers. Constraint: User requested rustyline 15 specifically for the CLI editor rewrite Constraint: Existing LineEditor constructor and read_line API had to remain stable Rejected: Keep extending the crossterm-based editor | custom key handling and history logic were redundant with rustyline Rejected: Resolve aliases only for --model flags | /model would still diverge from CLI startup behavior Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep model alias normalization centralized in main.rs so CLI flag parsing and /model stay in sync Tested: cargo check --workspace Tested: cargo test --workspace Tested: cargo build --workspace Tested: cargo clippy --workspace --all-targets -- -D warnings Not-tested: Interactive manual terminal validation of Shift+Enter behavior across terminal emulators
270 lines
7.6 KiB
Rust
270 lines
7.6 KiB
Rust
use std::borrow::Cow;
|
|
use std::cell::RefCell;
|
|
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,
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 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.contains(char::is_whitespace) || !prefix.starts_with('/') {
|
|
return None;
|
|
}
|
|
|
|
Some(prefix)
|
|
}
|
|
|
|
#[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_only_terminal_slash_command_prefixes() {
|
|
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
|
|
assert_eq!(slash_command_prefix("/help me", 5), None);
|
|
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 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);
|
|
}
|
|
}
|