From e6725eb6d94694a147afeb6f78ef06d648f2fd87 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 8 Feb 2026 21:42:41 -0800 Subject: [PATCH] feat: Improve CLI (#5) * Start working on improved CLI * Add tool result previews, boxed approval card, and polished help screen REPL iteration 2: styled /help with grouped sections, box-drawing approval card with colored params, dim separator before responses, inline tool output previews via new StatusUpdate::ToolResult variant. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/agent/agent_loop.rs | 95 +++++++++++++++++ src/channels/channel.rs | 2 + src/channels/repl.rs | 201 ++++++++++++++++++++++++----------- src/channels/wasm/wrapper.rs | 5 + src/channels/web/mod.rs | 1 + src/channels/web/sse.rs | 1 + src/channels/web/types.rs | 2 + 7 files changed, 245 insertions(+), 62 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 9b66a6b4..76cefdf9 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -26,6 +26,23 @@ use crate::safety::SafetyLayer; use crate::tools::ToolRegistry; use crate::workspace::Workspace; +/// Collapse a tool output string into a single-line preview for display. +fn truncate_for_preview(output: &str, max_chars: usize) -> String { + let collapsed: String = output + .chars() + .take(max_chars + 50) + .map(|c| if c == '\n' { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if collapsed.len() > max_chars { + format!("{}...", &collapsed[..max_chars]) + } else { + collapsed + } +} + /// Result of the agentic loop execution. enum AgenticLoopResult { /// Completed with a response. @@ -877,10 +894,49 @@ impl Agent { } } + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::ToolStarted { + name: tc.name.clone(), + }, + &message.metadata, + ) + .await; + let tool_result = self .execute_chat_tool(&tc.name, &tc.arguments, &job_ctx) .await; + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::ToolCompleted { + name: tc.name.clone(), + success: tool_result.is_ok(), + }, + &message.metadata, + ) + .await; + + if let Ok(ref output) = tool_result { + if !output.is_empty() { + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::ToolResult { + name: tc.name.clone(), + preview: truncate_for_preview(output, 200), + }, + &message.metadata, + ) + .await; + } + } + // Record result in thread { let mut sess = session.lock().await; @@ -1256,10 +1312,49 @@ impl Agent { let job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::ToolStarted { + name: pending.tool_name.clone(), + }, + &message.metadata, + ) + .await; + let tool_result = self .execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx) .await; + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::ToolCompleted { + name: pending.tool_name.clone(), + success: tool_result.is_ok(), + }, + &message.metadata, + ) + .await; + + if let Ok(ref output) = tool_result { + if !output.is_empty() { + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::ToolResult { + name: pending.tool_name.clone(), + preview: truncate_for_preview(output, 200), + }, + &message.metadata, + ) + .await; + } + } + // Build context including the tool result let mut context_messages = pending.context_messages; diff --git a/src/channels/channel.rs b/src/channels/channel.rs index 8292f6be..c5575ccd 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -108,6 +108,8 @@ pub enum StatusUpdate { ToolStarted { name: String }, /// Tool execution completed. ToolCompleted { name: String, success: bool }, + /// Brief preview of tool execution output. + ToolResult { name: String, preview: String }, /// Streaming text chunk. StreamChunk(String), /// General status message. diff --git a/src/channels/repl.rs b/src/channels/repl.rs index dc3aed96..ef5db362 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -112,9 +112,50 @@ fn make_skin() -> MadSkin { .set_fg(termimad::crossterm::style::Color::Green); skin.code_block .set_fg(termimad::crossterm::style::Color::Green); + skin.code_block.left_margin = 2; skin } +/// Format JSON params as `key: value` lines for the approval card. +fn format_json_params(params: &serde_json::Value, indent: &str) -> String { + match params { + serde_json::Value::Object(map) => { + let mut lines = Vec::new(); + for (key, value) in map { + let val_str = match value { + serde_json::Value::String(s) => { + let display = if s.len() > 120 { &s[..120] } else { s }; + format!("\x1b[32m\"{display}\"\x1b[0m") + } + other => { + let rendered = other.to_string(); + if rendered.len() > 120 { + format!("{}...", &rendered[..120]) + } else { + rendered + } + } + }; + lines.push(format!("{indent}\x1b[36m{key}\x1b[0m: {val_str}")); + } + lines.join("\n") + } + other => { + let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()); + let truncated = if pretty.len() > 300 { + format!("{}...", &pretty[..300]) + } else { + pretty + }; + truncated + .lines() + .map(|l| format!("{indent}\x1b[90m{l}\x1b[0m")) + .collect::>() + .join("\n") + } + } +} + /// REPL channel with line editing and markdown rendering. pub struct ReplChannel { /// Optional single message to send (for -m flag). @@ -156,32 +197,33 @@ impl Default for ReplChannel { } fn print_help() { - println!( - r#" -IronClaw REPL + // Bold white for section headers, bold cyan for commands, dim gray for descriptions + let h = "\x1b[1m"; // bold (section headers) + let c = "\x1b[1;36m"; // bold cyan (commands) + let d = "\x1b[90m"; // dim gray (descriptions) + let r = "\x1b[0m"; // reset -Commands: - /help Show this help message - /quit, /exit Exit the REPL - /debug Toggle debug mode (verbose output) - /undo Undo the last turn - /redo Redo an undone turn - /clear Clear the conversation - /compact Compact the context window - /new Start a new conversation thread - /interrupt Stop the current operation - -Approval responses (when prompted): - yes, y Approve the tool execution - no, n Deny the tool execution - always Approve and auto-approve this tool for the session - -Tips: - - Tool calls requiring approval will pause and wait for your response - - Use /debug to see detailed tool inputs and outputs - - Press Ctrl+C to interrupt a long-running operation -"# - ); + println!(); + println!(" {h}IronClaw REPL{r}"); + println!(); + println!(" {h}Commands{r}"); + println!(" {c}/help{r} {d}show this help{r}"); + println!(" {c}/debug{r} {d}toggle verbose output{r}"); + println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}"); + println!(); + println!(" {h}Conversation{r}"); + println!(" {c}/undo{r} {d}undo the last turn{r}"); + println!(" {c}/redo{r} {d}redo an undone turn{r}"); + println!(" {c}/clear{r} {d}clear conversation{r}"); + println!(" {c}/compact{r} {d}compact context window{r}"); + println!(" {c}/new{r} {d}new conversation thread{r}"); + println!(" {c}/interrupt{r} {d}stop current operation{r}"); + println!(); + println!(" {h}Approval responses{r}"); + println!(" {c}yes{r} ({c}y{r}) {d}approve tool execution{r}"); + println!(" {c}no{r} ({c}n{r}) {d}deny tool execution{r}"); + println!(" {c}always{r} ({c}a{r}) {d}approve for this session{r}"); + println!(); } /// Get the history file path (~/.ironclaw/history). @@ -236,14 +278,14 @@ impl Channel for ReplChannel { } let _ = rl.load_history(&hist_path); - println!("IronClaw REPL - Type /help for commands, /quit to exit"); + println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit"); println!(); loop { let prompt = if debug_mode.load(Ordering::Relaxed) { - "\x1b[33m[debug]\x1b[0m \x1b[36m>\x1b[0m " + "\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m " } else { - "\x1b[36m>\x1b[0m " + "\x1b[1;36m\u{203A}\x1b[0m " }; match rl.readline(prompt) { @@ -264,9 +306,9 @@ impl Channel for ReplChannel { let current = debug_mode.load(Ordering::Relaxed); debug_mode.store(!current, Ordering::Relaxed); if !current { - println!("Debug mode ON - showing verbose tool output"); + println!("\x1b[90mdebug mode on\x1b[0m"); } else { - println!("Debug mode OFF"); + println!("\x1b[90mdebug mode off\x1b[0m"); } continue; } @@ -310,6 +352,10 @@ impl Channel for ReplChannel { _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + // If we were streaming, the content was already printed via StreamChunk. // Just finish the line and reset. if self.is_streaming.swap(false, Ordering::Relaxed) { @@ -318,14 +364,14 @@ impl Channel for ReplChannel { return Ok(()); } + // Dim separator line before the response + let sep_width = width.min(80); + eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width)); + // Render markdown let skin = make_skin(); - let width = crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(80); let text = termimad::FmtText::from(&skin, &response.content, Some(width)); - println!(); print!("{text}"); println!(); Ok(()) @@ -340,28 +386,36 @@ impl Channel for ReplChannel { match status { StatusUpdate::Thinking(msg) => { - if debug { - eprintln!("\x1b[90m[thinking] {msg}\x1b[0m"); - } + eprintln!(" \x1b[90m\u{25CB} {msg}\x1b[0m"); } StatusUpdate::ToolStarted { name } => { - eprintln!(" \x1b[33m>> {name}\x1b[0m"); + eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m"); } StatusUpdate::ToolCompleted { name, success } => { if success { - eprintln!(" \x1b[32m<< {name}\x1b[0m"); + eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m"); } else { - eprintln!(" \x1b[31m<< {name} failed\x1b[0m"); + eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m"); } } + StatusUpdate::ToolResult { name: _, preview } => { + eprintln!(" \x1b[90m{preview}\x1b[0m"); + } StatusUpdate::StreamChunk(chunk) => { - self.is_streaming.store(true, Ordering::Relaxed); + // Print separator on the false-to-true transition + if !self.is_streaming.swap(true, Ordering::Relaxed) { + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + let sep_width = width.min(80); + eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width)); + } print!("{chunk}"); let _ = io::stdout().flush(); } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { - eprintln!("\x1b[90m[status] {msg}\x1b[0m"); + eprintln!(" \x1b[90m{msg}\x1b[0m"); } } StatusUpdate::ApprovalNeeded { @@ -370,29 +424,52 @@ impl Channel for ReplChannel { description, parameters, } => { - let params_preview = serde_json::to_string_pretty(¶meters) - .unwrap_or_else(|_| parameters.to_string()); - let params_truncated = if params_preview.chars().count() > 200 { - format!( - "{}...", - params_preview.chars().take(200).collect::() - ) + let term_width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + let box_width = (term_width.saturating_sub(4)).clamp(40, 60); + + // Short request ID for the bottom border + let short_id = if request_id.len() > 8 { + &request_id[..8] } else { - params_preview + &request_id }; - eprintln!(); - eprintln!("\x1b[33m Tool requires approval\x1b[0m"); - eprintln!(" \x1b[1mTool:\x1b[0m {tool_name}"); - eprintln!(" \x1b[1mDesc:\x1b[0m {description}"); - eprintln!( - " \x1b[1mParams:\x1b[0m\n {}", - params_truncated.replace('\n', "\n ") + + // Top border: ┌ tool_name requires approval ─── + let top_label = format!(" {tool_name} requires approval "); + let top_fill = box_width.saturating_sub(top_label.len() + 1); + let top_border = format!( + "\u{250C}\x1b[33m{top_label}\x1b[0m{}", + "\u{2500}".repeat(top_fill) ); - eprintln!(); - eprintln!( - " Reply: \x1b[32myes\x1b[0m / \x1b[34malways\x1b[0m / \x1b[31mno\x1b[0m" + + // Bottom border: └─ short_id ───── + let bot_label = format!(" {short_id} "); + let bot_fill = box_width.saturating_sub(bot_label.len() + 2); + let bot_border = format!( + "\u{2514}\u{2500}\x1b[90m{bot_label}\x1b[0m{}", + "\u{2500}".repeat(bot_fill) ); - eprintln!(" \x1b[90mRequest ID: {request_id}\x1b[0m"); + + eprintln!(); + eprintln!(" {top_border}"); + eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m"); + eprintln!(" \u{2502}"); + + // Params + let param_lines = format_json_params(¶meters, " \u{2502} "); + // The format_json_params already includes the indent prefix + // but we need to handle the case where each line already starts with it + for line in param_lines.lines() { + eprintln!("{line}"); + } + + eprintln!(" \u{2502}"); + eprintln!( + " \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)" + ); + eprintln!(" {bot_border}"); eprintln!(); } } @@ -409,7 +486,7 @@ impl Channel for ReplChannel { .map(|(w, _)| w as usize) .unwrap_or(80); - eprintln!("\x1b[36m[notification]\x1b[0m"); + eprintln!("\x1b[34m\u{25CF}\x1b[0m notification"); let text = termimad::FmtText::from(&skin, &response.content, Some(width)); eprint!("{text}"); eprintln!(); diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index c70e9845..14a33a24 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1826,6 +1826,11 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha message: format!("{}: {}", name, if *success { "ok" } else { "failed" }), metadata_json, }, + StatusUpdate::ToolResult { name, preview } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::ToolCompleted, + message: format!("{}: {}", name, preview), + metadata_json, + }, StatusUpdate::StreamChunk(chunk) => wit_channel::StatusUpdate { status: wit_channel::StatusType::Thinking, message: chunk.clone(), diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 2eb23c86..46590402 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -208,6 +208,7 @@ impl Channel for GatewayChannel { StatusUpdate::ToolCompleted { name, success } => { SseEvent::ToolCompleted { name, success } } + StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview }, StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content }, StatusUpdate::Status(msg) => SseEvent::Status { message: msg }, StatusUpdate::ApprovalNeeded { diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 132748fe..240c9c2b 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -75,6 +75,7 @@ impl SseManager { SseEvent::Thinking { .. } => "thinking", SseEvent::ToolStarted { .. } => "tool_started", SseEvent::ToolCompleted { .. } => "tool_completed", + SseEvent::ToolResult { .. } => "tool_result", SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::Status { .. } => "status", SseEvent::ApprovalNeeded { .. } => "approval_needed", diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index ee5e6122..85420211 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -78,6 +78,8 @@ pub enum SseEvent { ToolStarted { name: String }, #[serde(rename = "tool_completed")] ToolCompleted { name: String, success: bool }, + #[serde(rename = "tool_result")] + ToolResult { name: String, preview: String }, #[serde(rename = "stream_chunk")] StreamChunk { content: String }, #[serde(rename = "status")]