diff --git a/rust/README.md b/rust/README.md index 86fb561..f5fb366 100644 --- a/rust/README.md +++ b/rust/README.md @@ -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" ``` +Restrict enabled tools in an interactive session: + +```bash +cd rust +cargo run -p rusty-claude-cli -- --allowedTools read,glob +``` + ### 2) REPL mode Start the interactive shell: @@ -123,6 +130,10 @@ Inside the REPL, useful commands include: /memory /config /init +/diff +/version +/export notes.txt +/session list /exit ``` @@ -169,6 +180,10 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config - `/config [env|hooks|model]` — inspect discovered Claude config - `/memory` — inspect loaded instruction memory files - `/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 ]` — inspect or switch managed local sessions - `/exit` — leave the REPL ## Environment variables diff --git a/rust/crates/rusty-claude-cli/src/app.rs b/rust/crates/rusty-claude-cli/src/app.rs index 253c288..b2864a3 100644 --- a/rust/crates/rusty-claude-cli/src/app.rs +++ b/rust/crates/rusty-claude-cli/src/app.rs @@ -2,7 +2,7 @@ use std::io::{self, Write}; use std::path::PathBuf; use crate::args::{OutputFormat, PermissionMode}; -use crate::input::LineEditor; +use crate::input::{LineEditor, ReadOutcome}; use crate::render::{Spinner, TerminalRenderer}; use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary}; @@ -111,16 +111,21 @@ impl CliApp { } 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!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); - while let Some(input) = editor.read_line()? { - if input.trim().is_empty() { - continue; + loop { + match editor.read_line()? { + ReadOutcome::Submit(input) => { + if input.trim().is_empty() { + continue; + } + self.handle_submission(&input, &mut io::stdout())?; + } + ReadOutcome::Cancel => continue, + ReadOutcome::Exit => break, } - - self.handle_submission(&input, &mut io::stdout())?; } Ok(()) diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index 3ca982e..bca3791 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -1,9 +1,8 @@ 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::queue; -use crossterm::style::Print; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -85,21 +84,124 @@ impl InputBuffer { self.buffer.clear(); self.cursor = 0; } + + pub fn replace(&mut self, value: impl Into) { + 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::>(); + 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, + 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 { prompt: String, + continuation_prompt: String, + history: Vec, + history_index: Option, + draft: Option, + completions: Vec, } impl LineEditor { #[must_use] - pub fn new(prompt: impl Into) -> Self { + pub fn new(prompt: impl Into, completions: Vec) -> Self { Self { prompt: prompt.into(), + continuation_prompt: String::from("> "), + history: Vec::new(), + history_index: None, + draft: None, + completions, } } - pub fn read_line(&self) -> io::Result> { + pub fn push_history(&mut self, entry: impl Into) { + 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 { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return self.read_line_fallback(); } @@ -107,29 +209,43 @@ impl LineEditor { enable_raw_mode()?; let mut stdout = io::stdout(); let mut input = InputBuffer::new(); - self.redraw(&mut stdout, &input)?; + let mut rendered_lines = 1usize; + self.redraw(&mut stdout, &input, rendered_lines)?; loop { let event = event::read()?; if let Event::Key(key) = event { - match Self::handle_key(key, &mut input) { - EditorAction::Continue => self.redraw(&mut stdout, &input)?, + match self.handle_key(key, &mut input) { + EditorAction::Continue => { + rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?; + } EditorAction::Submit => { disable_raw_mode()?; writeln!(stdout)?; - 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 => { disable_raw_mode()?; 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> { + fn read_line_fallback(&self) -> io::Result { let mut stdout = io::stdout(); write!(stdout, "{}", self.prompt)?; stdout.flush()?; @@ -137,22 +253,32 @@ impl LineEditor { let mut buffer = String::new(); let bytes_read = io::stdin().read_line(&mut buffer)?; if bytes_read == 0 { - return Ok(None); + return Ok(ReadOutcome::Exit); } while matches!(buffer.chars().last(), Some('\n' | '\r')) { 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 { KeyEvent { code: KeyCode::Char('c'), 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 { code: KeyCode::Char('j'), modifiers, @@ -194,6 +320,25 @@ impl LineEditor { 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, .. @@ -211,6 +356,8 @@ impl LineEditor { code: KeyCode::Esc, .. } => { input.clear(); + self.history_index = None; + self.draft = None; EditorAction::Cancel } KeyEvent { @@ -219,22 +366,74 @@ impl LineEditor { .. } if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => { input.insert(ch); + self.history_index = None; + self.draft = None; EditorAction::Continue } _ => EditorAction::Continue, } } - fn redraw(&self, out: &mut impl Write, input: &InputBuffer) -> io::Result<()> { - let display = input.as_str().replace('\n', "\\n\n> "); + 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 { + 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), - 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, Submit, Cancel, + Exit, +} + +#[must_use] +pub fn render_buffer( + prompt: &str, + continuation_prompt: &str, + input: &InputBuffer, +) -> RenderedBuffer { + let before_cursor = &input.as_str()[..input.cursor]; + let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count()); + let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default(); + let cursor_prompt = if cursor_row == 0 { + prompt + } else { + continuation_prompt + }; + let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count()); + + let mut lines = Vec::new(); + for (index, line) in input.as_str().split('\n').enumerate() { + let prefix = if index == 0 { + prompt + } else { + continuation_prompt + }; + lines.push(format!("{prefix}{line}")); + } + if lines.is_empty() { + lines.push(prompt.to_string()); + } + + RenderedBuffer { + lines, + cursor_row, + cursor_col, + } +} + +#[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)] 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] fn supports_basic_line_editing() { @@ -266,4 +530,119 @@ mod tests { 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(), + "/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()); + } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1293d98..df35543 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16,7 +16,9 @@ use api::{ 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 render::{Spinner, TerminalRenderer}; use runtime::{ @@ -891,22 +893,35 @@ fn run_repl( permission_mode: PermissionMode, ) -> Result<(), Box> { 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()); - while let Some(input) = editor.read_line()? { - let trimmed = input.trim(); - if trimmed.is_empty() { - continue; + loop { + match editor.read_line()? { + input::ReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.persist_session()?; + break; + } + if let Some(command) = SlashCommand::parse(&trimmed) { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + editor.push_history(input); + cli.run_turn(&trimmed)?; + } + input::ReadOutcome::Cancel => {} + input::ReadOutcome::Exit => { + cli.persist_session()?; + break; + } } - if matches!(trimmed, "/exit" | "/quit") { - break; - } - if let Some(command) = SlashCommand::parse(trimmed) { - cli.handle_repl_command(command)?; - continue; - } - cli.run_turn(trimmed)?; } Ok(()) @@ -1066,28 +1081,60 @@ impl LiveCli { fn handle_repl_command( &mut self, command: SlashCommand, - ) -> Result<(), Box> { - match command { - SlashCommand::Help => println!("{}", render_repl_help()), - SlashCommand::Status => self.print_status(), - SlashCommand::Compact => self.compact()?, + ) -> Result> { + Ok(match command { + SlashCommand::Help => { + println!("{}", render_repl_help()); + false + } + SlashCommand::Status => { + self.print_status(); + false + } + SlashCommand::Compact => { + self.compact()?; + false + } SlashCommand::Model { model } => self.set_model(model)?, SlashCommand::Permissions { mode } => self.set_permissions(mode)?, SlashCommand::Clear { confirm } => self.clear_session(confirm)?, - SlashCommand::Cost => self.print_cost(), - SlashCommand::Resume { session_path } => self.resume_session(session_path)?, - SlashCommand::Config { section } => Self::print_config(section.as_deref())?, - SlashCommand::Memory => Self::print_memory()?, - SlashCommand::Init => Self::run_init()?, - SlashCommand::Diff => Self::print_diff()?, - SlashCommand::Version => Self::print_version(), - SlashCommand::Export { path } => self.export_session(path.as_deref())?, - SlashCommand::Session { action, target } => { - self.handle_session_command(action.as_deref(), target.as_deref())?; + SlashCommand::Cost => { + self.print_cost(); + false } - SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), - } - Ok(()) + SlashCommand::Resume { session_path } => self.resume_session(session_path)?, + SlashCommand::Config { section } => { + Self::print_config(section.as_deref())?; + false + } + SlashCommand::Memory => { + 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 } => { + self.handle_session_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Unknown(name) => { + eprintln!("unknown slash command: /{name}"); + false + } + }) } fn persist_session(&self) -> Result<(), Box> { @@ -1115,7 +1162,7 @@ impl LiveCli { ); } - fn set_model(&mut self, model: Option) -> Result<(), Box> { + fn set_model(&mut self, model: Option) -> Result> { let Some(model) = model else { println!( "{}", @@ -1125,7 +1172,7 @@ impl LiveCli { self.runtime.usage().turns(), ) ); - return Ok(()); + return Ok(false); }; if model == self.model { @@ -1137,7 +1184,7 @@ impl LiveCli { self.runtime.usage().turns(), ) ); - return Ok(()); + return Ok(false); } let previous = self.model.clone(); @@ -1152,21 +1199,23 @@ impl LiveCli { self.permission_mode, )?; self.model.clone_from(&model); - self.persist_session()?; println!( "{}", format_model_switch_report(&previous, &model, message_count) ); - Ok(()) + Ok(true) } - fn set_permissions(&mut self, mode: Option) -> Result<(), Box> { + fn set_permissions( + &mut self, + mode: Option, + ) -> Result> { let Some(mode) = mode else { println!( "{}", format_permissions_report(self.permission_mode.as_str()) ); - return Ok(()); + return Ok(false); }; let normalized = normalize_permission_mode(&mode).ok_or_else(|| { @@ -1177,7 +1226,7 @@ impl LiveCli { if normalized == self.permission_mode.as_str() { println!("{}", format_permissions_report(normalized)); - return Ok(()); + return Ok(false); } let previous = self.permission_mode.as_str().to_string(); @@ -1191,20 +1240,19 @@ impl LiveCli { self.allowed_tools.clone(), self.permission_mode, )?; - self.persist_session()?; println!( "{}", format_permissions_switch_report(&previous, normalized) ); - Ok(()) + Ok(true) } - fn clear_session(&mut self, confirm: bool) -> Result<(), Box> { + fn clear_session(&mut self, confirm: bool) -> Result> { if !confirm { println!( "clear: confirmation required; run /clear --confirm to start a fresh session." ); - return Ok(()); + return Ok(false); } self.session = create_managed_session_handle()?; @@ -1216,14 +1264,13 @@ impl LiveCli { self.allowed_tools.clone(), self.permission_mode, )?; - self.persist_session()?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, self.permission_mode.as_str(), self.session.id, ); - Ok(()) + Ok(true) } fn print_cost(&self) { @@ -1234,10 +1281,10 @@ impl LiveCli { fn resume_session( &mut self, session_path: Option, - ) -> Result<(), Box> { + ) -> Result> { let Some(session_ref) = session_path else { println!("Usage: /resume "); - return Ok(()); + return Ok(false); }; let handle = resolve_session_reference(&session_ref)?; @@ -1252,7 +1299,6 @@ impl LiveCli { self.permission_mode, )?; self.session = handle; - self.persist_session()?; println!( "{}", format_resume_report( @@ -1261,7 +1307,7 @@ impl LiveCli { self.runtime.usage().turns(), ) ); - Ok(()) + Ok(true) } fn print_config(section: Option<&str>) -> Result<(), Box> { @@ -1306,16 +1352,16 @@ impl LiveCli { &mut self, action: Option<&str>, target: Option<&str>, - ) -> Result<(), Box> { + ) -> Result> { match action { None | Some("list") => { println!("{}", render_session_list(&self.session.id)?); - Ok(()) + Ok(false) } Some("switch") => { let Some(target) = target else { println!("Usage: /session switch "); - return Ok(()); + return Ok(false); }; let handle = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; @@ -1329,18 +1375,17 @@ impl LiveCli { self.permission_mode, )?; self.session = handle; - self.persist_session()?; println!( "Session switched\n Active session {}\n File {}\n Messages {}", self.session.id, self.session.path.display(), message_count, ); - Ok(()) + Ok(true) } Some(other) => { println!("Unknown /session action '{other}'. Use /session list or /session switch ."); - Ok(()) + Ok(false) } } } @@ -1469,6 +1514,10 @@ fn render_repl_help() -> String { "REPL".to_string(), " /exit 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(), render_slash_command_help(), ] @@ -2089,6 +2138,63 @@ impl ApiClient for AnthropicRuntimeClient { } } +fn slash_command_completion_candidates() -> Vec { + 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::(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::(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::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + fn push_output_block( block: OutputContentBlock, out: &mut impl Write, @@ -2105,6 +2211,14 @@ fn push_output_block( } } 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())); } } @@ -2164,13 +2278,19 @@ impl ToolExecutor for CliToolExecutor { .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; match execute_tool(tool_name, &value) { 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 .stream_markdown(&markdown, &mut io::stdout()) .map_err(|error| ToolError::new(error.to_string()))?; 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, format_model_report, format_model_switch_report, format_permissions_report, format_permissions_switch_report, format_resume_report, format_status_report, - normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, - render_init_claude_md, render_memory_report, render_repl_help, - resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, - StatusUsage, DEFAULT_MODEL, + format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, + parse_git_status_metadata, render_config_report, render_init_claude_md, + render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, + CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use std::path::{Path, PathBuf}; @@ -2773,4 +2893,22 @@ mod tests { assert_eq!(converted[1].role, "assistant"); 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")); + } }