Parcourir la source

Merge remote-tracking branch 'origin/rcc/cli' into dev/rust

# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
Yeachan-Heo il y a 2 mois
Parent
commit
c92403994d

+ 15 - 0
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 <session-id>]` — inspect or switch managed local sessions
 - `/exit` — leave the REPL
 
 ## Environment variables

+ 12 - 7
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(())

+ 400 - 21
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<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 {
     prompt: String,
+    continuation_prompt: String,
+    history: Vec<String>,
+    history_index: Option<usize>,
+    draft: Option<String>,
+    completions: Vec<String>,
 }
 
 impl LineEditor {
     #[must_use]
-    pub fn new(prompt: impl Into<String>) -> Self {
+    pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> 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<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() {
             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<Option<String>> {
+    fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
         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<usize> {
+        let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
+        if previous_line_count > 1 {
+            queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
+        }
+        queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
+        rendered.write(out)?;
         queue!(
             out,
+            MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
             MoveToColumn(0),
-            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());
+    }
 }

+ 198 - 60
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<dyn std::error::Error>> {
     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;
-        }
-        if matches!(trimmed, "/exit" | "/quit") {
-            break;
-        }
-        if let Some(command) = SlashCommand::parse(trimmed) {
-            cli.handle_repl_command(command)?;
-            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;
+            }
         }
-        cli.run_turn(trimmed)?;
     }
 
     Ok(())
@@ -1066,28 +1081,60 @@ impl LiveCli {
     fn handle_repl_command(
         &mut self,
         command: SlashCommand,
-    ) -> Result<(), Box<dyn std::error::Error>> {
-        match command {
-            SlashCommand::Help => println!("{}", render_repl_help()),
-            SlashCommand::Status => self.print_status(),
-            SlashCommand::Compact => self.compact()?,
+    ) -> Result<bool, Box<dyn std::error::Error>> {
+        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::Cost => {
+                self.print_cost();
+                false
+            }
             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::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())?;
+                self.handle_session_command(action.as_deref(), target.as_deref())?
             }
-            SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
-        }
-        Ok(())
+            SlashCommand::Unknown(name) => {
+                eprintln!("unknown slash command: /{name}");
+                false
+            }
+        })
     }
 
     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 {
             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<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 {
             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<dyn std::error::Error>> {
+    fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
         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<String>,
-    ) -> Result<(), Box<dyn std::error::Error>> {
+    ) -> Result<bool, Box<dyn std::error::Error>> {
         let Some(session_ref) = session_path else {
             println!("Usage: /resume <session-path>");
-            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<dyn std::error::Error>> {
@@ -1306,16 +1352,16 @@ impl LiveCli {
         &mut self,
         action: Option<&str>,
         target: Option<&str>,
-    ) -> Result<(), Box<dyn std::error::Error>> {
+    ) -> Result<bool, Box<dyn std::error::Error>> {
         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 <session-id>");
-                    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 <session-id>.");
-                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<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(
     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"));
+    }
 }