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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-09 05:42:41 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 91e27b7395
commit e6725eb6d9
7 changed files with 245 additions and 62 deletions
+95
View File
@@ -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::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.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;
+2
View File
@@ -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.
+139 -62
View File
@@ -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::<Vec<_>>()
.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(&parameters)
.unwrap_or_else(|_| parameters.to_string());
let params_truncated = if params_preview.chars().count() > 200 {
format!(
"{}...",
params_preview.chars().take(200).collect::<String>()
)
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(&parameters, " \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!();
+5
View File
@@ -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(),
+1
View File
@@ -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 {
+1
View File
@@ -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",
+2
View File
@@ -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")]