Skip to main content

rustyclaw_core/gateway/
canvas_handler.rs

1//! Canvas tool execution handler for the gateway.
2
3use std::sync::Arc;
4use tokio::sync::Mutex;
5use tracing::{debug, instrument, warn};
6
7use crate::canvas::CanvasHost;
8
9pub type SharedCanvasHost = Arc<Mutex<CanvasHost>>;
10
11/// Check if a tool name is a canvas tool.
12pub fn is_canvas_tool(name: &str) -> bool {
13    name.starts_with("canvas_")
14}
15
16/// Execute a canvas tool call.
17#[instrument(skip(args, canvas, session), fields(%name))]
18pub async fn execute_canvas_tool(
19    name: &str,
20    args: &serde_json::Value,
21    canvas: &SharedCanvasHost,
22    session: &str,
23) -> Result<String, String> {
24    debug!("Executing canvas tool");
25
26    let host = canvas.lock().await;
27
28    match name {
29        "canvas_present" => {
30            // Show the canvas (for node-based canvases, this would signal the node)
31            let url = host.canvas_url(session);
32            Ok(format!("Canvas available at: {}", url))
33        }
34
35        "canvas_hide" => {
36            // Hide the canvas (node signal)
37            Ok("Canvas hidden".to_string())
38        }
39
40        "canvas_navigate" => {
41            let url = args
42                .get("url")
43                .and_then(|v| v.as_str())
44                .ok_or_else(|| "Missing required parameter: url".to_string())?;
45
46            // For local paths, write a redirect or serve directly
47            if url.starts_with("http://") || url.starts_with("https://") {
48                Ok(format!("Navigated to external URL: {}", url))
49            } else {
50                let canvas_url = format!(
51                    "{}{}",
52                    host.canvas_url(session),
53                    url.trim_start_matches('/')
54                );
55                Ok(format!("Navigated to: {}", canvas_url))
56            }
57        }
58
59        "canvas_write" => {
60            let path = args
61                .get("path")
62                .and_then(|v| v.as_str())
63                .ok_or_else(|| "Missing required parameter: path".to_string())?;
64            let content = args
65                .get("content")
66                .and_then(|v| v.as_str())
67                .ok_or_else(|| "Missing required parameter: content".to_string())?;
68
69            drop(host); // Release lock before async operation
70            let host = canvas.lock().await;
71
72            match host.write_file(session, path, content.as_bytes()).await {
73                Ok(file_path) => Ok(format!("Wrote canvas file: {}", file_path.display())),
74                Err(e) => Err(format!("Failed to write canvas file: {}", e)),
75            }
76        }
77
78        "canvas_a2ui_push" => {
79            let text = args.get("text").and_then(|v| v.as_str());
80            let jsonl = args.get("jsonl").and_then(|v| v.as_str());
81
82            drop(host); // Release lock
83            let host = canvas.lock().await;
84
85            if let Some(text) = text {
86                // Simple text push
87                match host.push_text(session, text).await {
88                    Ok(()) => Ok("A2UI text pushed".to_string()),
89                    Err(e) => Err(format!("Failed to push A2UI: {}", e)),
90                }
91            } else if let Some(jsonl) = jsonl {
92                // Parse JSONL and push
93                let messages: Result<Vec<crate::canvas::A2UIMessage>, _> = jsonl
94                    .lines()
95                    .filter(|l| !l.trim().is_empty())
96                    .map(|l| serde_json::from_str(l))
97                    .collect();
98
99                match messages {
100                    Ok(msgs) => match host.push_a2ui(session, msgs).await {
101                        Ok(()) => Ok("A2UI messages pushed".to_string()),
102                        Err(e) => Err(format!("Failed to push A2UI: {}", e)),
103                    },
104                    Err(e) => Err(format!("Invalid A2UI JSONL: {}", e)),
105                }
106            } else {
107                Err("Either 'text' or 'jsonl' parameter required".to_string())
108            }
109        }
110
111        "canvas_a2ui_reset" => {
112            drop(host);
113            let host = canvas.lock().await;
114
115            match host.reset_a2ui(session).await {
116                Ok(()) => Ok("A2UI state reset".to_string()),
117                Err(e) => Err(format!("Failed to reset A2UI: {}", e)),
118            }
119        }
120
121        "canvas_snapshot" => {
122            drop(host);
123            let host = canvas.lock().await;
124
125            match host.snapshot(session).await {
126                Ok(data) => {
127                    // Return base64-encoded image
128                    let b64 =
129                        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
130                    Ok(format!("data:image/png;base64,{}", b64))
131                }
132                Err(e) => Err(format!("Snapshot failed: {}", e)),
133            }
134        }
135
136        _ => {
137            warn!(tool = name, "Unknown canvas tool");
138            Err(format!("Unknown canvas tool: {}", name))
139        }
140    }
141}
142
143/// Generate canvas tools section for the system prompt.
144pub fn generate_canvas_prompt_section() -> String {
145    r#"
146## Canvas Tools
147
148Canvas provides an agent-controlled visual workspace for HTML, CSS, JS, and A2UI content.
149
150- **canvas_present**: Show the canvas panel
151- **canvas_hide**: Hide the canvas panel  
152- **canvas_navigate**: Navigate to a URL or local path
153  - Parameters: `url` (string) — path or URL to navigate to
154- **canvas_write**: Write a file to the canvas directory
155  - Parameters: `path` (string), `content` (string)
156- **canvas_a2ui_push**: Push A2UI component updates
157  - Parameters: `text` (string) OR `jsonl` (string — A2UI messages as JSONL)
158- **canvas_a2ui_reset**: Reset A2UI state for the session
159- **canvas_snapshot**: Capture canvas as image (returns base64 PNG)
160
161"#
162    .to_string()
163}