Skip to main content

rustant_tools/
canvas.rs

1//! Canvas tools — Agent-callable tools for pushing content to the canvas UI.
2//!
3//! Provides 5 tools: canvas_push, canvas_clear, canvas_update, canvas_snapshot, canvas_interact.
4
5use async_trait::async_trait;
6use rustant_core::canvas::{CanvasManager, CanvasMessage, CanvasTarget, ContentType};
7use rustant_core::error::ToolError;
8use rustant_core::types::{RiskLevel, ToolOutput};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::Mutex;
12
13use crate::registry::Tool;
14
15/// Shared canvas state for all canvas tools.
16pub type SharedCanvas = Arc<Mutex<CanvasManager>>;
17
18/// Create a new shared canvas manager.
19pub fn create_shared_canvas() -> SharedCanvas {
20    Arc::new(Mutex::new(CanvasManager::new()))
21}
22
23// --- canvas_push ---
24
25/// Tool to push content to the canvas.
26pub struct CanvasPushTool {
27    canvas: SharedCanvas,
28}
29
30impl CanvasPushTool {
31    pub fn new(canvas: SharedCanvas) -> Self {
32        Self { canvas }
33    }
34}
35
36#[async_trait]
37impl Tool for CanvasPushTool {
38    fn name(&self) -> &str {
39        "canvas_push"
40    }
41
42    fn description(&self) -> &str {
43        "Push content to the canvas UI. Supports HTML, Markdown, Code, Chart, Table, Form, Image, and Diagram content types."
44    }
45
46    fn parameters_schema(&self) -> serde_json::Value {
47        serde_json::json!({
48            "type": "object",
49            "properties": {
50                "content_type": {
51                    "type": "string",
52                    "enum": ["html", "markdown", "code", "chart", "table", "form", "image", "diagram"],
53                    "description": "The type of content to push"
54                },
55                "content": {
56                    "type": "string",
57                    "description": "The content to display"
58                },
59                "target": {
60                    "type": "string",
61                    "description": "Canvas target name (empty for broadcast)"
62                }
63            },
64            "required": ["content_type", "content"]
65        })
66    }
67
68    async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
69        let content_type_str =
70            args["content_type"]
71                .as_str()
72                .ok_or_else(|| ToolError::InvalidArguments {
73                    name: "canvas_push".into(),
74                    reason: "content_type is required".into(),
75                })?;
76        let content = args["content"]
77            .as_str()
78            .ok_or_else(|| ToolError::InvalidArguments {
79                name: "canvas".into(),
80                reason: "content is required".into(),
81            })?;
82        let target = match args["target"].as_str() {
83            Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
84            _ => CanvasTarget::Broadcast,
85        };
86        let ct = ContentType::from_str_loose(content_type_str).ok_or_else(|| {
87            ToolError::InvalidArguments {
88                name: "canvas".into(),
89                reason: format!("Unknown content_type: {}", content_type_str),
90            }
91        })?;
92
93        let mut canvas = self.canvas.lock().await;
94        let id = canvas.push(&target, ct, content.to_string()).map_err(|e| {
95            ToolError::ExecutionFailed {
96                name: "canvas".into(),
97                message: e.to_string(),
98            }
99        })?;
100
101        Ok(ToolOutput::text(format!(
102            "Content pushed to canvas (id: {})",
103            id
104        )))
105    }
106
107    fn risk_level(&self) -> RiskLevel {
108        RiskLevel::ReadOnly
109    }
110
111    fn timeout(&self) -> Duration {
112        Duration::from_secs(5)
113    }
114}
115
116// --- canvas_clear ---
117
118/// Tool to clear the canvas.
119pub struct CanvasClearTool {
120    canvas: SharedCanvas,
121}
122
123impl CanvasClearTool {
124    pub fn new(canvas: SharedCanvas) -> Self {
125        Self { canvas }
126    }
127}
128
129#[async_trait]
130impl Tool for CanvasClearTool {
131    fn name(&self) -> &str {
132        "canvas_clear"
133    }
134
135    fn description(&self) -> &str {
136        "Clear all content from the canvas."
137    }
138
139    fn parameters_schema(&self) -> serde_json::Value {
140        serde_json::json!({
141            "type": "object",
142            "properties": {
143                "target": {
144                    "type": "string",
145                    "description": "Canvas target to clear (empty for broadcast)"
146                }
147            }
148        })
149    }
150
151    async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
152        let target = match args["target"].as_str() {
153            Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
154            _ => CanvasTarget::Broadcast,
155        };
156
157        let mut canvas = self.canvas.lock().await;
158        canvas.clear(&target);
159        Ok(ToolOutput::text("Canvas cleared"))
160    }
161
162    fn risk_level(&self) -> RiskLevel {
163        RiskLevel::Write
164    }
165
166    fn timeout(&self) -> Duration {
167        Duration::from_secs(5)
168    }
169}
170
171// --- canvas_update ---
172
173/// Tool to update canvas content (push with update semantics).
174pub struct CanvasUpdateTool {
175    canvas: SharedCanvas,
176}
177
178impl CanvasUpdateTool {
179    pub fn new(canvas: SharedCanvas) -> Self {
180        Self { canvas }
181    }
182}
183
184#[async_trait]
185impl Tool for CanvasUpdateTool {
186    fn name(&self) -> &str {
187        "canvas_update"
188    }
189
190    fn description(&self) -> &str {
191        "Update content on the canvas (push updated content)."
192    }
193
194    fn parameters_schema(&self) -> serde_json::Value {
195        serde_json::json!({
196            "type": "object",
197            "properties": {
198                "content_type": {
199                    "type": "string",
200                    "enum": ["html", "markdown", "code", "chart", "table", "form", "image", "diagram"]
201                },
202                "content": {
203                    "type": "string",
204                    "description": "The updated content"
205                },
206                "target": {
207                    "type": "string",
208                    "description": "Canvas target name"
209                }
210            },
211            "required": ["content_type", "content"]
212        })
213    }
214
215    async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
216        let content_type_str =
217            args["content_type"]
218                .as_str()
219                .ok_or_else(|| ToolError::InvalidArguments {
220                    name: "canvas_push".into(),
221                    reason: "content_type is required".into(),
222                })?;
223        let content = args["content"]
224            .as_str()
225            .ok_or_else(|| ToolError::InvalidArguments {
226                name: "canvas".into(),
227                reason: "content is required".into(),
228            })?;
229        let target = match args["target"].as_str() {
230            Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
231            _ => CanvasTarget::Broadcast,
232        };
233        let ct = ContentType::from_str_loose(content_type_str).ok_or_else(|| {
234            ToolError::InvalidArguments {
235                name: "canvas".into(),
236                reason: format!("Unknown content_type: {}", content_type_str),
237            }
238        })?;
239
240        let mut canvas = self.canvas.lock().await;
241        let id = canvas.push(&target, ct, content.to_string()).map_err(|e| {
242            ToolError::ExecutionFailed {
243                name: "canvas".into(),
244                message: e.to_string(),
245            }
246        })?;
247
248        Ok(ToolOutput::text(format!("Canvas updated (id: {})", id)))
249    }
250
251    fn risk_level(&self) -> RiskLevel {
252        RiskLevel::Write
253    }
254
255    fn timeout(&self) -> Duration {
256        Duration::from_secs(5)
257    }
258}
259
260// --- canvas_snapshot ---
261
262/// Tool to get a snapshot of canvas state.
263pub struct CanvasSnapshotTool {
264    canvas: SharedCanvas,
265}
266
267impl CanvasSnapshotTool {
268    pub fn new(canvas: SharedCanvas) -> Self {
269        Self { canvas }
270    }
271}
272
273#[async_trait]
274impl Tool for CanvasSnapshotTool {
275    fn name(&self) -> &str {
276        "canvas_snapshot"
277    }
278
279    fn description(&self) -> &str {
280        "Get a snapshot of the current canvas state."
281    }
282
283    fn parameters_schema(&self) -> serde_json::Value {
284        serde_json::json!({
285            "type": "object",
286            "properties": {
287                "target": {
288                    "type": "string",
289                    "description": "Canvas target to snapshot"
290                }
291            }
292        })
293    }
294
295    async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
296        let target = match args["target"].as_str() {
297            Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
298            _ => CanvasTarget::Broadcast,
299        };
300
301        let canvas = self.canvas.lock().await;
302        let items = canvas.snapshot(&target);
303
304        let snapshot: Vec<serde_json::Value> = items
305            .iter()
306            .map(|item| {
307                serde_json::json!({
308                    "id": item.id.to_string(),
309                    "content_type": item.content_type,
310                    "content": item.content,
311                    "created_at": item.created_at.to_rfc3339(),
312                })
313            })
314            .collect();
315
316        let output = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "[]".into());
317        Ok(ToolOutput::text(output))
318    }
319
320    fn risk_level(&self) -> RiskLevel {
321        RiskLevel::ReadOnly
322    }
323
324    fn timeout(&self) -> Duration {
325        Duration::from_secs(5)
326    }
327}
328
329// --- canvas_interact ---
330
331/// Tool to send an interaction event to the canvas.
332#[allow(dead_code)]
333pub struct CanvasInteractTool {
334    canvas: SharedCanvas,
335}
336
337impl CanvasInteractTool {
338    pub fn new(canvas: SharedCanvas) -> Self {
339        Self { canvas }
340    }
341}
342
343#[async_trait]
344impl Tool for CanvasInteractTool {
345    fn name(&self) -> &str {
346        "canvas_interact"
347    }
348
349    fn description(&self) -> &str {
350        "Send an interaction event to the canvas (click, submit, etc.)."
351    }
352
353    fn parameters_schema(&self) -> serde_json::Value {
354        serde_json::json!({
355            "type": "object",
356            "properties": {
357                "action": {
358                    "type": "string",
359                    "description": "Interaction action (e.g. click, submit, select)"
360                },
361                "selector": {
362                    "type": "string",
363                    "description": "CSS selector or element ID"
364                },
365                "data": {
366                    "type": "object",
367                    "description": "Additional interaction data"
368                }
369            },
370            "required": ["action", "selector"]
371        })
372    }
373
374    async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
375        let action = args["action"]
376            .as_str()
377            .ok_or_else(|| ToolError::InvalidArguments {
378                name: "canvas_interact".into(),
379                reason: "action is required".into(),
380            })?;
381        let selector = args["selector"]
382            .as_str()
383            .ok_or_else(|| ToolError::InvalidArguments {
384                name: "canvas_interact".into(),
385                reason: "selector is required".into(),
386            })?;
387        let data = args.get("data").cloned().unwrap_or(serde_json::json!({}));
388
389        // Build the interact message (would be sent to connected UI clients)
390        let _msg = CanvasMessage::Interact {
391            action: action.into(),
392            selector: selector.into(),
393            data,
394        };
395
396        Ok(ToolOutput::text(format!(
397            "Interaction sent: {} on {}",
398            action, selector
399        )))
400    }
401
402    fn risk_level(&self) -> RiskLevel {
403        RiskLevel::Write
404    }
405
406    fn timeout(&self) -> Duration {
407        Duration::from_secs(5)
408    }
409}
410
411/// Register all canvas tools into a ToolRegistry.
412pub fn register_canvas_tools(registry: &mut crate::registry::ToolRegistry, canvas: SharedCanvas) {
413    let tools: Vec<Arc<dyn Tool>> = vec![
414        Arc::new(CanvasPushTool::new(canvas.clone())),
415        Arc::new(CanvasClearTool::new(canvas.clone())),
416        Arc::new(CanvasUpdateTool::new(canvas.clone())),
417        Arc::new(CanvasSnapshotTool::new(canvas.clone())),
418        Arc::new(CanvasInteractTool::new(canvas)),
419    ];
420
421    for tool in tools {
422        if let Err(e) = registry.register(tool) {
423            tracing::warn!("Failed to register canvas tool: {}", e);
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[tokio::test]
433    async fn test_canvas_push_tool() {
434        let canvas = create_shared_canvas();
435        let tool = CanvasPushTool::new(canvas.clone());
436
437        let result = tool
438            .execute(serde_json::json!({
439                "content_type": "html",
440                "content": "<h1>Hello</h1>"
441            }))
442            .await
443            .unwrap();
444
445        assert!(result.content.contains("pushed"));
446        let mgr = canvas.lock().await;
447        assert_eq!(mgr.total_items(), 1);
448    }
449
450    #[tokio::test]
451    async fn test_canvas_clear_tool() {
452        let canvas = create_shared_canvas();
453        {
454            let mut mgr = canvas.lock().await;
455            mgr.push(&CanvasTarget::Broadcast, ContentType::Html, "test".into())
456                .unwrap();
457        }
458
459        let tool = CanvasClearTool::new(canvas.clone());
460        let result = tool.execute(serde_json::json!({})).await.unwrap();
461        assert!(result.content.contains("cleared"));
462
463        let mgr = canvas.lock().await;
464        assert_eq!(mgr.total_items(), 0);
465    }
466
467    #[tokio::test]
468    async fn test_canvas_update_tool() {
469        let canvas = create_shared_canvas();
470        let tool = CanvasUpdateTool::new(canvas.clone());
471
472        let result = tool
473            .execute(serde_json::json!({
474                "content_type": "markdown",
475                "content": "# Updated"
476            }))
477            .await
478            .unwrap();
479
480        assert!(result.content.contains("updated"));
481    }
482
483    #[tokio::test]
484    async fn test_canvas_snapshot_tool() {
485        let canvas = create_shared_canvas();
486        {
487            let mut mgr = canvas.lock().await;
488            mgr.push(
489                &CanvasTarget::Broadcast,
490                ContentType::Html,
491                "<p>1</p>".into(),
492            )
493            .unwrap();
494            mgr.push(
495                &CanvasTarget::Broadcast,
496                ContentType::Code,
497                "let x = 1;".into(),
498            )
499            .unwrap();
500        }
501
502        let tool = CanvasSnapshotTool::new(canvas);
503        let result = tool.execute(serde_json::json!({})).await.unwrap();
504        let parsed: Vec<serde_json::Value> = serde_json::from_str(&result.content).unwrap();
505        assert_eq!(parsed.len(), 2);
506    }
507
508    #[tokio::test]
509    async fn test_canvas_interact_tool() {
510        let canvas = create_shared_canvas();
511        let tool = CanvasInteractTool::new(canvas);
512
513        let result = tool
514            .execute(serde_json::json!({
515                "action": "click",
516                "selector": "#submit-btn"
517            }))
518            .await
519            .unwrap();
520
521        assert!(result.content.contains("click"));
522        assert!(result.content.contains("#submit-btn"));
523    }
524
525    #[tokio::test]
526    async fn test_canvas_push_invalid_content_type() {
527        let canvas = create_shared_canvas();
528        let tool = CanvasPushTool::new(canvas);
529
530        let result = tool
531            .execute(serde_json::json!({
532                "content_type": "invalid",
533                "content": "test"
534            }))
535            .await;
536
537        assert!(result.is_err());
538    }
539}