output_iframe: read from file path, support bundled files, add download

Philip Zeyliger and Shelley created

I'm still figuring out the ergonomics for this tool, but there's
something here, so I'm continuing on it. The alternative is to
support output Mermaid or vegalite or whatever, but that's too limited.

Prompt: the tool to produce an iframe output: instead of taking a
string, have it take a file. And also add a download button so it's easy
to download the produced visualizations. [Support multiple files with
zip download]

Changes the output_iframe tool to read HTML from a file path instead of
accepting raw HTML strings. Also adds support for bundling additional
data files and a download button in the UI.

Backend:
- Tool now takes 'path' parameter pointing to an HTML file
- Add optional 'files' parameter to bundle additional files (JSON, CSS, JS)
- CSS files injected as <style> tags, JS as <script> tags
- Data files (JSON, CSV, text) accessible via window.__FILES__['filename']
- Supports both relative and absolute paths

Frontend:
- Add download button next to 'open in new tab' button
- Single HTML: downloads the file directly
- Multiple files: creates ZIP with original HTML + all bundled files
- Add JSZip dependency for zip creation

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/output_iframe.go            | 217 +++++++++++++++++++--
claudetool/output_iframe_test.go       | 279 +++++++++++++++++++++++++--
claudetool/toolset.go                  |   4 
ui/package.json                        |   3 
ui/pnpm-lock.yaml                      |  85 ++++++++
ui/src/components/OutputIframeTool.tsx | 153 +++++++++++++--
ui/src/styles.css                      |  41 +--
7 files changed, 687 insertions(+), 95 deletions(-)

Detailed changes

claudetool/output_iframe.go 🔗

@@ -3,16 +3,26 @@ package claudetool
 import (
 	"context"
 	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
 
 	"shelley.exe.dev/llm"
 )
 
 // OutputIframeTool displays sandboxed HTML content to the user.
-var OutputIframeTool = &llm.Tool{
-	Name:        outputIframeName,
-	Description: outputIframeDescription,
-	InputSchema: llm.MustSchema(outputIframeInputSchema),
-	Run:         outputIframeRun,
+// It requires a MutableWorkingDir to resolve relative file paths.
+type OutputIframeTool struct {
+	WorkingDir *MutableWorkingDir
+}
+
+func (t *OutputIframeTool) Tool() *llm.Tool {
+	return &llm.Tool{
+		Name:        outputIframeName,
+		Description: outputIframeDescription,
+		InputSchema: llm.MustSchema(outputIframeInputSchema),
+		Run:         t.Run,
+	}
 }
 
 const (
@@ -34,50 +44,217 @@ Good uses:
 - SVG graphics
 
 The HTML should be self-contained. You can include inline <script> and <style> tags.
-External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).`
+External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).
+
+For visualizations that need external data files (JSON, CSV, etc.), use the 'files' parameter
+to bundle them. They will be injected into the page and accessible via window.__FILES__['filename'].`
 
 	outputIframeInputSchema = `
 {
   "type": "object",
-  "required": ["html"],
+  "required": ["path"],
   "properties": {
-    "html": {
+    "path": {
       "type": "string",
-      "description": "The HTML content to display. Should be a complete HTML document or fragment."
+      "description": "Path to the HTML file to display. Relative paths are resolved from the working directory."
     },
     "title": {
       "type": "string", 
       "description": "Optional title describing the visualization"
+    },
+    "files": {
+      "type": "object",
+      "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.",
+      "additionalProperties": {
+        "type": "string"
+      }
     }
   }
 }
 `
 )
 
+// EmbeddedFile represents a file bundled with the HTML.
+type EmbeddedFile struct {
+	Name    string `json:"name"`
+	Path    string `json:"path"`
+	Content string `json:"content"`
+	Type    string `json:"type"` // "json", "css", "js", "text"
+}
+
 // OutputIframeDisplay is the data passed to the UI for rendering.
 type OutputIframeDisplay struct {
-	Type  string `json:"type"`
-	HTML  string `json:"html"`
-	Title string `json:"title,omitempty"`
+	Type     string         `json:"type"`
+	HTML     string         `json:"html"`
+	Title    string         `json:"title,omitempty"`
+	Filename string         `json:"filename,omitempty"`
+	Files    []EmbeddedFile `json:"files,omitempty"`
+}
+
+// detectFileType guesses the file type from the filename.
+func detectFileType(filename string) string {
+	ext := strings.ToLower(filepath.Ext(filename))
+	switch ext {
+	case ".json":
+		return "json"
+	case ".css":
+		return "css"
+	case ".js":
+		return "js"
+	case ".csv":
+		return "csv"
+	default:
+		return "text"
+	}
+}
+
+// injectFiles modifies the HTML to make bundled files accessible.
+// For JSON/text/csv files: adds a script that populates window.__FILES__
+// For CSS files: injects as <style> tags
+// For JS files: injects as <script> tags
+func injectFiles(html string, files []EmbeddedFile) string {
+	if len(files) == 0 {
+		return html
+	}
+
+	var jsFiles []EmbeddedFile
+	var cssFiles []EmbeddedFile
+	var dataFiles []EmbeddedFile
+
+	for _, f := range files {
+		switch f.Type {
+		case "css":
+			cssFiles = append(cssFiles, f)
+		case "js":
+			jsFiles = append(jsFiles, f)
+		default:
+			dataFiles = append(dataFiles, f)
+		}
+	}
+
+	var injection strings.Builder
+
+	// Inject CSS files as style tags
+	for _, f := range cssFiles {
+		injection.WriteString("<style data-file=\"")
+		injection.WriteString(f.Name)
+		injection.WriteString("\">\n")
+		injection.WriteString(f.Content)
+		injection.WriteString("\n</style>\n")
+	}
+
+	// Inject data files as window.__FILES__
+	if len(dataFiles) > 0 {
+		injection.WriteString("<script>\nwindow.__FILES__ = window.__FILES__ || {};\n")
+		for _, f := range dataFiles {
+			// Escape the content for JavaScript string
+			escaped := escapeJSString(f.Content)
+			injection.WriteString("window.__FILES__[\"")
+			injection.WriteString(f.Name)
+			injection.WriteString("\"] = \"")
+			injection.WriteString(escaped)
+			injection.WriteString("\";\n")
+		}
+		injection.WriteString("</script>\n")
+	}
+
+	// Inject JS files as script tags
+	for _, f := range jsFiles {
+		injection.WriteString("<script data-file=\"")
+		injection.WriteString(f.Name)
+		injection.WriteString("\">\n")
+		injection.WriteString(f.Content)
+		injection.WriteString("\n</script>\n")
+	}
+
+	// Insert after <head> or at the beginning
+	injectionStr := injection.String()
+	if idx := strings.Index(strings.ToLower(html), "<head>"); idx != -1 {
+		return html[:idx+6] + "\n" + injectionStr + html[idx+6:]
+	}
+	if idx := strings.Index(strings.ToLower(html), "<html>"); idx != -1 {
+		return html[:idx+6] + "\n<head>\n" + injectionStr + "</head>\n" + html[idx+6:]
+	}
+	// No head or html tag, just prepend
+	return injectionStr + html
+}
+
+// escapeJSString escapes a string for use in a JavaScript string literal.
+func escapeJSString(s string) string {
+	var b strings.Builder
+	for _, r := range s {
+		switch r {
+		case '\\':
+			b.WriteString("\\\\")
+		case '"':
+			b.WriteString("\\\"")
+		case '\n':
+			b.WriteString("\\n")
+		case '\r':
+			b.WriteString("\\r")
+		case '\t':
+			b.WriteString("\\t")
+		default:
+			b.WriteRune(r)
+		}
+	}
+	return b.String()
 }
 
-func outputIframeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+func (t *OutputIframeTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
 	var input struct {
-		HTML  string `json:"html"`
-		Title string `json:"title"`
+		Path  string            `json:"path"`
+		Title string            `json:"title"`
+		Files map[string]string `json:"files"`
 	}
 	if err := json.Unmarshal(m, &input); err != nil {
 		return llm.ErrorToolOut(err)
 	}
 
-	if input.HTML == "" {
-		return llm.ErrorfToolOut("html content is required")
+	if input.Path == "" {
+		return llm.ErrorfToolOut("path is required")
 	}
 
+	// Resolve the path relative to working directory
+	path := input.Path
+	if !filepath.IsAbs(path) {
+		path = filepath.Join(t.WorkingDir.Get(), path)
+	}
+
+	// Read the main HTML file
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return llm.ErrorfToolOut("failed to read file: %v", err)
+	}
+
+	// Read additional files
+	var embeddedFiles []EmbeddedFile
+	for name, filePath := range input.Files {
+		// Resolve relative paths
+		if !filepath.IsAbs(filePath) {
+			filePath = filepath.Join(t.WorkingDir.Get(), filePath)
+		}
+		content, err := os.ReadFile(filePath)
+		if err != nil {
+			return llm.ErrorfToolOut("failed to read file %q: %v", name, err)
+		}
+		embeddedFiles = append(embeddedFiles, EmbeddedFile{
+			Name:    name,
+			Path:    input.Files[name], // Original path for download
+			Content: string(content),
+			Type:    detectFileType(name),
+		})
+	}
+
+	// Inject files into the HTML for iframe display
+	html := injectFiles(string(data), embeddedFiles)
+
 	display := OutputIframeDisplay{
-		Type:  "output_iframe",
-		HTML:  input.HTML,
-		Title: input.Title,
+		Type:     "output_iframe",
+		HTML:     html,
+		Title:    input.Title,
+		Filename: filepath.Base(input.Path),
+		Files:    embeddedFiles,
 	}
 
 	return llm.ToolOut{

claudetool/output_iframe_test.go 🔗

@@ -3,45 +3,150 @@ package claudetool
 import (
 	"context"
 	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
 	"testing"
 )
 
 func TestOutputIframeRun(t *testing.T) {
+	// Create a temp directory for test files
+	tmpDir := t.TempDir()
+
+	// Create test HTML files
+	htmlFile := filepath.Join(tmpDir, "test.html")
+	if err := os.WriteFile(htmlFile, []byte("<html><head></head><body><h1>Hello</h1></body></html>"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	chartFile := filepath.Join(tmpDir, "chart.html")
+	if err := os.WriteFile(chartFile, []byte("<html><head></head><body><div>Chart</div></body></html>"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a data file
+	dataFile := filepath.Join(tmpDir, "data.json")
+	if err := os.WriteFile(dataFile, []byte(`{"values": [1, 2, 3]}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a CSS file
+	cssFile := filepath.Join(tmpDir, "styles.css")
+	if err := os.WriteFile(cssFile, []byte("body { color: red; }"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	workingDir := &MutableWorkingDir{}
+	workingDir.Set(tmpDir)
+
+	tool := &OutputIframeTool{WorkingDir: workingDir}
+
 	tests := []struct {
-		name      string
-		input     map[string]any
-		wantErr   bool
-		wantTitle string
+		name         string
+		input        map[string]any
+		wantErr      bool
+		wantTitle    string
+		wantFilename string
+		wantFiles    int
+		checkHTML    func(html string) bool
 	}{
 		{
-			name: "basic html",
+			name: "basic html file",
 			input: map[string]any{
-				"html": "<h1>Hello</h1>",
+				"path": "test.html",
 			},
-			wantErr:   false,
-			wantTitle: "",
+			wantErr:      false,
+			wantTitle:    "",
+			wantFilename: "test.html",
+			wantFiles:    0,
 		},
 		{
 			name: "html with title",
 			input: map[string]any{
-				"html":  "<div>Chart</div>",
+				"path":  "chart.html",
 				"title": "My Chart",
 			},
-			wantErr:   false,
-			wantTitle: "My Chart",
+			wantErr:      false,
+			wantTitle:    "My Chart",
+			wantFilename: "chart.html",
+			wantFiles:    0,
+		},
+		{
+			name: "html with data file",
+			input: map[string]any{
+				"path":  "chart.html",
+				"title": "Chart with Data",
+				"files": map[string]any{
+					"data.json": "data.json",
+				},
+			},
+			wantErr:      false,
+			wantTitle:    "Chart with Data",
+			wantFilename: "chart.html",
+			wantFiles:    1,
+			checkHTML: func(html string) bool {
+				return strings.Contains(html, "window.__FILES__") &&
+					strings.Contains(html, "data.json")
+			},
+		},
+		{
+			name: "html with multiple files",
+			input: map[string]any{
+				"path":  "chart.html",
+				"title": "Styled Chart",
+				"files": map[string]any{
+					"data.json":  "data.json",
+					"styles.css": "styles.css",
+				},
+			},
+			wantErr:      false,
+			wantTitle:    "Styled Chart",
+			wantFilename: "chart.html",
+			wantFiles:    2,
+			checkHTML: func(html string) bool {
+				return strings.Contains(html, "window.__FILES__") &&
+					strings.Contains(html, "<style data-file=\"styles.css\">") &&
+					strings.Contains(html, "body { color: red; }")
+			},
+		},
+		{
+			name: "absolute path",
+			input: map[string]any{
+				"path": htmlFile,
+			},
+			wantErr:      false,
+			wantFilename: "test.html",
+			wantFiles:    0,
 		},
 		{
-			name: "empty html",
+			name: "empty path",
 			input: map[string]any{
-				"html": "",
+				"path": "",
 			},
 			wantErr: true,
 		},
 		{
-			name:    "missing html",
+			name:    "missing path",
 			input:   map[string]any{},
 			wantErr: true,
 		},
+		{
+			name: "nonexistent file",
+			input: map[string]any{
+				"path": "nonexistent.html",
+			},
+			wantErr: true,
+		},
+		{
+			name: "nonexistent data file",
+			input: map[string]any{
+				"path": "chart.html",
+				"files": map[string]any{
+					"missing.json": "missing.json",
+				},
+			},
+			wantErr: true,
+		},
 	}
 
 	for _, tt := range tests {
@@ -51,7 +156,7 @@ func TestOutputIframeRun(t *testing.T) {
 				t.Fatalf("failed to marshal input: %v", err)
 			}
 
-			result := outputIframeRun(context.Background(), inputJSON)
+			result := tool.Run(context.Background(), inputJSON)
 
 			if tt.wantErr {
 				if result.Error == nil {
@@ -83,24 +188,154 @@ func TestOutputIframeRun(t *testing.T) {
 				t.Errorf("expected Title %q, got %q", tt.wantTitle, display.Title)
 			}
 
-			if html, _ := tt.input["html"].(string); display.HTML != html {
-				t.Errorf("expected HTML %q, got %q", html, display.HTML)
+			if display.Filename != tt.wantFilename {
+				t.Errorf("expected Filename %q, got %q", tt.wantFilename, display.Filename)
+			}
+
+			if len(display.Files) != tt.wantFiles {
+				t.Errorf("expected %d files, got %d", tt.wantFiles, len(display.Files))
+			}
+
+			if tt.checkHTML != nil && !tt.checkHTML(display.HTML) {
+				t.Errorf("HTML check failed, got: %s", display.HTML)
+			}
+		})
+	}
+}
+
+func TestDetectFileType(t *testing.T) {
+	tests := []struct {
+		filename string
+		want     string
+	}{
+		{"data.json", "json"},
+		{"styles.css", "css"},
+		{"script.js", "js"},
+		{"data.csv", "csv"},
+		{"readme.txt", "text"},
+		{"unknown", "text"},
+		{"DATA.JSON", "json"}, // case insensitive
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.filename, func(t *testing.T) {
+			got := detectFileType(tt.filename)
+			if got != tt.want {
+				t.Errorf("detectFileType(%q) = %q, want %q", tt.filename, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestEscapeJSString(t *testing.T) {
+	tests := []struct {
+		input string
+		want  string
+	}{
+		{"hello", "hello"},
+		{"hello\nworld", "hello\\nworld"},
+		{`say "hi"`, `say \"hi\"`},
+		{"back\\slash", "back\\\\slash"},
+		{"tab\there", "tab\\there"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			got := escapeJSString(tt.input)
+			if got != tt.want {
+				t.Errorf("escapeJSString(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestInjectFiles(t *testing.T) {
+	tests := []struct {
+		name     string
+		html     string
+		files    []EmbeddedFile
+		contains []string
+	}{
+		{
+			name:     "no files",
+			html:     "<html><head></head><body></body></html>",
+			files:    nil,
+			contains: []string{"<html><head></head><body></body></html>"},
+		},
+		{
+			name: "inject json file",
+			html: "<html><head></head><body></body></html>",
+			files: []EmbeddedFile{
+				{Name: "data.json", Content: `{"x": 1}`, Type: "json"},
+			},
+			contains: []string{"window.__FILES__", "data.json"},
+		},
+		{
+			name: "inject css file",
+			html: "<html><head></head><body></body></html>",
+			files: []EmbeddedFile{
+				{Name: "styles.css", Content: "body { color: red; }", Type: "css"},
+			},
+			contains: []string{"<style data-file=\"styles.css\">", "body { color: red; }"},
+		},
+		{
+			name: "inject js file",
+			html: "<html><head></head><body></body></html>",
+			files: []EmbeddedFile{
+				{Name: "app.js", Content: "console.log('hi');", Type: "js"},
+			},
+			contains: []string{"<script data-file=\"app.js\">", "console.log('hi');"},
+		},
+		{
+			name: "html without head tag",
+			html: "<html><body>content</body></html>",
+			files: []EmbeddedFile{
+				{Name: "data.json", Content: `{}`, Type: "json"},
+			},
+			contains: []string{"<head>", "</head>", "window.__FILES__"},
+		},
+		{
+			name: "plain html without tags",
+			html: "<div>content</div>",
+			files: []EmbeddedFile{
+				{Name: "data.json", Content: `{}`, Type: "json"},
+			},
+			contains: []string{"window.__FILES__", "<div>content</div>"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := injectFiles(tt.html, tt.files)
+			for _, s := range tt.contains {
+				if !strings.Contains(result, s) {
+					t.Errorf("expected result to contain %q, got: %s", s, result)
+				}
 			}
 		})
 	}
 }
 
 func TestOutputIframeToolSchema(t *testing.T) {
-	// Verify the tool is properly configured
-	if OutputIframeTool.Name != "output_iframe" {
-		t.Errorf("expected name 'output_iframe', got %q", OutputIframeTool.Name)
+	workingDir := &MutableWorkingDir{}
+	workingDir.Set("/tmp")
+	tool := &OutputIframeTool{WorkingDir: workingDir}
+	llmTool := tool.Tool()
+
+	if llmTool.Name != "output_iframe" {
+		t.Errorf("expected name 'output_iframe', got %q", llmTool.Name)
 	}
 
-	if OutputIframeTool.Run == nil {
+	if llmTool.Run == nil {
 		t.Error("expected Run function to be set")
 	}
 
-	if len(OutputIframeTool.InputSchema) == 0 {
+	if len(llmTool.InputSchema) == 0 {
 		t.Error("expected InputSchema to be set")
 	}
+
+	// Verify schema contains files property
+	if !strings.Contains(string(llmTool.InputSchema), "files") {
+		t.Error("expected InputSchema to contain 'files' property")
+	}
 }

claudetool/toolset.go 🔗

@@ -123,13 +123,15 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
 		OnChange:   cfg.OnWorkingDirChange,
 	}
 
+	outputIframeTool := &OutputIframeTool{WorkingDir: wd}
+
 	tools := []*llm.Tool{
 		Think,
 		bashTool.Tool(),
 		patchTool.Tool(),
 		keywordTool.Tool(),
 		changeDirTool.Tool(),
-		OutputIframeTool,
+		outputIframeTool.Tool(),
 	}
 
 	// Add subagent tool if configured

ui/package.json 🔗

@@ -20,14 +20,15 @@
     "test:e2e:debug": "pnpm run build && playwright test --debug"
   },
   "dependencies": {
+    "jszip": "^3.10.1",
     "monaco-editor": "^0.44.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@eslint/js": "^9.35.0",
-    "@types/node": "^22.0.0",
     "@playwright/test": "^1.40.0",
+    "@types/node": "^22.0.0",
     "@types/react": "^18.2.0",
     "@types/react-dom": "^18.2.0",
     "@typescript-eslint/eslint-plugin": "^8.43.0",

ui/pnpm-lock.yaml 🔗

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      jszip:
+        specifier: ^3.10.1
+        version: 3.10.1
       monaco-editor:
         specifier: ^0.44.0
         version: 0.44.0
@@ -593,6 +596,9 @@ packages:
   concat-map@0.0.1:
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
 
+  core-util-is@1.0.3:
+    resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+
   cross-spawn@7.0.6:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
@@ -867,6 +873,9 @@ packages:
     resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
     engines: {node: '>= 4'}
 
+  immediate@3.0.6:
+    resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
   import-fresh@3.3.1:
     resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
     engines: {node: '>=6'}
@@ -875,6 +884,9 @@ packages:
     resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
     engines: {node: '>=0.8.19'}
 
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
   internal-slot@1.1.0:
     resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
     engines: {node: '>= 0.4'}
@@ -975,6 +987,9 @@ packages:
     resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
     engines: {node: '>= 0.4'}
 
+  isarray@1.0.0:
+    resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
   isarray@2.0.5:
     resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
 
@@ -1005,6 +1020,9 @@ packages:
     resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
     engines: {node: '>=4.0'}
 
+  jszip@3.10.1:
+    resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
@@ -1012,6 +1030,9 @@ packages:
     resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
     engines: {node: '>= 0.8.0'}
 
+  lie@3.3.0:
+    resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
   locate-path@6.0.0:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
@@ -1087,6 +1108,9 @@ packages:
     resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
     engines: {node: '>=10'}
 
+  pako@1.0.11:
+    resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
   parent-module@1.0.1:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
@@ -1129,6 +1153,9 @@ packages:
     engines: {node: '>=14'}
     hasBin: true
 
+  process-nextick-args@2.0.1:
+    resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
   prop-types@15.8.1:
     resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
 
@@ -1148,6 +1175,9 @@ packages:
     resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
     engines: {node: '>=0.10.0'}
 
+  readable-stream@2.3.8:
+    resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
   reflect.getprototypeof@1.0.10:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}
@@ -1171,6 +1201,9 @@ packages:
     resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
     engines: {node: '>=0.4'}
 
+  safe-buffer@5.1.2:
+    resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
   safe-push-apply@1.0.0:
     resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
     engines: {node: '>= 0.4'}
@@ -1203,6 +1236,9 @@ packages:
     resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
     engines: {node: '>= 0.4'}
 
+  setimmediate@1.0.5:
+    resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
   shebang-command@2.0.0:
     resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
     engines: {node: '>=8'}
@@ -1250,6 +1286,9 @@ packages:
     resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
     engines: {node: '>= 0.4'}
 
+  string_decoder@1.1.1:
+    resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
   strip-json-comments@3.1.1:
     resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
     engines: {node: '>=8'}
@@ -1319,6 +1358,9 @@ packages:
   uri-js@4.4.1:
     resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
 
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
   which-boxed-primitive@1.1.1:
     resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
     engines: {node: '>= 0.4'}
@@ -1793,6 +1835,8 @@ snapshots:
 
   concat-map@0.0.1: {}
 
+  core-util-is@1.0.3: {}
+
   cross-spawn@7.0.6:
     dependencies:
       path-key: 3.1.1
@@ -2215,6 +2259,8 @@ snapshots:
 
   ignore@7.0.5: {}
 
+  immediate@3.0.6: {}
+
   import-fresh@3.3.1:
     dependencies:
       parent-module: 1.0.1
@@ -2222,6 +2268,8 @@ snapshots:
 
   imurmurhash@0.1.4: {}
 
+  inherits@2.0.4: {}
+
   internal-slot@1.1.0:
     dependencies:
       es-errors: 1.3.0
@@ -2334,6 +2382,8 @@ snapshots:
       call-bound: 1.0.4
       get-intrinsic: 1.3.0
 
+  isarray@1.0.0: {}
+
   isarray@2.0.5: {}
 
   isexe@2.0.0: {}
@@ -2366,6 +2416,13 @@ snapshots:
       object.assign: 4.1.7
       object.values: 1.2.1
 
+  jszip@3.10.1:
+    dependencies:
+      lie: 3.3.0
+      pako: 1.0.11
+      readable-stream: 2.3.8
+      setimmediate: 1.0.5
+
   keyv@4.5.4:
     dependencies:
       json-buffer: 3.0.1
@@ -2375,6 +2432,10 @@ snapshots:
       prelude-ls: 1.2.1
       type-check: 0.4.0
 
+  lie@3.3.0:
+    dependencies:
+      immediate: 3.0.6
+
   locate-path@6.0.0:
     dependencies:
       p-locate: 5.0.0
@@ -2460,6 +2521,8 @@ snapshots:
     dependencies:
       p-limit: 3.1.0
 
+  pako@1.0.11: {}
+
   parent-module@1.0.1:
     dependencies:
       callsites: 3.1.0
@@ -2486,6 +2549,8 @@ snapshots:
 
   prettier@3.7.4: {}
 
+  process-nextick-args@2.0.1: {}
+
   prop-types@15.8.1:
     dependencies:
       loose-envify: 1.4.0
@@ -2506,6 +2571,16 @@ snapshots:
     dependencies:
       loose-envify: 1.4.0
 
+  readable-stream@2.3.8:
+    dependencies:
+      core-util-is: 1.0.3
+      inherits: 2.0.4
+      isarray: 1.0.0
+      process-nextick-args: 2.0.1
+      safe-buffer: 5.1.2
+      string_decoder: 1.1.1
+      util-deprecate: 1.0.2
+
   reflect.getprototypeof@1.0.10:
     dependencies:
       call-bind: 1.0.8
@@ -2544,6 +2619,8 @@ snapshots:
       has-symbols: 1.1.0
       isarray: 2.0.5
 
+  safe-buffer@5.1.2: {}
+
   safe-push-apply@1.0.0:
     dependencies:
       es-errors: 1.3.0
@@ -2585,6 +2662,8 @@ snapshots:
       es-errors: 1.3.0
       es-object-atoms: 1.1.1
 
+  setimmediate@1.0.5: {}
+
   shebang-command@2.0.0:
     dependencies:
       shebang-regex: 3.0.0
@@ -2668,6 +2747,10 @@ snapshots:
       define-properties: 1.2.1
       es-object-atoms: 1.1.1
 
+  string_decoder@1.1.1:
+    dependencies:
+      safe-buffer: 5.1.2
+
   strip-json-comments@3.1.1: {}
 
   supports-color@7.2.0:
@@ -2755,6 +2838,8 @@ snapshots:
     dependencies:
       punycode: 2.3.1
 
+  util-deprecate@1.0.2: {}
+
   which-boxed-primitive@1.1.1:
     dependencies:
       is-bigint: 1.1.0

ui/src/components/OutputIframeTool.tsx 🔗

@@ -1,9 +1,17 @@
 import React, { useState, useRef, useEffect, useCallback } from "react";
+import JSZip from "jszip";
 import { LLMContent } from "../types";
 
+interface EmbeddedFile {
+  name: string;
+  path: string;
+  content: string;
+  type: string;
+}
+
 interface OutputIframeToolProps {
   // For tool_use (pending state)
-  toolInput?: unknown; // { html: string, title?: string }
+  toolInput?: unknown; // { path: string, title?: string, files?: object }
   isRunning?: boolean;
 
   // For tool_result (completed state)
@@ -49,6 +57,22 @@ const HEIGHT_REPORTER_SCRIPT = `
 const MIN_HEIGHT = 100;
 const MAX_HEIGHT = 600;
 
+// Remove injected scripts/styles from HTML to get the original version for download
+function getOriginalHtml(html: string): string {
+  // Remove the window.__FILES__ script block
+  let result = html.replace(
+    /<script>\s*window\.__FILES__\s*=\s*window\.__FILES__\s*\|\|\s*\{\};[\s\S]*?<\/script>\s*/g,
+    "",
+  );
+  // Remove injected style tags
+  result = result.replace(/<style data-file="[^"]*">[\s\S]*?<\/style>\s*/g, "");
+  // Remove injected script tags
+  result = result.replace(/<script data-file="[^"]*">[\s\S]*?<\/script>\s*/g, "");
+  // Remove empty head tags that might have been added
+  result = result.replace(/<head>\s*<\/head>\s*/g, "");
+  return result;
+}
+
 function OutputIframeTool({
   toolInput,
   isRunning,
@@ -88,13 +112,25 @@ function OutputIframeTool({
   };
 
   // Get display data - prefer from display prop, fall back to toolInput
-  const getDisplayData = (): { html?: string; title?: string } => {
+  const getDisplayData = (): {
+    html?: string;
+    title?: string;
+    filename?: string;
+    files?: EmbeddedFile[];
+  } => {
     // First try display prop (from tool result)
     if (display && typeof display === "object" && display !== null) {
-      const d = display as { html?: string; title?: string };
+      const d = display as {
+        html?: string;
+        title?: string;
+        filename?: string;
+        files?: EmbeddedFile[];
+      };
       return {
         html: typeof d.html === "string" ? d.html : undefined,
         title: typeof d.title === "string" ? d.title : undefined,
+        filename: typeof d.filename === "string" ? d.filename : undefined,
+        files: Array.isArray(d.files) ? d.files : undefined,
       };
     }
     // Fall back to toolInput
@@ -107,6 +143,9 @@ function OutputIframeTool({
   const displayData = getDisplayData();
   const title = displayData.title || "HTML Output";
   const html = displayData.html;
+  const filename = displayData.filename || "output.html";
+  const files = displayData.files || [];
+  const hasMultipleFiles = files.length > 0;
 
   // Inject height reporter script into HTML
   const htmlWithHeightReporter = html
@@ -184,7 +223,52 @@ function OutputIframeTool({
     setTimeout(() => URL.revokeObjectURL(url), 1000);
   };
 
+  // Download files - single HTML or zip with all files
+  const handleDownload = async (e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (!html) return;
+
+    if (hasMultipleFiles) {
+      // Create a zip file with all files
+      const zip = new JSZip();
+
+      // Add the original HTML (without injected content)
+      const originalHtml = getOriginalHtml(html);
+      zip.file(filename, originalHtml);
+
+      // Add all embedded files
+      for (const file of files) {
+        zip.file(file.path || file.name, file.content);
+      }
+
+      // Generate and download the zip
+      const zipBlob = await zip.generateAsync({ type: "blob" });
+      const url = URL.createObjectURL(zipBlob);
+      const a = document.createElement("a");
+      a.href = url;
+      // Use the HTML filename without extension for the zip name
+      const zipName = filename.replace(/\.[^.]+$/, "") + ".zip";
+      a.download = zipName;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      setTimeout(() => URL.revokeObjectURL(url), 1000);
+    } else {
+      // Single file download
+      const blob = new Blob([html], { type: "text/html" });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = filename;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      setTimeout(() => URL.revokeObjectURL(url), 1000);
+    }
+  };
+
   const isComplete = !isRunning && toolResult !== undefined;
+  const downloadLabel = hasMultipleFiles ? "Download ZIP" : "Download HTML";
 
   return (
     <div
@@ -200,27 +284,50 @@ function OutputIframeTool({
         </div>
         <div className="output-iframe-tool-actions">
           {isComplete && !hasError && html && (
-            <button
-              className="output-iframe-tool-open-btn"
-              onClick={handleOpenInNewTab}
-              aria-label="Open in new tab"
-              title="Open in new tab"
-            >
-              <svg
-                width="14"
-                height="14"
-                viewBox="0 0 24 24"
-                fill="none"
-                stroke="currentColor"
-                strokeWidth="2"
-                strokeLinecap="round"
-                strokeLinejoin="round"
+            <>
+              <button
+                className="output-iframe-tool-download-btn"
+                onClick={handleDownload}
+                aria-label={downloadLabel}
+                title={downloadLabel}
+              >
+                <svg
+                  width="14"
+                  height="14"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  strokeWidth="2"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                >
+                  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
+                  <polyline points="7 10 12 15 17 10" />
+                  <line x1="12" y1="15" x2="12" y2="3" />
+                </svg>
+              </button>
+              <button
+                className="output-iframe-tool-open-btn"
+                onClick={handleOpenInNewTab}
+                aria-label="Open in new tab"
+                title="Open in new tab"
               >
-                <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
-                <polyline points="15 3 21 3 21 9" />
-                <line x1="10" y1="14" x2="21" y2="3" />
-              </svg>
-            </button>
+                <svg
+                  width="14"
+                  height="14"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  strokeWidth="2"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                >
+                  <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+                  <polyline points="15 3 21 3 21 9" />
+                  <line x1="10" y1="14" x2="21" y2="3" />
+                </svg>
+              </button>
+            </>
           )}
           <button
             className="output-iframe-tool-toggle"

ui/src/styles.css 🔗

@@ -1664,7 +1664,8 @@ button {
   flex-shrink: 0;
 }
 
-.output-iframe-tool-open-btn {
+.output-iframe-tool-open-btn,
+.output-iframe-tool-download-btn {
   background: none;
   border: none;
   padding: 0.25rem;
@@ -1676,12 +1677,14 @@ button {
   border-radius: 4px;
 }
 
-.output-iframe-tool-open-btn:hover {
+.output-iframe-tool-open-btn:hover,
+.output-iframe-tool-download-btn:hover {
   color: var(--text-primary);
   background: var(--gray-200);
 }
 
-.dark .output-iframe-tool-open-btn:hover {
+.dark .output-iframe-tool-open-btn:hover,
+.dark .output-iframe-tool-download-btn:hover {
   background: var(--gray-700);
 }
 
@@ -2897,33 +2900,15 @@ svg {
   }
 }
 
-.context-usage-bar-container {
+.context-usage-indicator {
+  position: relative;
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
   display: flex;
   align-items: center;
-  gap: 4px;
-}
-
-.context-warning-icon {
-  font-size: 14px;
-  line-height: 1;
-  cursor: help;
-}
-
-.context-usage-bar {
-  width: 60px;
-  height: 6px;
-  background: var(--bg-tertiary);
-  border-radius: 3px;
-  overflow: hidden;
-  cursor: pointer;
-}
-
-.context-usage-fill {
-  height: 100%;
-  border-radius: 3px;
-  transition:
-    width 0.3s ease,
-    background-color 0.3s ease;
+  justify-content: center;
+  flex-shrink: 0;
 }
 
 /* Mobile optimizations for tighter spacing */