Merge remote-tracking branch 'origin/rcc/cli' into dev/rust
# Conflicts: # rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
@@ -102,6 +102,13 @@ cd rust
|
|||||||
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
|
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Restrict enabled tools in an interactive session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rust
|
||||||
|
cargo run -p rusty-claude-cli -- --allowedTools read,glob
|
||||||
|
```
|
||||||
|
|
||||||
### 2) REPL mode
|
### 2) REPL mode
|
||||||
|
|
||||||
Start the interactive shell:
|
Start the interactive shell:
|
||||||
@@ -123,6 +130,10 @@ Inside the REPL, useful commands include:
|
|||||||
/memory
|
/memory
|
||||||
/config
|
/config
|
||||||
/init
|
/init
|
||||||
|
/diff
|
||||||
|
/version
|
||||||
|
/export notes.txt
|
||||||
|
/session list
|
||||||
/exit
|
/exit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,6 +180,10 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
|
|||||||
- `/config [env|hooks|model]` — inspect discovered Claude config
|
- `/config [env|hooks|model]` — inspect discovered Claude config
|
||||||
- `/memory` — inspect loaded instruction memory files
|
- `/memory` — inspect loaded instruction memory files
|
||||||
- `/init` — create a starter `CLAUDE.md`
|
- `/init` — create a starter `CLAUDE.md`
|
||||||
|
- `/diff` — show the current git diff for the workspace
|
||||||
|
- `/version` — print version and build metadata locally
|
||||||
|
- `/export [file]` — export the current conversation transcript
|
||||||
|
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
|
||||||
- `/exit` — leave the REPL
|
- `/exit` — leave the REPL
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::io::{self, Write};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::args::{OutputFormat, PermissionMode};
|
use crate::args::{OutputFormat, PermissionMode};
|
||||||
use crate::input::LineEditor;
|
use crate::input::{LineEditor, ReadOutcome};
|
||||||
use crate::render::{Spinner, TerminalRenderer};
|
use crate::render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||||
|
|
||||||
@@ -111,17 +111,22 @@ impl CliApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||||
let editor = LineEditor::new("› ");
|
let mut editor = LineEditor::new("› ", Vec::new());
|
||||||
println!("Rusty Claude CLI interactive mode");
|
println!("Rusty Claude CLI interactive mode");
|
||||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||||
|
|
||||||
while let Some(input) = editor.read_line()? {
|
loop {
|
||||||
|
match editor.read_line()? {
|
||||||
|
ReadOutcome::Submit(input) => {
|
||||||
if input.trim().is_empty() {
|
if input.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.handle_submission(&input, &mut io::stdout())?;
|
self.handle_submission(&input, &mut io::stdout())?;
|
||||||
}
|
}
|
||||||
|
ReadOutcome::Cancel => continue,
|
||||||
|
ReadOutcome::Exit => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use std::io::{self, IsTerminal, Write};
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
use crossterm::cursor::MoveToColumn;
|
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
use crossterm::queue;
|
use crossterm::queue;
|
||||||
use crossterm::style::Print;
|
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -85,21 +84,124 @@ impl InputBuffer {
|
|||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.cursor = 0;
|
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)]
|
||||||
|
pub enum ReadOutcome {
|
||||||
|
Submit(String),
|
||||||
|
Cancel,
|
||||||
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LineEditor {
|
pub struct LineEditor {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
|
continuation_prompt: String,
|
||||||
|
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>) -> Self {
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
prompt: prompt.into(),
|
prompt: prompt.into(),
|
||||||
|
continuation_prompt: String::from("> "),
|
||||||
|
history: Vec::new(),
|
||||||
|
history_index: None,
|
||||||
|
draft: None,
|
||||||
|
completions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_line(&self) -> io::Result<Option<String>> {
|
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> {
|
||||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||||
return self.read_line_fallback();
|
return self.read_line_fallback();
|
||||||
}
|
}
|
||||||
@@ -107,29 +209,43 @@ impl LineEditor {
|
|||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut input = InputBuffer::new();
|
let mut input = InputBuffer::new();
|
||||||
self.redraw(&mut stdout, &input)?;
|
let mut rendered_lines = 1usize;
|
||||||
|
self.redraw(&mut stdout, &input, rendered_lines)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let event = event::read()?;
|
let event = event::read()?;
|
||||||
if let Event::Key(key) = event {
|
if let Event::Key(key) = event {
|
||||||
match Self::handle_key(key, &mut input) {
|
match self.handle_key(key, &mut input) {
|
||||||
EditorAction::Continue => self.redraw(&mut stdout, &input)?,
|
EditorAction::Continue => {
|
||||||
|
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
|
||||||
|
}
|
||||||
EditorAction::Submit => {
|
EditorAction::Submit => {
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
writeln!(stdout)?;
|
writeln!(stdout)?;
|
||||||
return Ok(Some(input.as_str().to_owned()));
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
|
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
|
||||||
}
|
}
|
||||||
EditorAction::Cancel => {
|
EditorAction::Cancel => {
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
writeln!(stdout)?;
|
writeln!(stdout)?;
|
||||||
return Ok(None);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_line_fallback(&self) -> io::Result<Option<String>> {
|
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)?;
|
||||||
stdout.flush()?;
|
stdout.flush()?;
|
||||||
@@ -137,22 +253,32 @@ impl LineEditor {
|
|||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
return Ok(None);
|
return Ok(ReadOutcome::Exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
||||||
buffer.pop();
|
buffer.pop();
|
||||||
}
|
}
|
||||||
Ok(Some(buffer))
|
Ok(ReadOutcome::Submit(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
||||||
match key {
|
match key {
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Char('c'),
|
code: KeyCode::Char('c'),
|
||||||
modifiers,
|
modifiers,
|
||||||
..
|
..
|
||||||
} if modifiers.contains(KeyModifiers::CONTROL) => EditorAction::Cancel,
|
} 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 {
|
KeyEvent {
|
||||||
code: KeyCode::Char('j'),
|
code: KeyCode::Char('j'),
|
||||||
modifiers,
|
modifiers,
|
||||||
@@ -194,6 +320,25 @@ impl LineEditor {
|
|||||||
input.move_right();
|
input.move_right();
|
||||||
EditorAction::Continue
|
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 {
|
KeyEvent {
|
||||||
code: KeyCode::Home,
|
code: KeyCode::Home,
|
||||||
..
|
..
|
||||||
@@ -211,6 +356,8 @@ impl LineEditor {
|
|||||||
code: KeyCode::Esc, ..
|
code: KeyCode::Esc, ..
|
||||||
} => {
|
} => {
|
||||||
input.clear();
|
input.clear();
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
EditorAction::Cancel
|
EditorAction::Cancel
|
||||||
}
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
@@ -219,22 +366,74 @@ impl LineEditor {
|
|||||||
..
|
..
|
||||||
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
||||||
input.insert(ch);
|
input.insert(ch);
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
EditorAction::Continue
|
EditorAction::Continue
|
||||||
}
|
}
|
||||||
_ => EditorAction::Continue,
|
_ => EditorAction::Continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw(&self, out: &mut impl Write, input: &InputBuffer) -> io::Result<()> {
|
fn navigate_history_up(&mut self, input: &mut InputBuffer) {
|
||||||
let display = input.as_str().replace('\n', "\\n\n> ");
|
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!(
|
queue!(
|
||||||
out,
|
out,
|
||||||
|
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
|
||||||
MoveToColumn(0),
|
MoveToColumn(0),
|
||||||
Clear(ClearType::CurrentLine),
|
|
||||||
Print(&self.prompt),
|
|
||||||
Print(display),
|
|
||||||
)?;
|
)?;
|
||||||
out.flush()
|
if rendered.cursor_row > 0 {
|
||||||
|
queue!(out, MoveDown(rendered.cursor_row))?;
|
||||||
|
}
|
||||||
|
queue!(out, MoveToColumn(rendered.cursor_col))?;
|
||||||
|
out.flush()?;
|
||||||
|
Ok(rendered.line_count())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,11 +442,76 @@ enum EditorAction {
|
|||||||
Continue,
|
Continue,
|
||||||
Submit,
|
Submit,
|
||||||
Cancel,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::InputBuffer;
|
use super::{render_buffer, InputBuffer, LineEditor};
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supports_basic_line_editing() {
|
fn supports_basic_line_editing() {
|
||||||
@@ -266,4 +530,119 @@ mod tests {
|
|||||||
assert_eq!(input.as_str(), "hix");
|
assert_eq!(input.as_str(), "hix");
|
||||||
assert_eq!(input.cursor(), 2);
|
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(),
|
||||||
|
"/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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use api::{
|
|||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
|
use commands::{
|
||||||
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
|
||||||
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use render::{Spinner, TerminalRenderer};
|
use render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -891,22 +893,35 @@ fn run_repl(
|
|||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
||||||
let editor = input::LineEditor::new("› ");
|
let mut editor = input::LineEditor::new("› ", slash_command_completion_candidates());
|
||||||
println!("{}", cli.startup_banner());
|
println!("{}", cli.startup_banner());
|
||||||
|
|
||||||
while let Some(input) = editor.read_line()? {
|
loop {
|
||||||
let trimmed = input.trim();
|
match editor.read_line()? {
|
||||||
|
input::ReadOutcome::Submit(input) => {
|
||||||
|
let trimmed = input.trim().to_string();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if matches!(trimmed, "/exit" | "/quit") {
|
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
||||||
|
cli.persist_session()?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(command) = SlashCommand::parse(trimmed) {
|
if let Some(command) = SlashCommand::parse(&trimmed) {
|
||||||
cli.handle_repl_command(command)?;
|
if cli.handle_repl_command(command)? {
|
||||||
|
cli.persist_session()?;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
cli.run_turn(trimmed)?;
|
editor.push_history(input);
|
||||||
|
cli.run_turn(&trimmed)?;
|
||||||
|
}
|
||||||
|
input::ReadOutcome::Cancel => {}
|
||||||
|
input::ReadOutcome::Exit => {
|
||||||
|
cli.persist_session()?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1066,28 +1081,60 @@ impl LiveCli {
|
|||||||
fn handle_repl_command(
|
fn handle_repl_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
command: SlashCommand,
|
command: SlashCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
match command {
|
Ok(match command {
|
||||||
SlashCommand::Help => println!("{}", render_repl_help()),
|
SlashCommand::Help => {
|
||||||
SlashCommand::Status => self.print_status(),
|
println!("{}", render_repl_help());
|
||||||
SlashCommand::Compact => self.compact()?,
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Status => {
|
||||||
|
self.print_status();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Compact => {
|
||||||
|
self.compact()?;
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Model { model } => self.set_model(model)?,
|
SlashCommand::Model { model } => self.set_model(model)?,
|
||||||
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
|
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
|
||||||
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
|
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
|
||||||
SlashCommand::Cost => self.print_cost(),
|
SlashCommand::Cost => {
|
||||||
|
self.print_cost();
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
|
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
|
||||||
SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
|
SlashCommand::Config { section } => {
|
||||||
SlashCommand::Memory => Self::print_memory()?,
|
Self::print_config(section.as_deref())?;
|
||||||
SlashCommand::Init => Self::run_init()?,
|
false
|
||||||
SlashCommand::Diff => Self::print_diff()?,
|
}
|
||||||
SlashCommand::Version => Self::print_version(),
|
SlashCommand::Memory => {
|
||||||
SlashCommand::Export { path } => self.export_session(path.as_deref())?,
|
Self::print_memory()?;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Init => {
|
||||||
|
Self::run_init()?;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Diff => {
|
||||||
|
Self::print_diff()?;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Version => {
|
||||||
|
Self::print_version();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Export { path } => {
|
||||||
|
self.export_session(path.as_deref())?;
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Session { action, target } => {
|
SlashCommand::Session { action, target } => {
|
||||||
self.handle_session_command(action.as_deref(), target.as_deref())?;
|
self.handle_session_command(action.as_deref(), target.as_deref())?
|
||||||
}
|
}
|
||||||
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
SlashCommand::Unknown(name) => {
|
||||||
|
eprintln!("unknown slash command: /{name}");
|
||||||
|
false
|
||||||
}
|
}
|
||||||
Ok(())
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -1115,7 +1162,7 @@ impl LiveCli {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let Some(model) = model else {
|
let Some(model) = model else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
@@ -1125,7 +1172,7 @@ impl LiveCli {
|
|||||||
self.runtime.usage().turns(),
|
self.runtime.usage().turns(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if model == self.model {
|
if model == self.model {
|
||||||
@@ -1137,7 +1184,7 @@ impl LiveCli {
|
|||||||
self.runtime.usage().turns(),
|
self.runtime.usage().turns(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let previous = self.model.clone();
|
let previous = self.model.clone();
|
||||||
@@ -1152,21 +1199,23 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
)?;
|
)?;
|
||||||
self.model.clone_from(&model);
|
self.model.clone_from(&model);
|
||||||
self.persist_session()?;
|
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format_model_switch_report(&previous, &model, message_count)
|
format_model_switch_report(&previous, &model, message_count)
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
fn set_permissions(
|
||||||
|
&mut self,
|
||||||
|
mode: Option<String>,
|
||||||
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let Some(mode) = mode else {
|
let Some(mode) = mode else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format_permissions_report(self.permission_mode.as_str())
|
format_permissions_report(self.permission_mode.as_str())
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
|
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
|
||||||
@@ -1177,7 +1226,7 @@ impl LiveCli {
|
|||||||
|
|
||||||
if normalized == self.permission_mode.as_str() {
|
if normalized == self.permission_mode.as_str() {
|
||||||
println!("{}", format_permissions_report(normalized));
|
println!("{}", format_permissions_report(normalized));
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let previous = self.permission_mode.as_str().to_string();
|
let previous = self.permission_mode.as_str().to_string();
|
||||||
@@ -1191,20 +1240,19 @@ impl LiveCli {
|
|||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()?;
|
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format_permissions_switch_report(&previous, normalized)
|
format_permissions_switch_report(&previous, normalized)
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_session(&mut self, confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
|
fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
if !confirm {
|
if !confirm {
|
||||||
println!(
|
println!(
|
||||||
"clear: confirmation required; run /clear --confirm to start a fresh session."
|
"clear: confirmation required; run /clear --confirm to start a fresh session."
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.session = create_managed_session_handle()?;
|
self.session = create_managed_session_handle()?;
|
||||||
@@ -1216,14 +1264,13 @@ impl LiveCli {
|
|||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()?;
|
|
||||||
println!(
|
println!(
|
||||||
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||||||
self.model,
|
self.model,
|
||||||
self.permission_mode.as_str(),
|
self.permission_mode.as_str(),
|
||||||
self.session.id,
|
self.session.id,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_cost(&self) {
|
fn print_cost(&self) {
|
||||||
@@ -1234,10 +1281,10 @@ impl LiveCli {
|
|||||||
fn resume_session(
|
fn resume_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
session_path: Option<String>,
|
session_path: Option<String>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let Some(session_ref) = session_path else {
|
let Some(session_ref) = session_path else {
|
||||||
println!("Usage: /resume <session-path>");
|
println!("Usage: /resume <session-path>");
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle = resolve_session_reference(&session_ref)?;
|
let handle = resolve_session_reference(&session_ref)?;
|
||||||
@@ -1252,7 +1299,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
)?;
|
)?;
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
self.persist_session()?;
|
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format_resume_report(
|
format_resume_report(
|
||||||
@@ -1261,7 +1307,7 @@ impl LiveCli {
|
|||||||
self.runtime.usage().turns(),
|
self.runtime.usage().turns(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -1306,16 +1352,16 @@ impl LiveCli {
|
|||||||
&mut self,
|
&mut self,
|
||||||
action: Option<&str>,
|
action: Option<&str>,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
match action {
|
match action {
|
||||||
None | Some("list") => {
|
None | Some("list") => {
|
||||||
println!("{}", render_session_list(&self.session.id)?);
|
println!("{}", render_session_list(&self.session.id)?);
|
||||||
Ok(())
|
Ok(false)
|
||||||
}
|
}
|
||||||
Some("switch") => {
|
Some("switch") => {
|
||||||
let Some(target) = target else {
|
let Some(target) = target else {
|
||||||
println!("Usage: /session switch <session-id>");
|
println!("Usage: /session switch <session-id>");
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let handle = resolve_session_reference(target)?;
|
let handle = resolve_session_reference(target)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
@@ -1329,18 +1375,17 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
)?;
|
)?;
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
self.persist_session()?;
|
|
||||||
println!(
|
println!(
|
||||||
"Session switched\n Active session {}\n File {}\n Messages {}",
|
"Session switched\n Active session {}\n File {}\n Messages {}",
|
||||||
self.session.id,
|
self.session.id,
|
||||||
self.session.path.display(),
|
self.session.path.display(),
|
||||||
message_count,
|
message_count,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
Some(other) => {
|
Some(other) => {
|
||||||
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
|
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
|
||||||
Ok(())
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1469,6 +1514,10 @@ fn render_repl_help() -> String {
|
|||||||
"REPL".to_string(),
|
"REPL".to_string(),
|
||||||
" /exit Quit the REPL".to_string(),
|
" /exit Quit the REPL".to_string(),
|
||||||
" /quit Quit the REPL".to_string(),
|
" /quit Quit the REPL".to_string(),
|
||||||
|
" Up/Down Navigate prompt history".to_string(),
|
||||||
|
" Tab Complete slash commands".to_string(),
|
||||||
|
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||||||
|
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
render_slash_command_help(),
|
render_slash_command_help(),
|
||||||
]
|
]
|
||||||
@@ -2089,6 +2138,63 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn slash_command_completion_candidates() -> Vec<String> {
|
||||||
|
slash_command_specs()
|
||||||
|
.iter()
|
||||||
|
.map(|spec| format!("/{}", spec.name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_tool_call_start(name: &str, input: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"Tool call
|
||||||
|
Name {name}
|
||||||
|
Input {}",
|
||||||
|
summarize_tool_payload(input)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
|
||||||
|
let status = if is_error { "error" } else { "ok" };
|
||||||
|
format!(
|
||||||
|
"### Tool `{name}`
|
||||||
|
|
||||||
|
- Status: {status}
|
||||||
|
- Output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
",
|
||||||
|
prettify_tool_payload(output)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_tool_payload(payload: &str) -> String {
|
||||||
|
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
|
||||||
|
Ok(value) => value.to_string(),
|
||||||
|
Err(_) => payload.trim().to_string(),
|
||||||
|
};
|
||||||
|
truncate_for_summary(&compact, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prettify_tool_payload(payload: &str) -> String {
|
||||||
|
match serde_json::from_str::<serde_json::Value>(payload) {
|
||||||
|
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()),
|
||||||
|
Err(_) => payload.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_for_summary(value: &str, limit: usize) -> String {
|
||||||
|
let mut chars = value.chars();
|
||||||
|
let truncated = chars.by_ref().take(limit).collect::<String>();
|
||||||
|
if chars.next().is_some() {
|
||||||
|
format!("{truncated}…")
|
||||||
|
} else {
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push_output_block(
|
fn push_output_block(
|
||||||
block: OutputContentBlock,
|
block: OutputContentBlock,
|
||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
@@ -2105,6 +2211,14 @@ fn push_output_block(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutputContentBlock::ToolUse { id, name, input } => {
|
OutputContentBlock::ToolUse { id, name, input } => {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"
|
||||||
|
{}",
|
||||||
|
format_tool_call_start(&name, &input.to_string())
|
||||||
|
)
|
||||||
|
.and_then(|()| out.flush())
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
*pending_tool = Some((id, name, input.to_string()));
|
*pending_tool = Some((id, name, input.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2164,13 +2278,19 @@ impl ToolExecutor for CliToolExecutor {
|
|||||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||||
match execute_tool(tool_name, &value) {
|
match execute_tool(tool_name, &value) {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
|
let markdown = format_tool_result(tool_name, &output, false);
|
||||||
self.renderer
|
self.renderer
|
||||||
.stream_markdown(&markdown, &mut io::stdout())
|
.stream_markdown(&markdown, &mut io::stdout())
|
||||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
Err(error) => Err(ToolError::new(error)),
|
Err(error) => {
|
||||||
|
let markdown = format_tool_result(tool_name, &error, true);
|
||||||
|
self.renderer
|
||||||
|
.stream_markdown(&markdown, &mut io::stdout())
|
||||||
|
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||||||
|
Err(ToolError::new(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2279,10 +2399,10 @@ mod tests {
|
|||||||
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
|
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
|
||||||
format_model_report, format_model_switch_report, format_permissions_report,
|
format_model_report, format_model_switch_report, format_permissions_report,
|
||||||
format_permissions_switch_report, format_resume_report, format_status_report,
|
format_permissions_switch_report, format_resume_report, format_status_report,
|
||||||
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
|
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
|
||||||
render_init_claude_md, render_memory_report, render_repl_help,
|
parse_git_status_metadata, render_config_report, render_init_claude_md,
|
||||||
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
|
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
|
||||||
StatusUsage, DEFAULT_MODEL,
|
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -2773,4 +2893,22 @@ mod tests {
|
|||||||
assert_eq!(converted[1].role, "assistant");
|
assert_eq!(converted[1].role, "assistant");
|
||||||
assert_eq!(converted[2].role, "user");
|
assert_eq!(converted[2].role, "user");
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn repl_help_mentions_history_completion_and_multiline() {
|
||||||
|
let help = render_repl_help();
|
||||||
|
assert!(help.contains("Up/Down"));
|
||||||
|
assert!(help.contains("Tab"));
|
||||||
|
assert!(help.contains("Shift+Enter/Ctrl+J"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_rendering_helpers_compact_output() {
|
||||||
|
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
|
||||||
|
assert!(start.contains("Tool call"));
|
||||||
|
assert!(start.contains("src/main.rs"));
|
||||||
|
|
||||||
|
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
|
||||||
|
assert!(done.contains("Tool `read_file`"));
|
||||||
|
assert!(done.contains("contents"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user