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:
139
rust/Cargo.lock
generated
139
rust/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,45 +131,43 @@ 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> {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
write!(stdout, "{}", self.prompt)?;
|
write!(stdout, "{}", self.prompt)?;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user