use async_trait::async_trait;
use mecha_core::tool::{Approver, Decision, Tool};
use serde_json::Value;
use std::collections::HashSet;
use std::io::Write;
use std::sync::Mutex;
#[derive(Default)]
pub struct TerminalApprover {
always: Mutex<HashSet<String>>,
}
#[async_trait]
impl Approver for TerminalApprover {
async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision {
if self.always.lock().unwrap().contains(tool.name()) {
return Decision::Allow;
}
let name = tool.name().to_string();
let summary = summarize(&name, input);
let prompt = format!("\n {name} {summary}\n allow? [y]es / [a]lways / [n]o / [q]uit > ");
let answer = tokio::task::spawn_blocking(move || {
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) | Err(_) => "n".to_string(),
Ok(_) => line.trim().to_lowercase(),
}
})
.await
.unwrap_or_else(|_| "n".to_string());
match answer.chars().next() {
Some('y') | None => Decision::Allow,
Some('a') => {
self.always.lock().unwrap().insert(tool.name().to_string());
Decision::Allow
}
Some('q') => Decision::Deny("the user stopped the run".into()),
_ => Decision::Deny("the user declined this call".into()),
}
}
}
pub fn summarize(tool: &str, input: &Value) -> String {
let field = |key: &str| input.get(key).and_then(Value::as_str);
let text = match tool {
"shell" => field("command").map(str::to_string),
"fs_write" | "fs_edit" | "fs_read" | "fs_list" => field("path").map(str::to_string),
"http_fetch" => field("url").map(str::to_string),
_ => None,
}
.unwrap_or_else(|| {
serde_json::to_string(input).unwrap_or_default()
});
let flat = text.replace('\n', " ");
if flat.chars().count() > 100 {
format!("{}…", flat.chars().take(100).collect::<String>())
} else {
flat
}
}