Detailed changes
@@ -8,8 +8,9 @@
and can fabricate some inputs and outputs. To test things, launch shelley with the relevant flag
to only expose this model, and use shelley with a browser.
6. Build the UI (`make ui` or `cd ui && pnpm install && pnpm run build`) before running Go tests so `ui/dist` exists for the embed.
-7. Run Go unit tests with `go test ./server` (or narrower packages while iterating) once the UI bundle is built.
-8. To programmatically type into the React message input (e.g., in browser automation), you must use React's internal setter:
+7. Run TypeScript type checking with `cd ui && pnpm run type-check`. Run linting with `pnpm run lint`.
+8. Run Go unit tests with `go test ./server` (or narrower packages while iterating) once the UI bundle is built.
+9. To programmatically type into the React message input (e.g., in browser automation), you must use React's internal setter:
```javascript
const input = document.querySelector('[data-testid="message-input"]');
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
@@ -17,10 +18,10 @@
input.dispatchEvent(new Event('input', { bubbles: true }));
```
Simply setting `input.value = '...'` won't work because React won't detect the change.
-9. Commit your changes before finishing your turn.
-10. If you are testing Shelley itself, be aware that you might be running "under" shelley,
+10. Commit your changes before finishing your turn.
+11. If you are testing Shelley itself, be aware that you might be running "under" shelley,
and indiscriminately running pkill -f shelley may break things.
-11. To test the Shelley UI in a separate instance, build with `make build`, then run on a
+12. To test the Shelley UI in a separate instance, build with `make build`, then run on a
different port with a separate database:
```
./bin/shelley -config /exe.dev/shelley.json -db /tmp/shelley-test.db serve -port 8002
@@ -0,0 +1,87 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+
+ "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,
+}
+
+const (
+ outputIframeName = "output_iframe"
+ outputIframeDescription = `Display HTML content to the user in a sandboxed iframe.
+
+Use this tool for visualizations like charts, graphs, and HTML demos that the user should see.
+The HTML will be rendered in a secure sandbox with scripts enabled but isolated from the parent page.
+
+Do NOT use this tool for:
+- Regular text responses (use normal messages instead)
+- File operations (use patch or bash)
+- Simple data display (just describe it in text)
+
+Good uses:
+- Vega-Lite or other chart library visualizations
+- HTML/CSS demonstrations
+- Interactive widgets or mini-apps
+- 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/).`
+
+ outputIframeInputSchema = `
+{
+ "type": "object",
+ "required": ["html"],
+ "properties": {
+ "html": {
+ "type": "string",
+ "description": "The HTML content to display. Should be a complete HTML document or fragment."
+ },
+ "title": {
+ "type": "string",
+ "description": "Optional title describing the visualization"
+ }
+ }
+}
+`
+)
+
+// 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"`
+}
+
+func outputIframeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input struct {
+ HTML string `json:"html"`
+ Title string `json:"title"`
+ }
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ if input.HTML == "" {
+ return llm.ErrorfToolOut("html content is required")
+ }
+
+ display := OutputIframeDisplay{
+ Type: "output_iframe",
+ HTML: input.HTML,
+ Title: input.Title,
+ }
+
+ return llm.ToolOut{
+ LLMContent: llm.TextContent("displayed"),
+ Display: display,
+ }
+}
@@ -0,0 +1,106 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+)
+
+func TestOutputIframeRun(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]any
+ wantErr bool
+ wantTitle string
+ }{
+ {
+ name: "basic html",
+ input: map[string]any{
+ "html": "<h1>Hello</h1>",
+ },
+ wantErr: false,
+ wantTitle: "",
+ },
+ {
+ name: "html with title",
+ input: map[string]any{
+ "html": "<div>Chart</div>",
+ "title": "My Chart",
+ },
+ wantErr: false,
+ wantTitle: "My Chart",
+ },
+ {
+ name: "empty html",
+ input: map[string]any{
+ "html": "",
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing html",
+ input: map[string]any{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inputJSON, err := json.Marshal(tt.input)
+ if err != nil {
+ t.Fatalf("failed to marshal input: %v", err)
+ }
+
+ result := outputIframeRun(context.Background(), inputJSON)
+
+ if tt.wantErr {
+ if result.Error == nil {
+ t.Error("expected error, got nil")
+ }
+ return
+ }
+
+ if result.Error != nil {
+ t.Errorf("unexpected error: %v", result.Error)
+ return
+ }
+
+ if len(result.LLMContent) != 1 || result.LLMContent[0].Text != "displayed" {
+ t.Errorf("expected LLMContent [displayed], got %v", result.LLMContent)
+ }
+
+ display, ok := result.Display.(OutputIframeDisplay)
+ if !ok {
+ t.Errorf("expected Display to be OutputIframeDisplay, got %T", result.Display)
+ return
+ }
+
+ if display.Type != "output_iframe" {
+ t.Errorf("expected Type 'output_iframe', got %q", display.Type)
+ }
+
+ if display.Title != tt.wantTitle {
+ 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)
+ }
+ })
+ }
+}
+
+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)
+ }
+
+ if OutputIframeTool.Run == nil {
+ t.Error("expected Run function to be set")
+ }
+
+ if len(OutputIframeTool.InputSchema) == 0 {
+ t.Error("expected InputSchema to be set")
+ }
+}
@@ -125,6 +125,7 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
patchTool.Tool(),
keywordTool.Tool(),
changeDirTool.Tool(),
+ OutputIframeTool,
}
// Add subagent tool if configured
@@ -24,6 +24,7 @@ import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
import ChangeDirTool from "./ChangeDirTool";
import BrowserResizeTool from "./BrowserResizeTool";
import SubagentTool from "./SubagentTool";
+import OutputIframeTool from "./OutputIframeTool";
import DirectoryPickerModal from "./DirectoryPickerModal";
interface ContextUsageBarProps {
@@ -172,6 +173,7 @@ const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
change_dir: ChangeDirTool,
browser_resize: BrowserResizeTool,
subagent: SubagentTool,
+ output_iframe: OutputIframeTool,
};
function CoalescedToolCall({
@@ -14,6 +14,7 @@ import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
import ChangeDirTool from "./ChangeDirTool";
import BrowserResizeTool from "./BrowserResizeTool";
import SubagentTool from "./SubagentTool";
+import OutputIframeTool from "./OutputIframeTool";
import UsageDetailModal from "./UsageDetailModal";
import MessageActionBar from "./MessageActionBar";
@@ -375,6 +376,10 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
if (content.ToolName === "subagent") {
return <SubagentTool toolInput={content.ToolInput} isRunning={true} />;
}
+ // Use specialized component for output iframe tool
+ if (content.ToolName === "output_iframe") {
+ return <OutputIframeTool toolInput={content.ToolInput} isRunning={true} />;
+ }
// Use specialized component for browser console logs tools
if (
content.ToolName === "browser_recent_console_logs" ||
@@ -588,6 +593,20 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
);
}
+ // Use specialized component for output iframe tool
+ if (toolName === "output_iframe") {
+ return (
+ <OutputIframeTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ display={content.Display}
+ />
+ );
+ }
+
// Use specialized component for browser console logs tools
if (
toolName === "browser_recent_console_logs" ||
@@ -0,0 +1,310 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import { LLMContent } from "../types";
+
+interface OutputIframeToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown; // { html: string, title?: string }
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+ display?: unknown; // OutputIframeDisplay from the Go tool
+}
+
+// Script injected into iframe to report its content height
+const HEIGHT_REPORTER_SCRIPT = `
+<script>
+(function() {
+ function reportHeight() {
+ var height = Math.max(
+ document.body.scrollHeight,
+ document.body.offsetHeight,
+ document.documentElement.scrollHeight,
+ document.documentElement.offsetHeight
+ );
+ window.parent.postMessage({ type: 'iframe-height', height: height }, '*');
+ }
+ // Report on load
+ if (document.readyState === 'complete') {
+ reportHeight();
+ } else {
+ window.addEventListener('load', reportHeight);
+ }
+ // Report after a short delay to catch async content
+ setTimeout(reportHeight, 100);
+ setTimeout(reportHeight, 500);
+ // Report on resize
+ window.addEventListener('resize', reportHeight);
+ // Observe DOM changes
+ if (typeof MutationObserver !== 'undefined') {
+ var observer = new MutationObserver(reportHeight);
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true });
+ }
+})();
+</script>
+`;
+
+const MIN_HEIGHT = 100;
+const MAX_HEIGHT = 600;
+
+function OutputIframeTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+ display,
+}: OutputIframeToolProps) {
+ // Default to expanded for visual content
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [iframeHeight, setIframeHeight] = useState(300);
+ const iframeRef = useRef<HTMLIFrameElement>(null);
+
+ // Extract input data
+ const getTitle = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "title" in input &&
+ typeof input.title === "string"
+ ) {
+ return input.title;
+ }
+ return undefined;
+ };
+
+ const getHtmlFromInput = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "html" in input &&
+ typeof input.html === "string"
+ ) {
+ return input.html;
+ }
+ return undefined;
+ };
+
+ // Get display data - prefer from display prop, fall back to toolInput
+ const getDisplayData = (): { html?: string; title?: string } => {
+ // First try display prop (from tool result)
+ if (display && typeof display === "object" && display !== null) {
+ const d = display as { html?: string; title?: string };
+ return {
+ html: typeof d.html === "string" ? d.html : undefined,
+ title: typeof d.title === "string" ? d.title : undefined,
+ };
+ }
+ // Fall back to toolInput
+ return {
+ html: getHtmlFromInput(toolInput),
+ title: getTitle(toolInput),
+ };
+ };
+
+ const displayData = getDisplayData();
+ const title = displayData.title || "HTML Output";
+ const html = displayData.html;
+
+ // Inject height reporter script into HTML
+ const htmlWithHeightReporter = html
+ ? html.includes("</body>")
+ ? html.replace("</body>", HEIGHT_REPORTER_SCRIPT + "</body>")
+ : html + HEIGHT_REPORTER_SCRIPT
+ : undefined;
+
+ // Listen for height messages from iframe
+ const handleMessage = useCallback((event: MessageEvent) => {
+ if (
+ event.data &&
+ typeof event.data === "object" &&
+ event.data.type === "iframe-height" &&
+ typeof event.data.height === "number"
+ ) {
+ // Verify the message is from our iframe
+ if (iframeRef.current && event.source === iframeRef.current.contentWindow) {
+ const newHeight = Math.min(Math.max(event.data.height, MIN_HEIGHT), MAX_HEIGHT);
+ setIframeHeight(newHeight);
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, [handleMessage]);
+
+ // Escape HTML special characters for safe embedding
+ const escapeHtml = (str: string): string => {
+ return str
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ };
+
+ // Open HTML in new tab with sandbox protection
+ const handleOpenInNewTab = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (!html) return;
+
+ // Create a wrapper HTML page that embeds the content in a sandboxed iframe
+ // This preserves security even when opened in a new tab
+ const escapedHtml = escapeHtml(html);
+ const escapedTitle = escapeHtml(title);
+
+ const wrapperHtml = `<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>${escapedTitle}</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { height: 100%; background: #f5f5f5; }
+ iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: white;
+ }
+ </style>
+</head>
+<body>
+ <iframe sandbox="allow-scripts" srcdoc="${escapedHtml}"></iframe>
+</body>
+</html>`;
+
+ const blob = new Blob([wrapperHtml], { type: "text/html" });
+ const url = URL.createObjectURL(blob);
+ window.open(url, "_blank");
+ // Clean up the URL after a delay
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ };
+
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div
+ className="output-iframe-tool"
+ data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+ >
+ <div className="output-iframe-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="output-iframe-tool-summary">
+ <span className={`output-iframe-tool-emoji ${isRunning ? "running" : ""}`}>✨</span>
+ <span className="output-iframe-tool-title">{title}</span>
+ {isComplete && hasError && <span className="output-iframe-tool-error">✗</span>}
+ {isComplete && !hasError && <span className="output-iframe-tool-success">✓</span>}
+ </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"
+ >
+ <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"
+ onClick={(e) => {
+ e.stopPropagation();
+ setIsExpanded(!isExpanded);
+ }}
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {isExpanded && (
+ <div className="output-iframe-tool-details">
+ {isComplete && !hasError && htmlWithHeightReporter && (
+ <div className="output-iframe-tool-section">
+ {executionTime && (
+ <div className="output-iframe-tool-label">
+ <span>Output:</span>
+ <span className="output-iframe-tool-time">{executionTime}</span>
+ </div>
+ )}
+ <div className="output-iframe-container">
+ <iframe
+ ref={iframeRef}
+ srcDoc={htmlWithHeightReporter}
+ sandbox="allow-scripts"
+ title={title}
+ style={{
+ width: "100%",
+ height: `${iframeHeight}px`,
+ border: "1px solid var(--border-color, #e5e7eb)",
+ borderRadius: "4px",
+ backgroundColor: "white",
+ }}
+ />
+ </div>
+ </div>
+ )}
+
+ {isComplete && hasError && (
+ <div className="output-iframe-tool-section">
+ <div className="output-iframe-tool-label">
+ <span>Error:</span>
+ {executionTime && <span className="output-iframe-tool-time">{executionTime}</span>}
+ </div>
+ <pre className="output-iframe-tool-error-message">
+ {toolResult && toolResult[0]?.Text
+ ? toolResult[0].Text
+ : "Failed to display HTML content"}
+ </pre>
+ </div>
+ )}
+
+ {isRunning && (
+ <div className="output-iframe-tool-section">
+ <div className="output-iframe-tool-label">Preparing HTML output...</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default OutputIframeTool;
@@ -1583,6 +1583,167 @@ button {
color: var(--error-text);
}
+/* Output Iframe Tool */
+.output-iframe-tool {
+ background: var(--gray-100);
+ border-radius: 0.5rem;
+ margin: 0.5rem 0;
+ width: 100%;
+}
+
+.dark .output-iframe-tool {
+ background: var(--gray-800);
+}
+
+.output-iframe-tool-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.output-iframe-tool-header:hover {
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 0.5rem;
+}
+
+.dark .output-iframe-tool-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.output-iframe-tool-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.output-iframe-tool-emoji {
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.output-iframe-tool-emoji.running {
+ animation: rotate 1s linear infinite;
+}
+
+.output-iframe-tool-title {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.output-iframe-tool-success {
+ color: var(--success-text);
+ font-weight: 600;
+ margin-left: 0.5rem;
+}
+
+.output-iframe-tool-error {
+ color: var(--error-text);
+ font-weight: 600;
+ margin-left: 0.5rem;
+}
+
+.output-iframe-tool-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.output-iframe-tool-open-btn {
+ background: none;
+ border: none;
+ padding: 0.25rem;
+ cursor: pointer;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+}
+
+.output-iframe-tool-open-btn:hover {
+ color: var(--text-primary);
+ background: var(--gray-200);
+}
+
+.dark .output-iframe-tool-open-btn:hover {
+ background: var(--gray-700);
+}
+
+.output-iframe-tool-toggle {
+ background: none;
+ border: none;
+ padding: 0.25rem;
+ cursor: pointer;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.output-iframe-tool-toggle:hover {
+ color: var(--text-primary);
+}
+
+.output-iframe-tool-details {
+ padding: 0 1rem 1rem 1rem;
+}
+
+.output-iframe-tool-section {
+ margin-top: 0.5rem;
+}
+
+.output-iframe-tool-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.output-iframe-tool-time {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+}
+
+.output-iframe-container {
+ width: 100%;
+ overflow: hidden;
+ border-radius: 0.25rem;
+}
+
+.output-iframe-container iframe {
+ display: block;
+}
+
+.output-iframe-tool-error-message {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ margin: 0;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--error-text);
+}
+
/* Message Input */
.message-input-container {
flex: 0 0 auto;