1use std::path::PathBuf;
2
3use crate::event::{Block, RiskLevel};
6use crate::tools::{Tool, ToolCtx, ToolResult};
7
8pub struct BrowserAutomation;
11
12#[async_trait::async_trait]
13impl Tool for BrowserAutomation {
14 fn name(&self) -> &str {
15 "browser"
16 }
17 fn description(&self) -> &str {
18 "Control a Playwright headless browser to navigate pages, take screenshots, and interact with elements"
19 }
20 fn schema(&self) -> serde_json::Value {
21 crate::tools::browser_sandbox::BrowserTool.schema()
22 }
23 fn risk(&self) -> RiskLevel {
24 RiskLevel::Network
25 }
26 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
27 crate::tools::browser_sandbox::BrowserTool
28 .call(args, ctx)
29 .await
30 }
31}
32
33pub struct VisionInput;
36
37#[async_trait::async_trait]
38impl Tool for VisionInput {
39 fn name(&self) -> &str {
40 "vision"
41 }
42 fn description(&self) -> &str {
43 "Analyze an image file using the model's vision capabilities"
44 }
45 fn schema(&self) -> serde_json::Value {
46 serde_json::json!({
47 "type": "object",
48 "properties": {
49 "path": { "type": "string", "description": "Path to image file" },
50 "question": { "type": "string", "description": "Question about the image" }
51 },
52 "required": ["path"]
53 })
54 }
55 fn risk(&self) -> RiskLevel {
56 RiskLevel::ReadOnly
57 }
58 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
59 let path = args["path"].as_str().unwrap_or("");
60 let question = args["question"].as_str().unwrap_or("Describe this image");
61 let full_path = ctx.workspace_root.join(path);
62
63 if !full_path.exists() {
64 return Ok(ToolResult::error(format!("Image not found: {}", path)));
65 }
66
67 let data = std::fs::read(&full_path)?;
69 let mime = mime_guess::from_path(&full_path)
70 .first_or_octet_stream()
71 .to_string();
72 let _b64 = base64_encode(&data);
73
74 Ok(ToolResult::ok(vec![
75 Block::Text(format!(
76 "Image loaded: {} ({} bytes, {})\nQuestion: {}\n\nBase64 data ready for vision model.",
77 path,
78 data.len(),
79 mime,
80 question
81 )),
82 Block::Image { data, mime },
83 ]))
84 }
85}
86
87fn base64_encode(data: &[u8]) -> String {
88 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
89 let mut result = String::new();
90 for chunk in data.chunks(3) {
91 let b0 = chunk[0] as u32;
92 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
93 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
94 let triple = (b0 << 16) | (b1 << 8) | b2;
95 result.push(CHARS[(triple >> 18) as usize & 63] as char);
96 result.push(CHARS[(triple >> 12) as usize & 63] as char);
97 if chunk.len() > 1 {
98 result.push(CHARS[(triple >> 6) as usize & 63] as char);
99 } else {
100 result.push('=');
101 }
102 if chunk.len() > 2 {
103 result.push(CHARS[triple as usize & 63] as char);
104 } else {
105 result.push('=');
106 }
107 }
108 result
109}
110
111pub struct ImageGeneration;
114
115#[async_trait::async_trait]
116impl Tool for ImageGeneration {
117 fn name(&self) -> &str {
118 "image_gen"
119 }
120 fn description(&self) -> &str {
121 "Alias for image_generate: generate an image from a text prompt and save it into the workspace"
122 }
123 fn schema(&self) -> serde_json::Value {
124 serde_json::json!({
125 "type": "object",
126 "properties": {
127 "prompt": { "type": "string", "description": "Image generation prompt" },
128 "size": { "type": "string", "enum": ["512x512", "1024x1024", "1792x1024"] }
129 },
130 "required": ["prompt"]
131 })
132 }
133 fn risk(&self) -> RiskLevel {
134 RiskLevel::Network
135 }
136 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
137 crate::tools::media::ImageGen::new().call(args, ctx).await
138 }
139}
140
141pub struct TextToSpeech;
142
143#[async_trait::async_trait]
144impl Tool for TextToSpeech {
145 fn name(&self) -> &str {
146 "tts"
147 }
148 fn description(&self) -> &str {
149 "Alias for text_to_speech: synthesize speech from text and save an audio file into the workspace"
150 }
151 fn schema(&self) -> serde_json::Value {
152 serde_json::json!({
153 "type": "object",
154 "properties": {
155 "text": { "type": "string", "description": "Text to speak" },
156 "voice": { "type": "string", "description": "Voice name" }
157 },
158 "required": ["text"]
159 })
160 }
161 fn risk(&self) -> RiskLevel {
162 RiskLevel::Network
163 }
164 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
165 crate::tools::media::Tts::new().call(args, ctx).await
166 }
167}
168
169pub fn spawn_config_watcher(
172 config_path: PathBuf,
173 reload_tx: tokio::sync::mpsc::UnboundedSender<crate::config::Config>,
174) {
175 spawn_config_watcher_every(config_path, reload_tx, tokio::time::Duration::from_secs(5));
176}
177
178fn spawn_config_watcher_every(
179 config_path: PathBuf,
180 reload_tx: tokio::sync::mpsc::UnboundedSender<crate::config::Config>,
181 interval: tokio::time::Duration,
182) {
183 tokio::spawn(async move {
184 let mut last_mtime = std::fs::metadata(&config_path)
185 .and_then(|meta| meta.modified())
186 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
187 loop {
188 tokio::time::sleep(interval).await;
189 if let Ok(meta) = std::fs::metadata(&config_path) {
190 if let Ok(mtime) = meta.modified() {
191 if mtime > last_mtime {
192 last_mtime = mtime;
193 if let Ok(content) = std::fs::read_to_string(&config_path) {
194 if let Ok(cfg) = toml::from_str::<crate::config::Config>(&content) {
195 tracing::info!("Config reloaded from disk");
196 let _ = reload_tx.send(cfg);
197 }
198 }
199 }
200 }
201 }
202 }
203 });
204}
205
206pub fn ndjson_output(event: &crate::event::Event) -> String {
209 if !event.is_public() {
210 return String::new();
211 }
212 match serde_json::to_string(event) {
213 Ok(json) => format!("{}\n", json),
214 Err(e) => format!("{{\"error\":\"{}\"}}\n", e),
215 }
216}
217
218#[cfg(test)]
219mod watcher_tests {
220 use super::*;
221 use crate::config::{Config, FsConfigStore};
222 use crate::event::{Event, RunId};
223 use std::time::Duration;
224
225 #[test]
226 fn ndjson_output_filters_reasoning_delta() {
227 let line = ndjson_output(&Event::ReasoningDelta {
228 run: RunId::new(),
229 text: " internal chain fragment".into(),
230 });
231
232 assert!(
233 line.is_empty(),
234 "ReasoningDelta must stay out of public NDJSON streams"
235 );
236 }
237
238 #[tokio::test]
239 async fn config_watcher_reloads_after_file_change() {
240 use crate::config::ConfigStore;
241
242 let tmp = tempfile::tempdir().expect("tmp");
243 let store = FsConfigStore::new(tmp.path().to_path_buf());
244 let mut cfg = Config {
245 theme: "captain".into(),
246 ..Config::default()
247 };
248 store.save(&cfg).expect("initial config save");
249
250 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
251 spawn_config_watcher_every(
252 tmp.path().join("config.toml"),
253 tx,
254 Duration::from_millis(25),
255 );
256
257 tokio::time::sleep(Duration::from_millis(60)).await;
258 assert!(
259 rx.try_recv().is_err(),
260 "watcher should not emit a reload for an unchanged file"
261 );
262
263 cfg.theme = "paper".into();
264 store.save(&cfg).expect("updated config save");
265
266 let updated = tokio::time::timeout(Duration::from_secs(2), rx.recv())
267 .await
268 .expect("config reload should arrive")
269 .expect("watcher channel should stay open");
270
271 assert_eq!(updated.theme, "paper");
272 }
273}
274
275use std::sync::Arc;
278use tokio::sync::Mutex;
279
280pub struct SessionBridge {
281 pub session_id: String,
282 pub active_surface: Mutex<String>,
283 pub pending_approvals: Mutex<Vec<crate::gateway::GatewayResponse>>,
284 pub engine: Option<Arc<crate::engine::Engine>>,
285}
286
287impl SessionBridge {
288 pub fn new() -> Self {
289 Self {
290 session_id: uuid::Uuid::new_v4().to_string(),
291 active_surface: Mutex::new("cli".into()),
292 pending_approvals: Mutex::new(Vec::new()),
293 engine: None,
294 }
295 }
296
297 pub async fn set_surface(&self, surface: &str) {
298 *self.active_surface.lock().await = surface.to_string();
299 }
300
301 pub async fn add_approval(&self, response: crate::gateway::GatewayResponse) {
302 self.pending_approvals.lock().await.push(response);
303 }
304
305 pub async fn drain_approvals(&self) -> Vec<crate::gateway::GatewayResponse> {
306 self.pending_approvals.lock().await.drain(..).collect()
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::ndjson_output;
313
314 #[test]
315 fn ndjson_output_suppresses_internal_reasoning_delta() {
316 let event = crate::event::Event::ReasoningDelta {
317 run: crate::event::RunId("run-1".into()),
318 text: " hidden provider state".into(),
319 };
320
321 assert_eq!(ndjson_output(&event), "");
322 assert!(!event.is_public());
323 }
324
325 #[test]
326 fn ndjson_output_keeps_public_events() {
327 let event = crate::event::Event::ThinkingDelta {
328 run: crate::event::RunId("run-1".into()),
329 text: "visible answer".into(),
330 };
331
332 let line = ndjson_output(&event);
333 assert!(line.contains("\"ThinkingDelta\""));
334 assert!(line.ends_with('\n'));
335 assert!(event.is_public());
336 }
337}