mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
91e27b7395
commit
e6725eb6d9
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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(¶meters)
|
||||
.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(¶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!();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user