import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { Text } from "@mariozechner/pi-tui"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { getGuidelines, AVAILABLE_MODULES } from "./guidelines.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const GLIMPSE_PATH = join(__dirname, "../../../node_modules/glimpseui/src/glimpse.mjs"); // Shell HTML with a root container — used for streaming. // Content is injected via win.send() JS eval, not setHTML(), to avoid full-page flashes. function shellHTML(): string { return `
`; } // Wrap HTML fragment into a full document for Glimpse (non-streaming fallback) function wrapHTML(code: string, isSVG = false): string { if (isSVG) { return ` ${code}`; } return ` ${code}`; } // Escape a string for safe injection into a JS string literal function escapeJS(s: string): string { return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/<\/script>/gi, '<\\/script>'); } export default function (pi: ExtensionAPI) { let hasSeenReadMe = false; let activeWindows: any[] = []; let glimpseModule: any = null; // Lazy-load glimpse module async function getGlimpse() { if (!glimpseModule) { glimpseModule = await import(GLIMPSE_PATH); } return glimpseModule; } // ── Streaming state ───────────────────────────────────────────────────── // Tracks in-flight show_widget tool calls being streamed interface StreamingWidget { contentIndex: number; window: any | null; lastHTML: string; updateTimer: any; ready: boolean; } let streaming: StreamingWidget | null = null; // ── message_update: intercept streaming tool calls ──────────────────── pi.on("message_update", async (event) => { const raw: any = event.assistantMessageEvent; if (!raw) return; // Tool call starts streaming if (raw.type === "toolcall_start") { const partial: any = raw.partial; const block = partial?.content?.[raw.contentIndex]; if (block?.type === "toolCall" && block?.name === "show_widget") { streaming = { contentIndex: raw.contentIndex, window: null, lastHTML: "", updateTimer: null, ready: false, }; } return; } // Tool call input JSON delta — arguments already parsed by pi-ai if (raw.type === "toolcall_delta" && streaming && raw.contentIndex === streaming.contentIndex) { const partial: any = raw.partial; const block = partial?.content?.[raw.contentIndex]; const html = block?.arguments?.widget_code; if (!html || html.length < 20 || html === streaming.lastHTML) return; streaming.lastHTML = html; // Debounce updates to ~150ms for smooth rendering if (streaming.updateTimer) return; streaming.updateTimer = setTimeout(async () => { if (!streaming) return; streaming.updateTimer = null; try { if (!streaming.window) { // Open window with empty shell — content will be injected via JS eval const args = block?.arguments ?? {}; const title = (args.title ?? "Widget").replace(/_/g, " "); const width = args.width ?? 800; const height = args.height ?? 600; const { open } = await getGlimpse(); streaming.window = open(shellHTML(), { width, height, title }); activeWindows.push(streaming.window); streaming.window.on("ready", () => { if (!streaming) return; streaming.ready = true; // Inject the content we've accumulated so far const escaped = escapeJS(streaming.lastHTML); streaming.window.send(`window._setContent('${escaped}')`); }); } else if (streaming.ready) { // Update content via JS — no full page replace const escaped = escapeJS(streaming.lastHTML); streaming.window.send(`window._setContent('${escaped}')`); } } catch {} }, 150); return; } // Tool call complete — final update with complete HTML + execute scripts if (raw.type === "toolcall_end" && streaming && raw.contentIndex === streaming.contentIndex) { if (streaming.updateTimer) { clearTimeout(streaming.updateTimer); streaming.updateTimer = null; } const toolCall = raw.toolCall; if (toolCall?.arguments?.widget_code && streaming.window && streaming.ready) { const escaped = escapeJS(toolCall.arguments.widget_code); streaming.window.send(`window._setContent('${escaped}'); window._runScripts();`); } // Don't clear streaming — execute() will pick up the window return; } }); // ── read_me tool ────────────────────────────────────────────────────────── pi.registerTool({ name: "visualize_read_me", label: "Read Guidelines", description: "Returns design guidelines for show_widget (CSS patterns, colors, typography, layout rules, examples). " + "Call once before your first show_widget call. Do NOT mention this call to the user — it is an internal setup step.", promptSnippet: "Load design guidelines before creating widgets. Call silently before first show_widget use.", promptGuidelines: [ "Call visualize_read_me once before your first show_widget call to load design guidelines.", "Do NOT mention the read_me call to the user — call it silently, then proceed directly to building the widget.", "Pick the modules that match your use case: interactive, chart, mockup, art, diagram.", ], parameters: Type.Object({ modules: Type.Array( StringEnum(AVAILABLE_MODULES as readonly string[]), { description: "Which module(s) to load. Pick all that fit." } ), }), async execute(_toolCallId, params) { hasSeenReadMe = true; const content = getGuidelines(params.modules); return { content: [{ type: "text" as const, text: content }], details: { modules: params.modules }, }; }, renderCall(args: any, theme: any) { const mods = (args.modules ?? []).join(", "); return new Text( theme.fg("toolTitle", theme.bold("read_me ")) + theme.fg("muted", mods), 0, 0 ); }, renderResult(_result: any, { isPartial }: any, theme: any) { if (isPartial) return new Text(theme.fg("warning", "Loading guidelines..."), 0, 0); return new Text(theme.fg("dim", "Guidelines loaded"), 0, 0); }, }); // ── show_widget tool ────────────────────────────────────────────────────── pi.registerTool({ name: "show_widget", label: "Show Widget", description: "Show visual content — SVG graphics, diagrams, charts, or interactive HTML widgets — in a native macOS window. " + "Use for flowcharts, dashboards, forms, calculators, data tables, games, illustrations, or any visual content. " + "The HTML is rendered in a native WKWebView with full CSS/JS support including Canvas and CDN libraries. " + "The page gets a window.glimpse.send(data) bridge to send JSON data back to the agent. " + "IMPORTANT: Call visualize_read_me once before your first show_widget call.", promptSnippet: "Render interactive HTML/SVG widgets in a native macOS window (WKWebView). Supports full CSS, JS, Canvas, Chart.js.", promptGuidelines: [ "Use show_widget when the user asks for visual content: charts, diagrams, interactive explainers, UI mockups, art.", "Always call visualize_read_me first to load design guidelines, then set i_have_seen_read_me: true.", "The widget opens in a native macOS window — it has full browser capabilities (Canvas, JS, CDN libraries).", "Structure HTML as fragments: no DOCTYPE///. Style first, then HTML, then scripts.", "The page has window.glimpse.send(data) to send data back. Use it for user choices and interactions.", "Keep widgets focused and appropriately sized. Default is 800x600 but adjust to fit content.", "For interactive explainers: sliders, live calculations, Chart.js charts.", "For SVG: start code with