mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: full image support across all channels End-to-end image handling: upload, generation, analysis, editing, and rendering across web gateway, HTTP webhook, WASM (Telegram/Slack), and REPL channels. Builds on the attachment infrastructure from #596 and draws inspiration from PR #641's image pipeline approach — credit to that PR's author for the sentinel JSON pattern and base64-in-JSON upload design. Key changes: - Image upload in web UI (file picker, paste, preview strip) - Image generation tool (FLUX/DALL-E via /v1/images/generations) - Image edit tool (multipart /v1/images/edits with fallback) - Image analysis tool (vision model for workspace images) - Model detection utilities (image_models.rs, vision_models.rs) - Sentinel JSON detection in dispatcher for generated image rendering - StatusUpdate::ImageGenerated → SSE/WS/REPL/WASM broadcast - HTTP webhook attachment support (base64, 5MB/file, 10MB total) - WASM channel image download (Telegram via file API, Slack via host HTTP) - Tool registration wiring in app.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #725 review comments (16 issues) - SecretString for API keys in all image tools (image_gen, image_edit, image_analyze) - Binary image read via tokio::fs::read instead of DB-backed workspace.read() - Replace Arc<Workspace> with Option<PathBuf> base_dir (workspace has no filesystem API) - ApprovalRequirement::UnlessAutoApproved for cost-sensitive image tools - Scope sentinel detection to image_generate/image_edit tool names only - Skip ToolResult preview broadcast for image sentinels (avoids multi-MB base64 in SSE) - Extract shared media_type_from_path() to builtin/mod.rs - Rename fallback_chat_edit → fallback_generate with tracing::warn - Increase gateway body limit from 1MB to 10MB for image uploads - Increase webhook body limit to 15MB (base64 overhead) - Log warning on invalid base64 in images_to_attachments - Client-side image size limits (5MB/file, 5 images max) in app.js - aria-label on attach button for accessibility - Update body_too_large test for new 10MB limit [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Slack file size check before download (PR review item #15) Skip downloading files larger than 20 MB in the Slack WASM channel to avoid excessive memory use and slow downloads in the WASM runtime. Logs a warning when a file is skipped. Also bumps channel versions for Slack and Telegram (prior branch changes). [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(security): add path validation and approval requirement to image tools Add sandbox path validation via validate_path() to both ImageAnalyzeTool and ImageEditTool to prevent path traversal attacks that could exfiltrate arbitrary files through external vision/edit APIs. Also fix ImageAnalyzeTool::requires_approval to return UnlessAutoApproved, consistent with ImageEditTool and ImageGenerateTool. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: post-download size guards and empty data_url sentinel check - Slack: add post-download size check on actual bytes when metadata size_bytes is absent, preventing bypass of the 20MB limit - Telegram: add 20MB download size limit (matching Slack) enforced in download_telegram_file() after receiving response bytes - Dispatcher: skip broadcasting ImageGenerated SSE event when data_url is empty from unwrap_or_default(), log warning instead Closes correctness issues #3, #4, #5 from PR #725 review. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use mime_guess for media type detection, add alt attrs and media_type validation - Replace hardcoded media type mapping with mime_guess crate (already in deps) - Add alt attributes to img elements in web UI for accessibility - Validate media_type starts with "image/" in images_to_attachments() - Update bmp test assertion to match mime_guess behavior Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Zaki <[email protected]>
665 lines
23 KiB
Rust
665 lines
23 KiB
Rust
//! Interactive REPL channel with line editing and markdown rendering.
|
|
//!
|
|
//! Provides the primary CLI interface for interacting with the agent.
|
|
//! Uses rustyline for line editing, history, and tab-completion.
|
|
//! Uses termimad for rendering markdown responses inline.
|
|
//!
|
|
//! ## Commands
|
|
//!
|
|
//! - `/help` - Show available commands
|
|
//! - `/quit` or `/exit` - Exit the REPL
|
|
//! - `/debug` - Toggle debug mode (verbose tool output)
|
|
//! - `/undo` - Undo the last turn
|
|
//! - `/redo` - Redo an undone turn
|
|
//! - `/clear` - Clear the conversation
|
|
//! - `/compact` - Compact the context
|
|
//! - `/new` - Start a new thread
|
|
//! - `yes`/`no`/`always` - Respond to tool approval prompts
|
|
//! - `Esc` - Interrupt current operation
|
|
|
|
use std::borrow::Cow;
|
|
use std::io::{self, IsTerminal, Write};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use async_trait::async_trait;
|
|
use rustyline::completion::Completer;
|
|
use rustyline::config::Config;
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::highlight::Highlighter;
|
|
use rustyline::hint::Hinter;
|
|
use rustyline::validate::Validator;
|
|
use rustyline::{
|
|
Cmd as ReadlineCmd, CompletionType, ConditionalEventHandler, Editor, Event, EventContext,
|
|
EventHandler, Helper, KeyCode, KeyEvent, Modifiers, RepeatCount,
|
|
};
|
|
use termimad::MadSkin;
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
use crate::agent::truncate_for_preview;
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
|
use crate::error::ChannelError;
|
|
|
|
/// Max characters for tool result previews in the terminal.
|
|
const CLI_TOOL_RESULT_MAX: usize = 200;
|
|
|
|
/// Max characters for thinking/status messages in the terminal.
|
|
const CLI_STATUS_MAX: usize = 200;
|
|
|
|
/// Slash commands available in the REPL.
|
|
const SLASH_COMMANDS: &[&str] = &[
|
|
"/help",
|
|
"/quit",
|
|
"/exit",
|
|
"/debug",
|
|
"/model",
|
|
"/undo",
|
|
"/redo",
|
|
"/clear",
|
|
"/compact",
|
|
"/new",
|
|
"/interrupt",
|
|
"/version",
|
|
"/tools",
|
|
"/ping",
|
|
"/job",
|
|
"/status",
|
|
"/cancel",
|
|
"/list",
|
|
"/heartbeat",
|
|
"/summarize",
|
|
"/suggest",
|
|
"/thread",
|
|
"/resume",
|
|
];
|
|
|
|
/// Rustyline helper for slash-command tab completion.
|
|
struct ReplHelper;
|
|
|
|
impl Completer for ReplHelper {
|
|
type Candidate = String;
|
|
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
pos: usize,
|
|
_ctx: &rustyline::Context<'_>,
|
|
) -> rustyline::Result<(usize, Vec<String>)> {
|
|
if !line.starts_with('/') {
|
|
return Ok((0, vec![]));
|
|
}
|
|
|
|
let prefix = &line[..pos];
|
|
let matches: Vec<String> = SLASH_COMMANDS
|
|
.iter()
|
|
.filter(|cmd| cmd.starts_with(prefix))
|
|
.map(|cmd| cmd.to_string())
|
|
.collect();
|
|
|
|
Ok((0, matches))
|
|
}
|
|
}
|
|
|
|
impl Hinter for ReplHelper {
|
|
type Hint = String;
|
|
|
|
fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
|
|
if !line.starts_with('/') || pos < line.len() {
|
|
return None;
|
|
}
|
|
|
|
SLASH_COMMANDS
|
|
.iter()
|
|
.find(|cmd| cmd.starts_with(line) && **cmd != line)
|
|
.map(|cmd| cmd[line.len()..].to_string())
|
|
}
|
|
}
|
|
|
|
impl Highlighter for ReplHelper {
|
|
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
|
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
|
|
}
|
|
}
|
|
|
|
impl Validator for ReplHelper {}
|
|
impl Helper for ReplHelper {}
|
|
|
|
struct EscInterruptHandler {
|
|
triggered: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl ConditionalEventHandler for EscInterruptHandler {
|
|
fn handle(
|
|
&self,
|
|
_evt: &Event,
|
|
_n: RepeatCount,
|
|
_positive: bool,
|
|
_ctx: &EventContext,
|
|
) -> Option<ReadlineCmd> {
|
|
self.triggered.store(true, Ordering::Relaxed);
|
|
Some(ReadlineCmd::Interrupt)
|
|
}
|
|
}
|
|
|
|
/// Build a termimad skin with our color scheme.
|
|
fn make_skin() -> MadSkin {
|
|
let mut skin = MadSkin::default();
|
|
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
|
|
skin.bold.set_fg(termimad::crossterm::style::Color::White);
|
|
skin.italic
|
|
.set_fg(termimad::crossterm::style::Color::Magenta);
|
|
skin.inline_code
|
|
.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).
|
|
single_message: Option<String>,
|
|
/// Debug mode flag (shared with input thread).
|
|
debug_mode: Arc<AtomicBool>,
|
|
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
|
is_streaming: Arc<AtomicBool>,
|
|
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
|
suppress_banner: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl ReplChannel {
|
|
/// Create a new REPL channel.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
single_message: None,
|
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
|
suppress_banner: Arc::new(AtomicBool::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Create a REPL channel that sends a single message and exits.
|
|
pub fn with_message(message: String) -> Self {
|
|
Self {
|
|
single_message: Some(message),
|
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
|
suppress_banner: Arc::new(AtomicBool::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Suppress the one-liner startup banner (boot screen will be shown instead).
|
|
pub fn suppress_banner(&self) {
|
|
self.suppress_banner.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
fn is_debug(&self) -> bool {
|
|
self.debug_mode.load(Ordering::Relaxed)
|
|
}
|
|
}
|
|
|
|
impl Default for ReplChannel {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
fn print_help() {
|
|
// 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
|
|
|
|
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!(" {c}esc{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).
|
|
fn history_path() -> std::path::PathBuf {
|
|
ironclaw_base_dir().join("history")
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for ReplChannel {
|
|
fn name(&self) -> &str {
|
|
"repl"
|
|
}
|
|
|
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
let (tx, rx) = mpsc::channel(32);
|
|
let single_message = self.single_message.clone();
|
|
let debug_mode = Arc::clone(&self.debug_mode);
|
|
let suppress_banner = Arc::clone(&self.suppress_banner);
|
|
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
|
|
|
std::thread::spawn(move || {
|
|
let sys_tz = crate::timezone::detect_system_timezone().name().to_string();
|
|
|
|
// Single message mode: send it and return
|
|
if let Some(msg) = single_message {
|
|
let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(incoming);
|
|
// Ensure the agent exits after handling exactly one turn in -m mode,
|
|
// even when other channels (gateway/http) are enabled.
|
|
let _ = tx.blocking_send(IncomingMessage::new("repl", "default", "/quit"));
|
|
return;
|
|
}
|
|
|
|
// Set up rustyline
|
|
let config = Config::builder()
|
|
.history_ignore_dups(true)
|
|
.expect("valid config")
|
|
.auto_add_history(true)
|
|
.completion_type(CompletionType::List)
|
|
.build();
|
|
|
|
let mut rl = match Editor::with_config(config) {
|
|
Ok(editor) => editor,
|
|
Err(e) => {
|
|
eprintln!("Failed to initialize line editor: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
rl.set_helper(Some(ReplHelper));
|
|
|
|
rl.bind_sequence(
|
|
KeyEvent(KeyCode::Esc, Modifiers::NONE),
|
|
EventHandler::Conditional(Box::new(EscInterruptHandler {
|
|
triggered: Arc::clone(&esc_interrupt_triggered_for_thread),
|
|
})),
|
|
);
|
|
|
|
// Load history
|
|
let hist_path = history_path();
|
|
if let Some(parent) = hist_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let _ = rl.load_history(&hist_path);
|
|
|
|
if !suppress_banner.load(Ordering::Relaxed) {
|
|
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[1;36m\u{203A}\x1b[0m "
|
|
} else {
|
|
"\x1b[1;36m\u{203A}\x1b[0m "
|
|
};
|
|
|
|
match rl.readline(prompt) {
|
|
Ok(line) => {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Handle local REPL commands (only commands that need
|
|
// immediate local handling stay here)
|
|
match line.to_lowercase().as_str() {
|
|
"/quit" | "/exit" => {
|
|
// Forward shutdown command so the agent loop exits even
|
|
// when other channels (e.g. web gateway) are still active.
|
|
let msg = IncomingMessage::new("repl", "default", "/quit")
|
|
.with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(msg);
|
|
break;
|
|
}
|
|
"/help" => {
|
|
print_help();
|
|
continue;
|
|
}
|
|
"/debug" => {
|
|
let current = debug_mode.load(Ordering::Relaxed);
|
|
debug_mode.store(!current, Ordering::Relaxed);
|
|
if !current {
|
|
println!("\x1b[90mdebug mode on\x1b[0m");
|
|
} else {
|
|
println!("\x1b[90mdebug mode off\x1b[0m");
|
|
}
|
|
continue;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let msg =
|
|
IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz);
|
|
if tx.blocking_send(msg).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Err(ReadlineError::Interrupted) => {
|
|
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
|
|
// Esc: interrupt current operation and keep REPL open.
|
|
let msg = IncomingMessage::new("repl", "default", "/interrupt")
|
|
.with_timezone(&sys_tz);
|
|
if tx.blocking_send(msg).is_err() {
|
|
break;
|
|
}
|
|
} else {
|
|
// Ctrl+C (VINTR): request graceful shutdown.
|
|
let msg = IncomingMessage::new("repl", "default", "/quit")
|
|
.with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(msg);
|
|
break;
|
|
}
|
|
}
|
|
Err(ReadlineError::Eof) => {
|
|
// Ctrl+D in interactive mode: graceful shutdown.
|
|
// In daemon mode (stdin = /dev/null, no TTY), EOF arrives
|
|
// immediately — just drop the REPL thread silently so other
|
|
// channels (gateway, telegram, …) keep running.
|
|
if std::io::stdin().is_terminal() {
|
|
let msg = IncomingMessage::new("repl", "default", "/quit")
|
|
.with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(msg);
|
|
}
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Input error: {e}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save history on exit
|
|
let _ = rl.save_history(&history_path());
|
|
});
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
_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) {
|
|
println!();
|
|
println!();
|
|
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 text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
|
|
|
print!("{text}");
|
|
println!();
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
_metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
let debug = self.is_debug();
|
|
|
|
match status {
|
|
StatusUpdate::Thinking(msg) => {
|
|
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
|
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
|
|
}
|
|
StatusUpdate::ToolStarted { name } => {
|
|
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
|
}
|
|
StatusUpdate::ToolCompleted { name, success, .. } => {
|
|
if success {
|
|
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
|
} else {
|
|
eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m");
|
|
}
|
|
}
|
|
StatusUpdate::ToolResult { name: _, preview } => {
|
|
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
|
eprintln!(" \x1b[90m{display}\x1b[0m");
|
|
}
|
|
StatusUpdate::StreamChunk(chunk) => {
|
|
// 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::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
} => {
|
|
eprintln!(
|
|
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
|
|
);
|
|
}
|
|
StatusUpdate::Status(msg) => {
|
|
if debug || msg.contains("approval") || msg.contains("Approval") {
|
|
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
|
eprintln!(" \x1b[90m{display}\x1b[0m");
|
|
}
|
|
}
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
} => {
|
|
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 {
|
|
&request_id
|
|
};
|
|
|
|
// 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)
|
|
);
|
|
|
|
// 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!();
|
|
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!();
|
|
}
|
|
StatusUpdate::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
setup_url,
|
|
..
|
|
} => {
|
|
eprintln!();
|
|
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
|
|
if let Some(ref instr) = instructions {
|
|
eprintln!(" {instr}");
|
|
}
|
|
if let Some(ref url) = setup_url {
|
|
eprintln!(" \x1b[4m{url}\x1b[0m");
|
|
}
|
|
eprintln!();
|
|
}
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
} => {
|
|
if success {
|
|
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
|
|
} else {
|
|
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
|
|
}
|
|
}
|
|
StatusUpdate::ImageGenerated { path, .. } => {
|
|
if let Some(ref p) = path {
|
|
eprintln!("\x1b[36m [image] {p}\x1b[0m");
|
|
} else {
|
|
eprintln!("\x1b[36m [image generated]\x1b[0m");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn broadcast(
|
|
&self,
|
|
_user_id: &str,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let skin = make_skin();
|
|
let width = crossterm::terminal::size()
|
|
.map(|(w, _)| w as usize)
|
|
.unwrap_or(80);
|
|
|
|
eprintln!("\x1b[34m\u{25CF}\x1b[0m notification");
|
|
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
|
eprint!("{text}");
|
|
eprintln!();
|
|
Ok(())
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use futures::StreamExt;
|
|
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn single_message_mode_sends_message_then_quit() {
|
|
let repl = ReplChannel::with_message("hi".to_string());
|
|
let mut stream = repl.start().await.expect("repl start should succeed");
|
|
|
|
let first = stream.next().await.expect("first message missing");
|
|
assert_eq!(first.channel, "repl");
|
|
assert_eq!(first.content, "hi");
|
|
|
|
let second = stream.next().await.expect("quit message missing");
|
|
assert_eq!(second.channel, "repl");
|
|
assert_eq!(second.content, "/quit");
|
|
|
|
assert!(
|
|
stream.next().await.is_none(),
|
|
"stream should end after /quit"
|
|
);
|
|
}
|
|
}
|