1package claudetool
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "shelley.exe.dev/llm"
11)
12
13// OutputIframeTool displays sandboxed HTML content to the user.
14// It requires a MutableWorkingDir to resolve relative file paths.
15type OutputIframeTool struct {
16 WorkingDir *MutableWorkingDir
17}
18
19func (t *OutputIframeTool) Tool() *llm.Tool {
20 return &llm.Tool{
21 Name: outputIframeName,
22 Description: outputIframeDescription,
23 InputSchema: llm.MustSchema(outputIframeInputSchema),
24 Run: t.Run,
25 }
26}
27
28const (
29 outputIframeName = "output_iframe"
30 outputIframeDescription = `Display HTML content to the user in a sandboxed iframe.
31
32Use this tool for visualizations like charts, graphs, and HTML demos that the user should see.
33The HTML will be rendered in a secure sandbox with scripts enabled but isolated from the parent page.
34
35Do NOT use this tool for:
36- Regular text responses (use normal messages instead)
37- File operations (use patch or bash)
38- Simple data display (just describe it in text)
39
40Good uses:
41- Vega-Lite or other chart library visualizations
42- HTML/CSS demonstrations
43- Interactive widgets or mini-apps
44- SVG graphics
45
46The HTML should be self-contained. You can include inline <script> and <style> tags.
47External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).
48
49For visualizations that need external data files (JSON, CSV, etc.), use the 'files' parameter
50to bundle them. They will be injected into the page and accessible via window.__FILES__['filename'].`
51
52 outputIframeInputSchema = `
53{
54 "type": "object",
55 "required": ["path"],
56 "properties": {
57 "path": {
58 "type": "string",
59 "description": "Path to the HTML file to display. Relative paths are resolved from the working directory."
60 },
61 "title": {
62 "type": "string",
63 "description": "Optional title describing the visualization"
64 },
65 "files": {
66 "type": "object",
67 "description": "Additional files to bundle (e.g., data.json, styles.css). Keys are the names to use in the HTML, values are file paths. Files are accessible in the HTML via window.__FILES__['filename'] for JSON/text, or injected as style tags for CSS.",
68 "additionalProperties": {
69 "type": "string"
70 }
71 }
72 }
73}
74`
75)
76
77// EmbeddedFile represents a file bundled with the HTML.
78type EmbeddedFile struct {
79 Name string `json:"name"`
80 Path string `json:"path"`
81 Content string `json:"content"`
82 Type string `json:"type"` // "json", "css", "js", "text"
83}
84
85// OutputIframeDisplay is the data passed to the UI for rendering.
86type OutputIframeDisplay struct {
87 Type string `json:"type"`
88 HTML string `json:"html"`
89 Title string `json:"title,omitempty"`
90 Filename string `json:"filename,omitempty"`
91 Files []EmbeddedFile `json:"files,omitempty"`
92}
93
94// detectFileType guesses the file type from the filename.
95func detectFileType(filename string) string {
96 ext := strings.ToLower(filepath.Ext(filename))
97 switch ext {
98 case ".json":
99 return "json"
100 case ".css":
101 return "css"
102 case ".js":
103 return "js"
104 case ".csv":
105 return "csv"
106 default:
107 return "text"
108 }
109}
110
111// injectFiles modifies the HTML to make bundled files accessible.
112// For JSON/text/csv files: adds a script that populates window.__FILES__
113// For CSS files: injects as <style> tags
114// For JS files: injects as <script> tags
115func injectFiles(html string, files []EmbeddedFile) string {
116 if len(files) == 0 {
117 return html
118 }
119
120 var jsFiles []EmbeddedFile
121 var cssFiles []EmbeddedFile
122 var dataFiles []EmbeddedFile
123
124 for _, f := range files {
125 switch f.Type {
126 case "css":
127 cssFiles = append(cssFiles, f)
128 case "js":
129 jsFiles = append(jsFiles, f)
130 default:
131 dataFiles = append(dataFiles, f)
132 }
133 }
134
135 var injection strings.Builder
136
137 // Inject CSS files as style tags
138 for _, f := range cssFiles {
139 injection.WriteString("<style data-file=\"")
140 injection.WriteString(f.Name)
141 injection.WriteString("\">\n")
142 injection.WriteString(f.Content)
143 injection.WriteString("\n</style>\n")
144 }
145
146 // Inject data files as window.__FILES__
147 if len(dataFiles) > 0 {
148 injection.WriteString("<script>\nwindow.__FILES__ = window.__FILES__ || {};\n")
149 for _, f := range dataFiles {
150 // Escape the content for JavaScript string
151 escaped := escapeJSString(f.Content)
152 injection.WriteString("window.__FILES__[\"")
153 injection.WriteString(f.Name)
154 injection.WriteString("\"] = \"")
155 injection.WriteString(escaped)
156 injection.WriteString("\";\n")
157 }
158 injection.WriteString("</script>\n")
159 }
160
161 // Inject JS files as script tags
162 for _, f := range jsFiles {
163 injection.WriteString("<script data-file=\"")
164 injection.WriteString(f.Name)
165 injection.WriteString("\">\n")
166 injection.WriteString(f.Content)
167 injection.WriteString("\n</script>\n")
168 }
169
170 // Insert after <head> or at the beginning
171 injectionStr := injection.String()
172 if idx := strings.Index(strings.ToLower(html), "<head>"); idx != -1 {
173 return html[:idx+6] + "\n" + injectionStr + html[idx+6:]
174 }
175 if idx := strings.Index(strings.ToLower(html), "<html>"); idx != -1 {
176 return html[:idx+6] + "\n<head>\n" + injectionStr + "</head>\n" + html[idx+6:]
177 }
178 // No head or html tag, just prepend
179 return injectionStr + html
180}
181
182// escapeJSString escapes a string for use in a JavaScript string literal.
183func escapeJSString(s string) string {
184 var b strings.Builder
185 for _, r := range s {
186 switch r {
187 case '\\':
188 b.WriteString("\\\\")
189 case '"':
190 b.WriteString("\\\"")
191 case '\n':
192 b.WriteString("\\n")
193 case '\r':
194 b.WriteString("\\r")
195 case '\t':
196 b.WriteString("\\t")
197 default:
198 b.WriteRune(r)
199 }
200 }
201 return b.String()
202}
203
204func (t *OutputIframeTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
205 var input struct {
206 Path string `json:"path"`
207 Title string `json:"title"`
208 Files map[string]string `json:"files"`
209 }
210 if err := json.Unmarshal(m, &input); err != nil {
211 return llm.ErrorToolOut(err)
212 }
213
214 if input.Path == "" {
215 return llm.ErrorfToolOut("path is required")
216 }
217
218 // Resolve the path relative to working directory
219 path := input.Path
220 if !filepath.IsAbs(path) {
221 path = filepath.Join(t.WorkingDir.Get(), path)
222 }
223
224 // Read the main HTML file
225 data, err := os.ReadFile(path)
226 if err != nil {
227 return llm.ErrorfToolOut("failed to read file: %v", err)
228 }
229
230 // Read additional files
231 var embeddedFiles []EmbeddedFile
232 for name, filePath := range input.Files {
233 // Resolve relative paths
234 if !filepath.IsAbs(filePath) {
235 filePath = filepath.Join(t.WorkingDir.Get(), filePath)
236 }
237 content, err := os.ReadFile(filePath)
238 if err != nil {
239 return llm.ErrorfToolOut("failed to read file %q: %v", name, err)
240 }
241 embeddedFiles = append(embeddedFiles, EmbeddedFile{
242 Name: name,
243 Path: input.Files[name], // Original path for download
244 Content: string(content),
245 Type: detectFileType(name),
246 })
247 }
248
249 // Inject files into the HTML for iframe display
250 html := injectFiles(string(data), embeddedFiles)
251
252 display := OutputIframeDisplay{
253 Type: "output_iframe",
254 HTML: html,
255 Title: input.Title,
256 Filename: filepath.Base(input.Path),
257 Files: embeddedFiles,
258 }
259
260 return llm.ToolOut{
261 LLMContent: llm.TextContent("displayed"),
262 Display: display,
263 }
264}