diff --git a/AGENTS.md b/AGENTS.md index 516e5923ef5c1a0c52c80dca5b3bde97e8392d55..9203f930028e00e54849f1963328950ee2901452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/claudetool/output_iframe.go b/claudetool/output_iframe.go new file mode 100644 index 0000000000000000000000000000000000000000..ff7d3c8f09a59d8a0dcb3576f71674b8e4929259 --- /dev/null +++ b/claudetool/output_iframe.go @@ -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 +`; + +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(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("") + ? html.replace("", HEIGHT_REPORTER_SCRIPT + "") + : 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, "'"); + }; + + // 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 = ` + + + + ${escapedTitle} + + + + + +`; + + 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 ( +
+
setIsExpanded(!isExpanded)}> +
+ + {title} + {isComplete && hasError && } + {isComplete && !hasError && } +
+
+ {isComplete && !hasError && html && ( + + )} + +
+
+ + {isExpanded && ( +
+ {isComplete && !hasError && htmlWithHeightReporter && ( +
+ {executionTime && ( +
+ Output: + {executionTime} +
+ )} +
+