2026-03-31 18:39:39 +00:00
|
|
|
|
use std::io::{self, IsTerminal, Write};
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
|
2026-04-01 00:14:38 +00:00
|
|
|
|
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
|
|
use crossterm::queue;
|
|
|
|
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub struct InputBuffer {
|
|
|
|
|
|
buffer: String,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl InputBuffer {
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
buffer: String::new(),
|
|
|
|
|
|
cursor: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn insert(&mut self, ch: char) {
|
|
|
|
|
|
self.buffer.insert(self.cursor, ch);
|
|
|
|
|
|
self.cursor += ch.len_utf8();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn insert_newline(&mut self) {
|
|
|
|
|
|
self.insert('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn backspace(&mut self) {
|
|
|
|
|
|
if self.cursor == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let previous = self.buffer[..self.cursor]
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.map_or(0, |(idx, _)| idx);
|
|
|
|
|
|
self.buffer.drain(previous..self.cursor);
|
|
|
|
|
|
self.cursor = previous;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn move_left(&mut self) {
|
|
|
|
|
|
if self.cursor == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.cursor = self.buffer[..self.cursor]
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.map_or(0, |(idx, _)| idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn move_right(&mut self) {
|
|
|
|
|
|
if self.cursor >= self.buffer.len() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(next) = self.buffer[self.cursor..].chars().next() {
|
|
|
|
|
|
self.cursor += next.len_utf8();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn move_home(&mut self) {
|
|
|
|
|
|
self.cursor = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn move_end(&mut self) {
|
|
|
|
|
|
self.cursor = self.buffer.len();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
|
|
|
|
&self.buffer
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
|
pub fn cursor(&self) -> usize {
|
|
|
|
|
|
self.cursor
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn clear(&mut self) {
|
|
|
|
|
|
self.buffer.clear();
|
|
|
|
|
|
self.cursor = 0;
|
|
|
|
|
|
}
|
2026-04-01 00:14:38 +00:00
|
|
|
|
|
|
|
|
|
|
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)]
|
|
|
|
|
|
pub enum ReadOutcome {
|
|
|
|
|
|
Submit(String),
|
|
|
|
|
|
Cancel,
|
|
|
|
|
|
Exit,
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct LineEditor {
|
|
|
|
|
|
prompt: String,
|
2026-04-01 00:14:38 +00:00
|
|
|
|
continuation_prompt: String,
|
|
|
|
|
|
history: Vec<String>,
|
|
|
|
|
|
history_index: Option<usize>,
|
|
|
|
|
|
draft: Option<String>,
|
|
|
|
|
|
completions: Vec<String>,
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl LineEditor {
|
|
|
|
|
|
#[must_use]
|
2026-04-01 00:14:38 +00:00
|
|
|
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
Self {
|
|
|
|
|
|
prompt: prompt.into(),
|
2026-04-01 00:14:38 +00:00
|
|
|
|
continuation_prompt: String::from("> "),
|
|
|
|
|
|
history: Vec::new(),
|
|
|
|
|
|
history_index: None,
|
|
|
|
|
|
draft: None,
|
|
|
|
|
|
completions,
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 00:14:38 +00:00
|
|
|
|
pub fn push_history(&mut self, entry: impl Into<String>) {
|
|
|
|
|
|
let entry = entry.into();
|
|
|
|
|
|
if entry.trim().is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.history.push(entry);
|
|
|
|
|
|
self.history_index = None;
|
|
|
|
|
|
self.draft = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
2026-03-31 18:39:39 +00:00
|
|
|
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
|
|
|
|
|
return self.read_line_fallback();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
enable_raw_mode()?;
|
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
|
let mut input = InputBuffer::new();
|
2026-04-01 00:14:38 +00:00
|
|
|
|
let mut rendered_lines = 1usize;
|
|
|
|
|
|
self.redraw(&mut stdout, &input, rendered_lines)?;
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
|
let event = event::read()?;
|
|
|
|
|
|
if let Event::Key(key) = event {
|
2026-04-01 00:14:38 +00:00
|
|
|
|
match self.handle_key(key, &mut input) {
|
|
|
|
|
|
EditorAction::Continue => {
|
|
|
|
|
|
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
EditorAction::Submit => {
|
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
|
writeln!(stdout)?;
|
2026-04-01 00:14:38 +00:00
|
|
|
|
self.history_index = None;
|
|
|
|
|
|
self.draft = None;
|
|
|
|
|
|
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
EditorAction::Cancel => {
|
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
|
writeln!(stdout)?;
|
2026-04-01 00:14:38 +00:00
|
|
|
|
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);
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 00:14:38 +00:00
|
|
|
|
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
|
2026-03-31 18:39:39 +00:00
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
|
write!(stdout, "{}", self.prompt)?;
|
|
|
|
|
|
stdout.flush()?;
|
|
|
|
|
|
|
|
|
|
|
|
let mut buffer = String::new();
|
|
|
|
|
|
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
|
|
|
|
|
if bytes_read == 0 {
|
2026-04-01 00:14:38 +00:00
|
|
|
|
return Ok(ReadOutcome::Exit);
|
2026-03-31 18:39:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
|
|
|
|
|
buffer.pop();
|
|
|
|
|
|
}
|
2026-04-01 00:14:38 +00:00
|
|
|
|
Ok(ReadOutcome::Submit(buffer))
|
2026-03-31 18:39:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 00:14:38 +00:00
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
|
|
|
|
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
match key {
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Char('c'),
|
|
|
|
|
|
modifiers,
|
|
|
|
|
|
..
|
2026-04-01 00:14:38 +00:00
|
|
|
|
} 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-01 00:14:38 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Home,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
input.move_home();
|
|
|
|
|
|
EditorAction::Continue
|
|
|
|
|
|
}
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::End, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
input.move_end();
|
|
|
|
|
|
EditorAction::Continue
|
|
|
|
|
|
}
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Esc, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
input.clear();
|
2026-04-01 00:14:38 +00:00
|
|
|
|
self.history_index = None;
|
|
|
|
|
|
self.draft = None;
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
EditorAction::Cancel
|
|
|
|
|
|
}
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Char(ch),
|
|
|
|
|
|
modifiers,
|
|
|
|
|
|
..
|
|
|
|
|
|
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
|
|
|
|
|
input.insert(ch);
|
2026-04-01 00:14:38 +00:00
|
|
|
|
self.history_index = None;
|
|
|
|
|
|
self.draft = None;
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
EditorAction::Continue
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => EditorAction::Continue,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 00:14:38 +00:00
|
|
|
|
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)?;
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
queue!(
|
|
|
|
|
|
out,
|
2026-04-01 00:14:38 +00:00
|
|
|
|
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
MoveToColumn(0),
|
|
|
|
|
|
)?;
|
2026-04-01 00:14:38 +00:00
|
|
|
|
if rendered.cursor_row > 0 {
|
|
|
|
|
|
queue!(out, MoveDown(rendered.cursor_row))?;
|
|
|
|
|
|
}
|
|
|
|
|
|
queue!(out, MoveToColumn(rendered.cursor_col))?;
|
|
|
|
|
|
out.flush()?;
|
|
|
|
|
|
Ok(rendered.line_count())
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
enum EditorAction {
|
|
|
|
|
|
Continue,
|
|
|
|
|
|
Submit,
|
|
|
|
|
|
Cancel,
|
2026-04-01 00:14:38 +00:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
|
fn longest_common_prefix(values: &[&str]) -> String {
|
|
|
|
|
|
let Some(first) = values.first() else {
|
|
|
|
|
|
return String::new();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut prefix = (*first).to_string();
|
|
|
|
|
|
for value in values.iter().skip(1) {
|
|
|
|
|
|
while !value.starts_with(&prefix) {
|
|
|
|
|
|
prefix.pop();
|
|
|
|
|
|
if prefix.is_empty() {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
prefix
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
|
fn saturating_u16(value: usize) -> u16 {
|
|
|
|
|
|
u16::try_from(value).unwrap_or(u16::MAX)
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
2026-04-01 00:14:38 +00:00
|
|
|
|
use super::{render_buffer, InputBuffer, LineEditor};
|
|
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
|
|
|
|
|
|
|
|
fn key(code: KeyCode) -> KeyEvent {
|
|
|
|
|
|
KeyEvent::new(code, KeyModifiers::NONE)
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
2026-04-01 00:14:38 +00:00
|
|
|
|
|
|
|
|
|
|
#[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(),
|
|
|
|
|
|
"/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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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!(
|
|
|
|
|
|
rendered.lines(),
|
|
|
|
|
|
&["› hello".to_string(), "> world".to_string()]
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(rendered.cursor_position(), (1, 7));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ctrl_c_exits_only_when_buffer_is_empty() {
|
|
|
|
|
|
let mut editor = LineEditor::new("› ", vec![]);
|
|
|
|
|
|
let mut empty = InputBuffer::new();
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
editor.handle_key(
|
|
|
|
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
|
|
|
|
&mut empty,
|
|
|
|
|
|
),
|
|
|
|
|
|
super::EditorAction::Exit
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-03-31 17:43:09 +00:00
|
|
|
|
}
|