From 6a7cea810ee24596ed48e67ac57a17a72305d6d4 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 23:40:57 +0000 Subject: [PATCH 1/2] Clarify the expanded CLI surface for local parity The branch already carries the new local slash commands and flag behavior, so this follow-up captures how to use them from the Rust README. That keeps the documented REPL and resume workflows aligned with the verified binary surface after the implementation and green verification pass. Constraint: Keep scope narrow and avoid touching ignored .omx planning artifacts Constraint: Documentation must reflect the active handwritten parser in main.rs Rejected: Re-open parser refactors in args.rs | outside the requested bounded change Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep README command examples aligned with main.rs help output when CLI flags or slash commands change Tested: cargo run -p rusty-claude-cli -- --version; cargo run -p rusty-claude-cli -- --help; cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test Not-tested: Interactive REPL manual slash-command session in a live API-backed conversation --- rust/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rust/README.md b/rust/README.md index 52b798d..2fd10bd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -82,6 +82,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: @@ -103,6 +110,10 @@ Inside the REPL, useful commands include: /memory /config /init +/diff +/version +/export notes.txt +/session list /exit ``` @@ -149,6 +160,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 From 8d4a739c05547c5211ae32d7de8bc278020f8aea Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 00:14:38 +0000 Subject: [PATCH 2/2] Make the REPL resilient enough for real interactive workflows The custom crossterm editor now supports prompt history, slash-command tab completion, multiline editing, and Ctrl-C semantics that clear partial input without always terminating the session. The live REPL loop now distinguishes buffer cancellation from clean exit, persists session state on meaningful boundaries, and renders tool activity in a more structured way for terminal use. Constraint: Keep the active REPL on the existing crossterm path without adding a line-editor dependency Rejected: Swap to rustyline or reedline | broader integration risk than this polish pass justifies Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep editor state logic generic in input.rs and leave REPL policy decisions in main.rs Tested: cargo fmt --manifest-path rust/Cargo.toml --all; cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings; cargo test --manifest-path rust/Cargo.toml Not-tested: Interactive manual terminal smoke test for arrow keys/tab/Ctrl-C in a live TTY --- rust/crates/rusty-claude-cli/src/app.rs | 19 +- rust/crates/rusty-claude-cli/src/input.rs | 421 ++++++++++++++++++++-- rust/crates/rusty-claude-cli/src/main.rs | 262 ++++++++++---- 3 files changed, 612 insertions(+), 90 deletions(-) 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 a8a28bd..3002bf9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -14,7 +14,9 @@ use api::{ 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::{ @@ -716,22 +718,35 @@ fn run_repl( allowed_tools: Option, ) -> Result<(), Box> { let mut cli = LiveCli::new(model, true, allowed_tools)?; - 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(()) @@ -885,28 +900,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> { @@ -934,7 +981,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!( "{}", @@ -944,7 +991,7 @@ impl LiveCli { self.runtime.usage().turns(), ) ); - return Ok(()); + return Ok(false); }; if model == self.model { @@ -956,7 +1003,7 @@ impl LiveCli { self.runtime.usage().turns(), ) ); - return Ok(()); + return Ok(false); } let previous = self.model.clone(); @@ -970,18 +1017,20 @@ impl LiveCli { self.allowed_tools.clone(), )?; 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(permission_mode_label())); - return Ok(()); + return Ok(false); }; let normalized = normalize_permission_mode(&mode).ok_or_else(|| { @@ -992,7 +1041,7 @@ impl LiveCli { if normalized == permission_mode_label() { println!("{}", format_permissions_report(normalized)); - return Ok(()); + return Ok(false); } let previous = permission_mode_label().to_string(); @@ -1005,20 +1054,19 @@ impl LiveCli { self.allowed_tools.clone(), normalized, )?; - 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()?; @@ -1030,14 +1078,13 @@ impl LiveCli { self.allowed_tools.clone(), permission_mode_label(), )?; - self.persist_session()?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, permission_mode_label(), self.session.id, ); - Ok(()) + Ok(true) } fn print_cost(&self) { @@ -1048,10 +1095,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)?; @@ -1066,7 +1113,6 @@ impl LiveCli { permission_mode_label(), )?; self.session = handle; - self.persist_session()?; println!( "{}", format_resume_report( @@ -1075,7 +1121,7 @@ impl LiveCli { self.runtime.usage().turns(), ) ); - Ok(()) + Ok(true) } fn print_config(section: Option<&str>) -> Result<(), Box> { @@ -1120,16 +1166,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)?; @@ -1143,18 +1189,17 @@ impl LiveCli { permission_mode_label(), )?; 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) } } } @@ -1283,6 +1328,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(), ] @@ -1866,6 +1915,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, @@ -1882,6 +1988,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())); } } @@ -1941,13 +2055,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)) + } } } } @@ -2051,10 +2171,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}; use std::path::{Path, PathBuf}; @@ -2516,4 +2636,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")); + } }