diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9030127..ad29288 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -98,6 +98,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "commands" version = "0.1.0" @@ -142,7 +151,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -197,6 +206,12 @@ dependencies = [ "syn", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "equivalent" version = "1.0.2" @@ -213,6 +228,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -351,6 +383,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -614,6 +655,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -669,6 +716,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -888,6 +956,16 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.9.2" @@ -1037,10 +1115,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1092,12 +1183,35 @@ dependencies = [ "crossterm", "pulldown-cmark", "runtime", + "rustyline", "serde_json", "syntect", "tokio", "tools", ] +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.59.0", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1525,6 +1639,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-width" version = "0.2.2" @@ -1555,6 +1675,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -1725,6 +1851,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 110a80b..3b137b4 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -520,6 +520,7 @@ fn read_auth_token() -> Option { .and_then(std::convert::identity) } +#[must_use] pub fn read_base_url() -> String { std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) } diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index 625d1e5..2ac6701 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -15,6 +15,7 @@ commands = { path = "../commands" } compat-harness = { path = "../compat-harness" } crossterm = "0.28" pulldown-cmark = "0.13" +rustyline = "15" runtime = { path = "../runtime" } serde_json = "1" syntect = "5" diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index bca3791..1cf6029 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -1,166 +1,16 @@ +use std::borrow::Cow; +use std::cell::RefCell; use std::io::{self, IsTerminal, Write}; -use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::queue; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InputBuffer { - buffer: String, - cursor: usize, -} - -impl InputBuffer { - #[must_use] - pub fn new() -> Self { - Self { - buffer: String::new(), - cursor: 0, - } - } - - pub fn insert(&mut self, ch: char) { - self.buffer.insert(self.cursor, ch); - self.cursor += ch.len_utf8(); - } - - pub fn insert_newline(&mut self) { - self.insert('\n'); - } - - pub fn backspace(&mut self) { - if self.cursor == 0 { - return; - } - - let previous = self.buffer[..self.cursor] - .char_indices() - .last() - .map_or(0, |(idx, _)| idx); - self.buffer.drain(previous..self.cursor); - self.cursor = previous; - } - - pub fn move_left(&mut self) { - if self.cursor == 0 { - return; - } - self.cursor = self.buffer[..self.cursor] - .char_indices() - .last() - .map_or(0, |(idx, _)| idx); - } - - pub fn move_right(&mut self) { - if self.cursor >= self.buffer.len() { - return; - } - if let Some(next) = self.buffer[self.cursor..].chars().next() { - self.cursor += next.len_utf8(); - } - } - - pub fn move_home(&mut self) { - self.cursor = 0; - } - - pub fn move_end(&mut self) { - self.cursor = self.buffer.len(); - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.buffer - } - - #[cfg(test)] - #[must_use] - pub fn cursor(&self) -> usize { - self.cursor - } - - pub fn clear(&mut self) { - self.buffer.clear(); - self.cursor = 0; - } - - pub fn replace(&mut self, value: impl Into) { - self.buffer = value.into(); - self.cursor = self.buffer.len(); - } - - #[must_use] - fn current_command_prefix(&self) -> Option<&str> { - if self.cursor != self.buffer.len() { - return None; - } - let prefix = &self.buffer[..self.cursor]; - if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') { - return None; - } - Some(prefix) - } - - pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool { - let Some(prefix) = self.current_command_prefix() else { - return false; - }; - - let matches = candidates - .iter() - .filter(|candidate| candidate.starts_with(prefix)) - .map(String::as_str) - .collect::>(); - if matches.is_empty() { - return false; - } - - let replacement = longest_common_prefix(&matches); - if replacement == prefix { - return false; - } - - self.replace(replacement); - true - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RenderedBuffer { - lines: Vec, - cursor_row: u16, - cursor_col: u16, -} - -impl RenderedBuffer { - #[must_use] - pub fn line_count(&self) -> usize { - self.lines.len() - } - - fn write(&self, out: &mut impl Write) -> io::Result<()> { - for (index, line) in self.lines.iter().enumerate() { - if index > 0 { - writeln!(out)?; - } - write!(out, "{line}")?; - } - Ok(()) - } - - #[cfg(test)] - #[must_use] - pub fn lines(&self) -> &[String] { - &self.lines - } - - #[cfg(test)] - #[must_use] - pub fn cursor_position(&self) -> (u16, u16) { - (self.cursor_row, self.cursor_col) - } -} +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 { @@ -169,25 +19,101 @@ pub enum ReadOutcome { Exit, } +struct SlashCommandHelper { + completions: Vec, + current_line: RefCell, +} + +impl SlashCommandHelper { + fn new(completions: Vec) -> 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)> { + 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, - continuation_prompt: String, - history: Vec, - history_index: Option, - draft: Option, - completions: Vec, + editor: Editor, } impl LineEditor { #[must_use] pub fn new(prompt: impl Into, completions: Vec) -> Self { + let config = Config::builder() + .completion_type(CompletionType::List) + .edit_mode(EditMode::Emacs) + .build(); + let mut editor = Editor::::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(), - continuation_prompt: String::from("> "), - history: Vec::new(), - history_index: None, - draft: None, - completions, + editor, } } @@ -196,9 +122,8 @@ impl LineEditor { if entry.trim().is_empty() { return; } - self.history.push(entry); - self.history_index = None; - self.draft = None; + + let _ = self.editor.add_history_entry(entry); } pub fn read_line(&mut self) -> io::Result { @@ -206,45 +131,43 @@ impl LineEditor { return self.read_line_fallback(); } - enable_raw_mode()?; - let mut stdout = io::stdout(); - let mut input = InputBuffer::new(); - let mut rendered_lines = 1usize; - self.redraw(&mut stdout, &input, rendered_lines)?; + if let Some(helper) = self.editor.helper_mut() { + helper.reset_current_line(); + } - loop { - let event = event::read()?; - if let Event::Key(key) = event { - match self.handle_key(key, &mut input) { - EditorAction::Continue => { - rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?; - } - EditorAction::Submit => { - disable_raw_mode()?; - writeln!(stdout)?; - self.history_index = None; - self.draft = None; - return Ok(ReadOutcome::Submit(input.as_str().to_owned())); - } - EditorAction::Cancel => { - disable_raw_mode()?; - writeln!(stdout)?; - self.history_index = None; - self.draft = None; - return Ok(ReadOutcome::Cancel); - } - EditorAction::Exit => { - disable_raw_mode()?; - writeln!(stdout)?; - self.history_index = None; - self.draft = None; - return Ok(ReadOutcome::Exit); - } + 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 { let mut stdout = io::stdout(); write!(stdout, "{}", self.prompt)?; @@ -261,388 +184,86 @@ impl LineEditor { } Ok(ReadOutcome::Submit(buffer)) } - - #[allow(clippy::too_many_lines)] - fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction { - match key { - KeyEvent { - code: KeyCode::Char('c'), - modifiers, - .. - } if modifiers.contains(KeyModifiers::CONTROL) => { - if input.as_str().is_empty() { - EditorAction::Exit - } else { - input.clear(); - self.history_index = None; - self.draft = None; - EditorAction::Cancel - } - } - KeyEvent { - code: KeyCode::Char('j'), - modifiers, - .. - } if modifiers.contains(KeyModifiers::CONTROL) => { - input.insert_newline(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Enter, - modifiers, - .. - } if modifiers.contains(KeyModifiers::SHIFT) => { - input.insert_newline(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Enter, - .. - } => EditorAction::Submit, - KeyEvent { - code: KeyCode::Backspace, - .. - } => { - input.backspace(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Left, - .. - } => { - input.move_left(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Right, - .. - } => { - input.move_right(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Up, .. - } => { - self.navigate_history_up(input); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Down, - .. - } => { - self.navigate_history_down(input); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Tab, .. - } => { - input.complete_slash_command(&self.completions); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Home, - .. - } => { - input.move_home(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::End, .. - } => { - input.move_end(); - EditorAction::Continue - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - input.clear(); - self.history_index = None; - self.draft = None; - EditorAction::Cancel - } - KeyEvent { - code: KeyCode::Char(ch), - modifiers, - .. - } if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => { - input.insert(ch); - self.history_index = None; - self.draft = None; - EditorAction::Continue - } - _ => EditorAction::Continue, - } - } - - fn navigate_history_up(&mut self, input: &mut InputBuffer) { - if self.history.is_empty() { - return; - } - - match self.history_index { - Some(0) => {} - Some(index) => { - let next_index = index - 1; - input.replace(self.history[next_index].clone()); - self.history_index = Some(next_index); - } - None => { - self.draft = Some(input.as_str().to_owned()); - let next_index = self.history.len() - 1; - input.replace(self.history[next_index].clone()); - self.history_index = Some(next_index); - } - } - } - - fn navigate_history_down(&mut self, input: &mut InputBuffer) { - let Some(index) = self.history_index else { - return; - }; - - if index + 1 < self.history.len() { - let next_index = index + 1; - input.replace(self.history[next_index].clone()); - self.history_index = Some(next_index); - return; - } - - input.replace(self.draft.take().unwrap_or_default()); - self.history_index = None; - } - - fn redraw( - &self, - out: &mut impl Write, - input: &InputBuffer, - previous_line_count: usize, - ) -> io::Result { - let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input); - if previous_line_count > 1 { - queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?; - } - queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?; - rendered.write(out)?; - queue!( - out, - MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))), - MoveToColumn(0), - )?; - if rendered.cursor_row > 0 { - queue!(out, MoveDown(rendered.cursor_row))?; - } - queue!(out, MoveToColumn(rendered.cursor_col))?; - out.flush()?; - Ok(rendered.line_count()) - } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EditorAction { - Continue, - Submit, - Cancel, - Exit, -} - -#[must_use] -pub fn render_buffer( - prompt: &str, - continuation_prompt: &str, - input: &InputBuffer, -) -> RenderedBuffer { - let before_cursor = &input.as_str()[..input.cursor]; - let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count()); - let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default(); - let cursor_prompt = if cursor_row == 0 { - prompt - } else { - continuation_prompt - }; - let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count()); - - let mut lines = Vec::new(); - for (index, line) in input.as_str().split('\n').enumerate() { - let prefix = if index == 0 { - prompt - } else { - continuation_prompt - }; - lines.push(format!("{prefix}{line}")); - } - if lines.is_empty() { - lines.push(prompt.to_string()); +fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> { + if pos != line.len() { + return None; } - RenderedBuffer { - lines, - cursor_row, - cursor_col, + let prefix = &line[..pos]; + if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') { + return None; } -} -#[must_use] -fn longest_common_prefix(values: &[&str]) -> String { - let Some(first) = values.first() else { - return String::new(); - }; - - let mut prefix = (*first).to_string(); - for value in values.iter().skip(1) { - while !value.starts_with(&prefix) { - prefix.pop(); - if prefix.is_empty() { - break; - } - } - } - prefix -} - -#[must_use] -fn saturating_u16(value: usize) -> u16 { - u16::try_from(value).unwrap_or(u16::MAX) + Some(prefix) } #[cfg(test)] mod tests { - use super::{render_buffer, InputBuffer, LineEditor}; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use super::{slash_command_prefix, LineEditor, SlashCommandHelper}; + use rustyline::completion::Completer; + use rustyline::highlight::Highlighter; + use rustyline::history::{DefaultHistory, History}; + use rustyline::Context; - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) + #[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 supports_basic_line_editing() { - let mut input = InputBuffer::new(); - input.insert('h'); - input.insert('i'); - input.move_end(); - input.insert_newline(); - input.insert('x'); - - assert_eq!(input.as_str(), "hi\nx"); - assert_eq!(input.cursor(), 4); - - input.move_left(); - input.backspace(); - assert_eq!(input.as_str(), "hix"); - assert_eq!(input.cursor(), 2); - } - - #[test] - fn completes_unique_slash_command() { - let mut input = InputBuffer::new(); - for ch in "/he".chars() { - input.insert(ch); - } - - assert!(input.complete_slash_command(&[ + fn completes_matching_slash_commands() { + let helper = SlashCommandHelper::new(vec![ "/help".to_string(), "/hello".to_string(), "/status".to_string(), - ])); - assert_eq!(input.as_str(), "/hel"); + ]); + let history = DefaultHistory::new(); + let ctx = Context::new(&history); + let (start, matches) = helper + .complete("/he", 3, &ctx) + .expect("completion should work"); - assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()])); - assert_eq!(input.as_str(), "/help"); - } - - #[test] - fn ignores_completion_when_prefix_is_not_a_slash_command() { - let mut input = InputBuffer::new(); - for ch in "hello".chars() { - input.insert(ch); - } - - assert!(!input.complete_slash_command(&["/help".to_string()])); - assert_eq!(input.as_str(), "hello"); - } - - #[test] - fn history_navigation_restores_current_draft() { - let mut editor = LineEditor::new("› ", vec![]); - editor.push_history("/help"); - editor.push_history("status report"); - - let mut input = InputBuffer::new(); - for ch in "draft".chars() { - input.insert(ch); - } - - let _ = editor.handle_key(key(KeyCode::Up), &mut input); - assert_eq!(input.as_str(), "status report"); - - let _ = editor.handle_key(key(KeyCode::Up), &mut input); - assert_eq!(input.as_str(), "/help"); - - let _ = editor.handle_key(key(KeyCode::Down), &mut input); - assert_eq!(input.as_str(), "status report"); - - let _ = editor.handle_key(key(KeyCode::Down), &mut input); - assert_eq!(input.as_str(), "draft"); - } - - #[test] - fn tab_key_completes_from_editor_candidates() { - let mut editor = LineEditor::new( - "› ", - vec![ - "/help".to_string(), - "/status".to_string(), - "/session".to_string(), - ], - ); - let mut input = InputBuffer::new(); - for ch in "/st".chars() { - input.insert(ch); - } - - let _ = editor.handle_key(key(KeyCode::Tab), &mut input); - assert_eq!(input.as_str(), "/status"); - } - - #[test] - fn renders_multiline_buffers_with_continuation_prompt() { - let mut input = InputBuffer::new(); - for ch in "hello\nworld".chars() { - if ch == '\n' { - input.insert_newline(); - } else { - input.insert(ch); - } - } - - let rendered = render_buffer("› ", "> ", &input); + assert_eq!(start, 0); assert_eq!( - rendered.lines(), - &["› hello".to_string(), "> world".to_string()] + matches + .into_iter() + .map(|candidate| candidate.replacement) + .collect::>(), + vec!["/help".to_string(), "/hello".to_string()] ); - assert_eq!(rendered.cursor_position(), (1, 7)); } #[test] - fn ctrl_c_exits_only_when_buffer_is_empty() { - let mut editor = LineEditor::new("› ", vec![]); - let mut empty = InputBuffer::new(); - assert!(matches!( - editor.handle_key( - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - &mut empty, - ), - super::EditorAction::Exit - )); + 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"); - let mut filled = InputBuffer::new(); - filled.insert('x'); - assert!(matches!( - editor.handle_key( - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - &mut filled, - ), - super::EditorAction::Cancel - )); - assert!(filled.as_str().is_empty()); + 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); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 173df7e..f65950c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -157,11 +157,11 @@ fn parse_args(args: &[String]) -> Result { let value = args .get(index + 1) .ok_or_else(|| "missing value for --model".to_string())?; - model.clone_from(value); + model = resolve_model_alias(value).to_string(); index += 2; } flag if flag.starts_with("--model=") => { - model = flag[8..].to_string(); + model = resolve_model_alias(&flag[8..]).to_string(); index += 1; } "--output-format" => { @@ -259,6 +259,15 @@ fn parse_args(args: &[String]) -> Result { } } +fn resolve_model_alias(model: &str) -> &str { + match model { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-3-5-20241022", + _ => model, + } +} + fn normalize_allowed_tools(values: &[String]) -> Result, String> { if values.is_empty() { return Ok(None); @@ -1033,7 +1042,8 @@ impl LiveCli { } fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { - let client = AnthropicClient::from_auth(resolve_cli_auth_source()?).with_base_url(api::read_base_url()); + let client = AnthropicClient::from_auth(resolve_cli_auth_source()?) + .with_base_url(api::read_base_url()); let request = MessageRequest { model: self.model.clone(), max_tokens: DEFAULT_MAX_TOKENS, @@ -1172,6 +1182,8 @@ impl LiveCli { return Ok(false); }; + let model = resolve_model_alias(&model).to_string(); + if model == self.model { println!( "{}", @@ -1934,7 +1946,8 @@ impl AnthropicRuntimeClient { ) -> Result> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, - client: AnthropicClient::from_auth(resolve_cli_auth_source()?).with_base_url(api::read_base_url()), + client: AnthropicClient::from_auth(resolve_cli_auth_source()?) + .with_base_url(api::read_base_url()), model, enable_tools, allowed_tools, @@ -2307,10 +2320,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!(out, " claw dump-manifests")?; writeln!(out, " claw bootstrap-plan")?; - writeln!( - out, - " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]" - )?; + writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!(out, " claw login")?; writeln!(out, " claw logout")?; writeln!(out, " claw init")?; @@ -2347,10 +2357,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { .join(", "); writeln!(out, "Resume-safe commands: {resume_commands}")?; writeln!(out, "Examples:")?; - writeln!( - out, - " claw --model claude-opus \"summarize this repo\"" - )?; + writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; writeln!( out, " claw --output-format json prompt \"explain src/main.rs\"" @@ -2379,7 +2386,7 @@ mod tests { 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_metadata, print_help_to, - render_config_report, render_memory_report, render_repl_help, + render_config_report, render_memory_report, render_repl_help, resolve_model_alias, resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; @@ -2438,6 +2445,34 @@ mod tests { ); } + #[test] + fn resolves_model_aliases_in_args() { + let args = vec![ + "--model".to_string(), + "opus".to_string(), + "explain".to_string(), + "this".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Prompt { + prompt: "explain this".to_string(), + model: "claude-opus-4-6".to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, + } + ); + } + + #[test] + fn resolves_known_model_aliases() { + assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); + assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6"); + assert_eq!(resolve_model_alias("haiku"), "claude-haiku-3-5-20241022"); + assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); + } + #[test] fn parses_version_flags_without_initializing_prompt_mode() { assert_eq!(