Просмотр исходного кода

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
Yeachan-Heo 2 месяцев назад
Родитель
Сommit
f2989128b9

+ 137 - 2
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"

+ 1 - 0
rust/crates/api/src/client.rs

@@ -520,6 +520,7 @@ fn read_auth_token() -> Option<String> {
         .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())
 }

+ 1 - 0
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"

+ 159 - 538
rust/crates/rusty-claude-cli/src/input.rs

@@ -1,193 +1,119 @@
+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};
+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 struct InputBuffer {
-    buffer: String,
-    cursor: usize,
+pub enum ReadOutcome {
+    Submit(String),
+    Cancel,
+    Exit,
 }
 
-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);
-    }
+struct SlashCommandHelper {
+    completions: Vec<String>,
+    current_line: RefCell<String>,
+}
 
-    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();
+impl SlashCommandHelper {
+    fn new(completions: Vec<String>) -> Self {
+        Self {
+            completions,
+            current_line: RefCell::new(String::new()),
         }
     }
 
-    pub fn move_home(&mut self) {
-        self.cursor = 0;
+    fn reset_current_line(&self) {
+        self.current_line.borrow_mut().clear();
     }
 
-    pub fn move_end(&mut self) {
-        self.cursor = self.buffer.len();
+    fn current_line(&self) -> String {
+        self.current_line.borrow().clone()
     }
 
-    #[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;
+    fn set_current_line(&self, line: &str) {
+        let mut current = self.current_line.borrow_mut();
+        current.clear();
+        current.push_str(line);
     }
+}
 
-    pub fn replace(&mut self, value: impl Into<String>) {
-        self.buffer = value.into();
-        self.cursor = self.buffer.len();
-    }
+impl Completer for SlashCommandHelper {
+    type Candidate = Pair;
 
-    #[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;
+    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 = candidates
+        let matches = self
+            .completions
             .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;
-        }
+            .map(|candidate| Pair {
+                display: candidate.clone(),
+                replacement: candidate.clone(),
+            })
+            .collect();
 
-        self.replace(replacement);
-        true
+        Ok((0, matches))
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RenderedBuffer {
-    lines: Vec<String>,
-    cursor_row: u16,
-    cursor_col: u16,
+impl Hinter for SlashCommandHelper {
+    type Hint = String;
 }
 
-impl RenderedBuffer {
-    #[must_use]
-    pub fn line_count(&self) -> usize {
-        self.lines.len()
+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 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)
+    fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
+        self.set_current_line(line);
+        false
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ReadOutcome {
-    Submit(String),
-    Cancel,
-    Exit,
-}
+impl Validator for SlashCommandHelper {}
+impl Helper for SlashCommandHelper {}
 
 pub struct LineEditor {
     prompt: String,
-    continuation_prompt: String,
-    history: Vec<String>,
-    history_index: Option<usize>,
-    draft: Option<String>,
-    completions: Vec<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(),
-            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<ReadOutcome> {
@@ -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)?;
-
-        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);
-                    }
+        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)?;
@@ -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<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)]
-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());
-    }
-
-    RenderedBuffer {
-        lines,
-        cursor_row,
-        cursor_col,
+fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
+    if pos != line.len() {
+        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;
-            }
-        }
+    let prefix = &line[..pos];
+    if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
+        return None;
     }
-    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};
-
-    fn key(code: KeyCode) -> KeyEvent {
-        KeyEvent::new(code, KeyModifiers::NONE)
-    }
+    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 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);
+    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_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");
-
-        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");
+        ]);
+        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 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");
+    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 _ = 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");
+        assert!(matches.is_empty());
     }
 
     #[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);
-        }
+    fn tracks_current_buffer_through_highlighter() {
+        let helper = SlashCommandHelper::new(Vec::new());
+        let _ = helper.highlight("draft", 5);
 
-        let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
-        assert_eq!(input.as_str(), "/status");
+        assert_eq!(helper.current_line(), "draft");
     }
 
     #[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!(
-            rendered.lines(),
-            &["› hello".to_string(), "> world".to_string()]
-        );
-        assert_eq!(rendered.cursor_position(), (1, 7));
-    }
+    fn push_history_ignores_blank_entries() {
+        let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
+        editor.push_history("   ");
+        editor.push_history("/help");
 
-    #[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
-        ));
-
-        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_eq!(editor.editor.history().len(), 1);
     }
 }

+ 48 - 13
rust/crates/rusty-claude-cli/src/main.rs

@@ -157,11 +157,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
                 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<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> {
     if values.is_empty() {
         return Ok(None);
@@ -1033,7 +1042,8 @@ impl LiveCli {
     }
 
     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 {
             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<Self, Box<dyn std::error::Error>> {
         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!(