Replace bespoke CLI line editing with rustyline and canonical model aliases

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
This commit is contained in:
Yeachan-Heo
2026-04-01 02:04:12 +00:00
parent 9a86aa6444
commit e2753f055a
5 changed files with 374 additions and 581 deletions

139
rust/Cargo.lock generated
View File

@@ -98,6 +98,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "commands" name = "commands"
version = "0.1.0" version = "0.1.0"
@@ -142,7 +151,7 @@ dependencies = [
"crossterm_winapi", "crossterm_winapi",
"mio", "mio",
"parking_lot", "parking_lot",
"rustix", "rustix 0.38.44",
"signal-hook", "signal-hook",
"signal-hook-mio", "signal-hook-mio",
"winapi", "winapi",
@@ -197,6 +206,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -213,6 +228,23 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -351,6 +383,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 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]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -614,6 +655,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -669,6 +716,27 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@@ -888,6 +956,16 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 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]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
@@ -1037,10 +1115,23 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.37" version = "0.23.37"
@@ -1092,12 +1183,35 @@ dependencies = [
"crossterm", "crossterm",
"pulldown-cmark", "pulldown-cmark",
"runtime", "runtime",
"rustyline",
"serde_json", "serde_json",
"syntect", "syntect",
"tokio", "tokio",
"tools", "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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -1525,6 +1639,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
@@ -1555,6 +1675,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -1725,6 +1851,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

View File

@@ -520,6 +520,7 @@ fn read_auth_token() -> Option<String> {
.and_then(std::convert::identity) .and_then(std::convert::identity)
} }
#[must_use]
pub fn read_base_url() -> String { pub fn read_base_url() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
} }

View File

@@ -15,6 +15,7 @@ commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" } compat-harness = { path = "../compat-harness" }
crossterm = "0.28" crossterm = "0.28"
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
serde_json = "1" serde_json = "1"
syntect = "5" syntect = "5"

View File

@@ -1,166 +1,16 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp}; use rustyline::completion::{Completer, Pair};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use rustyline::error::ReadlineError;
use crossterm::queue; use rustyline::highlight::{CmdKind, Highlighter};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
#[derive(Debug, Clone, PartialEq, Eq)] use rustyline::validate::Validator;
pub struct InputBuffer { use rustyline::{
buffer: String, Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers,
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<String>) {
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::<Vec<_>>();
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<String>,
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)
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadOutcome { pub enum ReadOutcome {
@@ -169,25 +19,101 @@ pub enum ReadOutcome {
Exit, 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 { pub struct LineEditor {
prompt: String, prompt: String,
continuation_prompt: String, editor: Editor<SlashCommandHelper, DefaultHistory>,
history: Vec<String>,
history_index: Option<usize>,
draft: Option<String>,
completions: Vec<String>,
} }
impl LineEditor { impl LineEditor {
#[must_use] #[must_use]
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self { 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 { Self {
prompt: prompt.into(), prompt: prompt.into(),
continuation_prompt: String::from("> "), editor,
history: Vec::new(),
history_index: None,
draft: None,
completions,
} }
} }
@@ -196,9 +122,8 @@ impl LineEditor {
if entry.trim().is_empty() { if entry.trim().is_empty() {
return; return;
} }
self.history.push(entry);
self.history_index = None; let _ = self.editor.add_history_entry(entry);
self.draft = None;
} }
pub fn read_line(&mut self) -> io::Result<ReadOutcome> { pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
@@ -206,43 +131,41 @@ impl LineEditor {
return self.read_line_fallback(); return self.read_line_fallback();
} }
enable_raw_mode()?; if let Some(helper) = self.editor.helper_mut() {
let mut stdout = io::stdout(); helper.reset_current_line();
let mut input = InputBuffer::new(); }
let mut rendered_lines = 1usize;
self.redraw(&mut stdout, &input, rendered_lines)?;
loop { match self.editor.readline(&self.prompt) {
let event = event::read()?; Ok(line) => Ok(ReadOutcome::Submit(line)),
if let Event::Key(key) = event { Err(ReadlineError::Interrupted) => {
match self.handle_key(key, &mut input) { let has_input = !self.current_line().is_empty();
EditorAction::Continue => { self.finish_interrupted_read()?;
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?; if has_input {
} Ok(ReadOutcome::Cancel)
EditorAction::Submit => { } else {
disable_raw_mode()?; Ok(ReadOutcome::Exit)
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);
} }
} }
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> { fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
@@ -261,388 +184,86 @@ impl LineEditor {
} }
Ok(ReadOutcome::Submit(buffer)) 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<usize> {
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)] fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
enum EditorAction { if pos != line.len() {
Continue, return None;
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());
} }
RenderedBuffer { let prefix = &line[..pos];
lines, if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
cursor_row, return None;
cursor_col,
} }
}
#[must_use] Some(prefix)
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)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{render_buffer, InputBuffer, LineEditor}; use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use rustyline::completion::Completer;
use rustyline::highlight::Highlighter;
use rustyline::history::{DefaultHistory, History};
use rustyline::Context;
fn key(code: KeyCode) -> KeyEvent { #[test]
KeyEvent::new(code, KeyModifiers::NONE) 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] #[test]
fn supports_basic_line_editing() { fn completes_matching_slash_commands() {
let mut input = InputBuffer::new(); let helper = SlashCommandHelper::new(vec![
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(&[
"/help".to_string(), "/help".to_string(),
"/hello".to_string(), "/hello".to_string(),
"/status".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!(start, 0);
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!( assert_eq!(
rendered.lines(), matches
&[" hello".to_string(), "> world".to_string()] .into_iter()
.map(|candidate| candidate.replacement)
.collect::<Vec<_>>(),
vec!["/help".to_string(), "/hello".to_string()]
); );
assert_eq!(rendered.cursor_position(), (1, 7));
} }
#[test] #[test]
fn ctrl_c_exits_only_when_buffer_is_empty() { fn ignores_non_slash_command_completion_requests() {
let mut editor = LineEditor::new(" ", vec![]); let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
let mut empty = InputBuffer::new(); let history = DefaultHistory::new();
assert!(matches!( let ctx = Context::new(&history);
editor.handle_key( let (_, matches) = helper
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), .complete("hello", 5, &ctx)
&mut empty, .expect("completion should work");
),
super::EditorAction::Exit
));
let mut filled = InputBuffer::new(); assert!(matches.is_empty());
filled.insert('x'); }
assert!(matches!(
editor.handle_key( #[test]
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), fn tracks_current_buffer_through_highlighter() {
&mut filled, let helper = SlashCommandHelper::new(Vec::new());
), let _ = helper.highlight("draft", 5);
super::EditorAction::Cancel
)); assert_eq!(helper.current_line(), "draft");
assert!(filled.as_str().is_empty()); }
#[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);
} }
} }

View File

@@ -157,11 +157,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let value = args let value = args
.get(index + 1) .get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?; .ok_or_else(|| "missing value for --model".to_string())?;
model.clone_from(value); model = resolve_model_alias(value).to_string();
index += 2; index += 2;
} }
flag if flag.starts_with("--model=") => { flag if flag.starts_with("--model=") => {
model = flag[8..].to_string(); model = resolve_model_alias(&flag[8..]).to_string();
index += 1; index += 1;
} }
"--output-format" => { "--output-format" => {
@@ -259,6 +259,15 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
} }
} }
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<Option<AllowedToolSet>, String> { fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
if values.is_empty() { if values.is_empty() {
return Ok(None); return Ok(None);
@@ -1033,7 +1042,8 @@ impl LiveCli {
} }
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
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 { let request = MessageRequest {
model: self.model.clone(), model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
@@ -1172,6 +1182,8 @@ impl LiveCli {
return Ok(false); return Ok(false);
}; };
let model = resolve_model_alias(&model).to_string();
if model == self.model { if model == self.model {
println!( println!(
"{}", "{}",
@@ -1934,7 +1946,8 @@ impl AnthropicRuntimeClient {
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self { Ok(Self {
runtime: tokio::runtime::Runtime::new()?, 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, model,
enable_tools, enable_tools,
allowed_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 dump-manifests")?;
writeln!(out, " claw bootstrap-plan")?; writeln!(out, " claw bootstrap-plan")?;
writeln!( writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
out,
" claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]"
)?;
writeln!(out, " claw login")?; writeln!(out, " claw login")?;
writeln!(out, " claw logout")?; writeln!(out, " claw logout")?;
writeln!(out, " claw init")?; writeln!(out, " claw init")?;
@@ -2347,10 +2357,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
.join(", "); .join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?; writeln!(out, "Resume-safe commands: {resume_commands}")?;
writeln!(out, "Examples:")?; writeln!(out, "Examples:")?;
writeln!( writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
out,
" claw --model claude-opus \"summarize this repo\""
)?;
writeln!( writeln!(
out, out,
" claw --output-format json prompt \"explain src/main.rs\"" " 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_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result, 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, 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, resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
StatusUsage, DEFAULT_MODEL, 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] #[test]
fn parses_version_flags_without_initializing_prompt_mode() { fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!( assert_eq!(