|
|
@@ -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"));
|
|
|
+ }
|
|
|
}
|