diff --git a/.vscodeignore b/.vscodeignore index c4f2425..5354589 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -4,6 +4,11 @@ src/** .github/** +# AI tool session data (never ship) +.opencode/** +.claude/** +.codenomad/** + # Config files .gitignore .yarnrc @@ -12,6 +17,9 @@ src/** tsconfig.json esbuild.js vsc-extension-quickstart.md +build-package.js +release-please-config.json +.release-please-manifest.json # Build artifacts - exclude all TypeScript files including .d.ts **/*.map @@ -35,6 +43,15 @@ package-lock.json **/*.test.js **/*.spec.js +# Development-only docs and specs +docs/** +specs/** +AGENTS.md +PR.md + +# Build tooling (pre-bundled CLI entry for Antigravity ovsx only) +bin/** + # Redundant dist files (tsc output, keep only extension.js from esbuild) dist/localization.js dist/mcpServer.js diff --git a/bin/seamless-agent-mcp.js b/bin/seamless-agent-mcp.js index ffe4270..4829ad3 100644 --- a/bin/seamless-agent-mcp.js +++ b/bin/seamless-agent-mcp.js @@ -11,13 +11,52 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); -const { z } = require('zod'); +// Use zod/v3 API so the MCP SDK routes schema conversion through zod-to-json-schema +// (rather than z4mini.toJSONSchema) — avoids a bundled-duplicate-core conflict. +const { z } = require('zod/v3'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +const STATE_FILE = path.join(os.homedir(), '.antigravity', 'seamless-agent-state.json'); + +/** + * Reads the state file and returns the best (most recently active) instance. + * Supports both the new registry format: + * { "uuid": { port, token, lastActive, startedAt }, ... } + * and the legacy flat format: + * { port, token } + */ +function readBestInstance() { + try { + const raw = fs.readFileSync(STATE_FILE, 'utf8'); + const registry = JSON.parse(raw); + // Backward compatibility: old format has a direct `port` field + if (registry && typeof registry.port === 'number' && typeof registry.token === 'string') { + return { port: registry.port, token: registry.token }; + } + const entries = Object.values(registry); + if (!Array.isArray(entries) || entries.length === 0) return null; + // Sort by lastActive descending, fall back to startedAt + entries.sort((a, b) => { + const aTime = (a.lastActive ?? a.startedAt ?? 0); + const bTime = (b.lastActive ?? b.startedAt ?? 0); + return bTime - aTime; + }); + const best = entries[0]; + if (!best || !best.port || !best.token) return null; + return { port: best.port, token: best.token }; + } catch { + return null; + } +} // Parse command line arguments +// --port and --token are optional; if absent, routing relies entirely on the registry. function parseArgs() { const args = process.argv.slice(2); - let port = null; - let token = null; + let port = 0; + let token = ''; for (let i = 0; i < args.length; i++) { if (args[i] === '--port' && args[i + 1]) { @@ -30,21 +69,14 @@ function parseArgs() { } } - if (!port || isNaN(port) || !token) { - console.error('Usage: seamless-agent-mcp --port --token '); - console.error(' --port The port where the VS Code extension API is running'); - console.error(' --token Authentication token for the local API service'); - process.exit(1); - } - - return { port, token }; + return { port: isNaN(port) ? 0 : port, token }; } // Make HTTP request to VS Code extension API -async function callExtensionApi(port, token, endpoint, data) { - const url = `http://localhost:${port}${endpoint}`; - - try { +// state is a mutable object { port, token } — updated on ECONNREFUSED from state file +async function callExtensionApi(state, endpoint, data) { + async function attempt(port, token) { + const url = `http://localhost:${port}${endpoint}`; const response = await fetch(url, { method: 'POST', headers: { @@ -53,17 +85,36 @@ async function callExtensionApi(port, token, endpoint, data) { }, body: JSON.stringify(data), }); - if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - return await response.json(); + } + + // Re-read registry on every call to route to the most-recently-focused IDE window + const proactive = readBestInstance(); + if (proactive && (proactive.port !== state.port || proactive.token !== state.token)) { + state.port = proactive.port; + state.token = proactive.token; + } + + try { + return await attempt(state.port, state.token); } catch (error) { - // Check if extension API is available + // If the extension was restarted with a new port, recover from the registry and retry once if (error.cause && error.cause.code === 'ECONNREFUSED') { + const fresh = readBestInstance(); + if (fresh && (fresh.port !== state.port || fresh.token !== state.token)) { + state.port = fresh.port; + state.token = fresh.token; + try { + return await attempt(state.port, state.token); + } catch (_) { + // Fall through to the error below + } + } throw new Error( - `Cannot connect to Seamless Agent extension API at port ${port}. ` + + `Cannot connect to Seamless Agent extension API at port ${state.port}. ` + `Please ensure the VS Code extension is running and the API service has started.` ); } @@ -72,7 +123,10 @@ async function callExtensionApi(port, token, endpoint, data) { } async function main() { - const { port, token } = parseArgs(); + const args = parseArgs(); + + // Mutable state — port and token may be refreshed from state file on ECONNREFUSED + const state = { port: args.port, token: args.token }; // Create MCP server const server = new McpServer({ @@ -93,7 +147,7 @@ async function main() { }, async (args) => { try { - const result = await callExtensionApi(port, token, '/ask_user', { + const result = await callExtensionApi(state, '/ask_user', { question: args.question, title: args.title, agentName: args.agentName, @@ -138,7 +192,7 @@ async function main() { }, async (args) => { try { - const result = await callExtensionApi(port, token, '/plan_review', { + const result = await callExtensionApi(state, '/plan_review', { plan: args.plan, title: args.title, mode: 'review', @@ -186,7 +240,7 @@ async function main() { }, async (args) => { try { - const result = await callExtensionApi(port, token, '/plan_review', { + const result = await callExtensionApi(state, '/plan_review', { plan: args.plan, title: args.title, mode: 'walkthrough', @@ -220,6 +274,173 @@ async function main() { } ); + // Register open_whiteboard tool + server.registerTool( + 'open_whiteboard', + { + description: 'Open an interactive whiteboard panel for sketching, drawing, or annotating visuals. Returns exported images as data URIs.', + inputSchema: z.object({ + context: z.string().optional().describe('Instructions for the user about what to draw or annotate'), + title: z.string().optional().describe('Title for the whiteboard panel'), + blankCanvas: z.boolean().optional().describe('Open a blank canvas. Defaults to true.'), + importImages: z.array(z.object({ + uri: z.string().describe('File URI of an image to import'), + label: z.string().optional().describe('Optional label for the image'), + })).optional().describe('Optional images to pre-load onto the canvas'), + }) + }, + async (args) => { + try { + const result = await callExtensionApi(state, '/open_whiteboard', args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: `Error: ${error.message}` }) }], + isError: true, + }; + } + } + ); + + // Register render_ui tool + server.registerTool( + 'render_ui', + { + description: 'Render a structured UI panel in a dedicated VS Code webview using a flat component list. Use for dashboards, forms, data displays, reports, or any rich structured UI. This tool creates the surface — call it FIRST before using append_ui, update_ui, or close_ui on the same surfaceId.', + inputSchema: z.object({ + surfaceId: z.string().optional().describe('Optional unique surface identifier. Re-using the same surfaceId will update an existing panel.'), + title: z.string().optional().describe('Optional panel title displayed in the webview header.'), + components: z.array(z.object({ + id: z.string(), + component: z.object({ + type: z.enum(['Row', 'Column', 'Card', 'Divider', 'Text', 'Heading', 'Image', 'Markdown', 'CodeBlock', 'Button', 'TextField', 'Checkbox', 'Select']), + props: z.record(z.any()).optional(), + }), + })).optional(), + waitForAction: z.boolean().optional().describe('If true, block until the user clicks a Button'), + }) + }, + async (args) => { + try { + const result = await callExtensionApi(state, '/render_ui', args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [{ type: 'text', text: JSON.stringify({ surfaceId: '', rendered: false, error: `Error: ${error.message}` }) }], + isError: true, + }; + } + } + ); + + // Register update_ui tool + server.registerTool( + 'update_ui', + { + description: 'Update the dataModel and/or title of an existing surface.', + inputSchema: z.object({ + surfaceId: z.string().describe('The surface identifier of the panel to update'), + title: z.string().optional().describe('Optional new panel title'), + dataModel: z.record(z.any()).optional().describe('Replacement data model'), + }) + }, + async (args) => { + try { + const result = await callExtensionApi(state, '/update_ui', args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [{ type: 'text', text: JSON.stringify({ surfaceId: args.surfaceId ?? '', applied: false, error: `Error: ${error.message}` }) }], + isError: true, + }; + } + } + ); + + // Register append_ui tool + server.registerTool( + 'append_ui', + { + description: 'Append components onto an existing surface.', + inputSchema: z.object({ + surfaceId: z.string().describe('The surface identifier of the panel to append onto'), + title: z.string().optional().describe('Optional new panel title'), + components: z.array(z.object({ + id: z.string(), + component: z.object({ + type: z.enum(['Row', 'Column', 'Card', 'Divider', 'Text', 'Heading', 'Image', 'Markdown', 'CodeBlock', 'Button', 'TextField', 'Checkbox', 'Select']), + props: z.record(z.any()).optional(), + }), + })).describe('Non-empty list of components to append'), + }) + }, + async (args) => { + try { + const result = await callExtensionApi(state, '/append_ui', args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [{ type: 'text', text: JSON.stringify({ surfaceId: args.surfaceId ?? '', applied: false, error: `Error: ${error.message}` }) }], + isError: true, + }; + } + } + ); + + // Register close_ui tool + server.registerTool( + 'close_ui', + { + description: 'Close an active surface panel by surfaceId.', + inputSchema: z.object({ + surfaceId: z.string().describe('The surface identifier of the panel to close'), + }) + }, + async (args) => { + try { + const result = await callExtensionApi(state, '/close_ui', args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [{ type: 'text', text: JSON.stringify({ surfaceId: args.surfaceId ?? '', closed: false, error: `Error: ${error.message}` }) }], + isError: true, + }; + } + } + ); + + // Register list_surfaces tool + server.registerTool( + 'list_surfaces', + { + description: 'List all currently active UI surface panels with their IDs, titles, and timestamps.', + inputSchema: z.object({}).describe('No parameters required'), + }, + async (args) => { + try { + const result = await callExtensionApi(state, '/list_surfaces', {}); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [{ type: 'text', text: JSON.stringify({ surfaces: [], error: `Error: ${error.message}` }) }], + isError: true, + }; + } + } + ); + // Create stdio transport const transport = new StdioServerTransport(); @@ -227,7 +448,7 @@ async function main() { await server.connect(transport); // Log to stderr (stdout is used for MCP protocol) - console.error(`Seamless Agent MCP server started, connecting to API at port ${port}`); + console.error(`Seamless Agent MCP server started, connecting to API at port ${state.port}`); } main().catch((error) => { diff --git a/esbuild.js b/esbuild.js index 60cd826..fee903c 100644 --- a/esbuild.js +++ b/esbuild.js @@ -98,7 +98,35 @@ async function main() { plugins: [esbuildProblemMatcherPlugin], }); - const contexts = [extensionCtx, webviewCtx, planReviewCtx]; + // Whiteboard webview bundle (browser) + const whiteboardCtx = await esbuild.context({ + entryPoints: ['src/webview/whiteboard.ts'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/whiteboard.js', + logLevel: 'info', + plugins: [esbuildProblemMatcherPlugin], + }); + + // A2UI panel bundle (browser) + const a2uiCtx = await esbuild.context({ + entryPoints: ['src/a2ui/webview.ts'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/a2ui.js', + logLevel: 'info', + plugins: [esbuildProblemMatcherPlugin], + }); + + const contexts = [extensionCtx, webviewCtx, planReviewCtx, whiteboardCtx, a2uiCtx]; // CLI bundle (Node.js standalone) - Only for Antigravity if (antigravity) { @@ -111,7 +139,7 @@ async function main() { sourcesContent: false, platform: 'node', outfile: 'dist/seamless-agent-mcp.js', - external: [], // Bundle all dependencies + external: ['vscode'], // Bundle zod and all subpaths to ensure standalone CLI resolves zod/v3 (MCP SDK 1.25.2 compat) logLevel: 'info', plugins: [esbuildProblemMatcherPlugin, shebangPlugin], }); diff --git a/media/a2ui.css b/media/a2ui.css new file mode 100644 index 0000000..684b339 --- /dev/null +++ b/media/a2ui.css @@ -0,0 +1,660 @@ +/* A2UI Surface Styles */ + +:root { + --a2ui-gap: 8px; + --a2ui-padding: 12px; + --a2ui-radius: 6px; + --a2ui-font: var(--vscode-font-family, system-ui, sans-serif); + --a2ui-font-size: var(--vscode-font-size, 13px); + --a2ui-fg: var(--vscode-foreground, #cccccc); + --a2ui-bg: var(--vscode-editor-background, #1e1e1e); + --a2ui-card-bg: var(--vscode-sideBar-background, #252526); + --a2ui-border: var(--vscode-panel-border, #3c3c3c); + --a2ui-button-bg: var(--vscode-button-background, #0078d4); + --a2ui-button-fg: var(--vscode-button-foreground, #ffffff); + --a2ui-button-hover: var(--vscode-button-hoverBackground, #006cbd); + --a2ui-input-bg: var(--vscode-input-background, #3c3c3c); + --a2ui-input-fg: var(--vscode-input-foreground, #cccccc); + --a2ui-input-border: var(--vscode-input-border, #3c3c3c); + --a2ui-badge-bg: var(--vscode-badge-background, #4d4d4d); + --a2ui-badge-fg: var(--vscode-badge-foreground, #ffffff); +} + +body { + font-family: var(--a2ui-font); + font-size: var(--a2ui-font-size); + color: var(--a2ui-fg); + background: var(--a2ui-bg); + margin: 0; + padding: var(--a2ui-padding); +} + +.a2ui-surface { + display: flex; + flex-direction: column; + gap: var(--a2ui-gap); +} + +.a2ui-diagnostics { + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + background: color-mix(in srgb, var(--a2ui-card-bg) 84%, transparent); +} + +.a2ui-diagnostics-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.a2ui-diagnostics-title, +.a2ui-diagnostics-score { + font-weight: 700; +} + +.a2ui-diagnostics-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.a2ui-diagnostics-body h2 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 6px; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-diagnostics-list { + margin: 0; + padding-left: 18px; +} + +.a2ui-diagnostics-empty { + margin: 0; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +/* Layout */ +.a2ui-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--a2ui-gap); + align-items: flex-start; +} + +.a2ui-column { + display: flex; + flex-direction: column; + gap: var(--a2ui-gap); +} + +/* Card */ +.a2ui-card { + background: var(--a2ui-card-bg); + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + display: flex; + flex-direction: column; + gap: var(--a2ui-gap); +} + +/* Divider */ +.a2ui-divider { + border: none; + border-top: 1px solid var(--a2ui-border); + margin: var(--a2ui-gap) 0; +} + +/* Text & Heading */ +.a2ui-text { + margin: 0; + line-height: 1.5; +} + +.a2ui-heading { + margin: 0 0 4px; + font-weight: 600; + line-height: 1.3; +} + +/* Image */ +.a2ui-image { + max-width: 100%; + height: auto; + border-radius: var(--a2ui-radius); +} + +/* Markdown / CodeBlock */ +.a2ui-markdown { + line-height: 1.5; +} + +.a2ui-markdown > :first-child { + margin-top: 0; +} + +.a2ui-markdown > :last-child { + margin-bottom: 0; +} + +.a2ui-markdown code { + background: var(--a2ui-input-bg); + border-radius: 4px; + padding: 1px 5px; + font-family: var(--vscode-editor-font-family, monospace); +} + +.a2ui-markdown pre { + margin: 0; +} + +.a2ui-markdown blockquote { + margin: 0; + padding-left: 12px; + border-left: 3px solid var(--a2ui-border); + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-markdown ul, +.a2ui-markdown ol { + margin: 0; + padding-left: 20px; +} + +.a2ui-codeblock { + background: var(--a2ui-input-bg); + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + overflow-x: auto; + margin: 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: calc(var(--a2ui-font-size) - 1px); + line-height: 1.4; +} + +.a2ui-codeblock code { + background: none; + padding: 0; +} + +/* Button */ +.a2ui-button { + background: var(--a2ui-button-bg); + color: var(--a2ui-button-fg); + border: none; + border-radius: var(--a2ui-radius); + padding: 6px 14px; + cursor: pointer; + font-size: var(--a2ui-font-size); + font-family: var(--a2ui-font); + transition: background 0.15s; +} + +.a2ui-button:hover { + background: var(--a2ui-button-hover); +} + +.a2ui-button-secondary { + background: var(--vscode-button-secondaryBackground, var(--a2ui-card-bg)); + color: var(--vscode-button-secondaryForeground, var(--a2ui-fg)); + border: 1px solid var(--a2ui-border); +} + +.a2ui-button-secondary:hover { + background: var(--vscode-button-secondaryHoverBackground, var(--a2ui-input-bg)); +} + +.a2ui-button-danger { + background: var(--vscode-errorForeground, #c2410c); +} + +.a2ui-button-danger:hover { + filter: brightness(1.05); +} + +.a2ui-button:active { + opacity: 0.85; +} + +.a2ui-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.a2ui-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.a2ui-field-label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-required { + margin-left: 4px; + color: var(--vscode-errorForeground, #f44747); +} + +.a2ui-field-helper { + font-size: 12px; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-invalid .a2ui-textfield, +.a2ui-invalid .a2ui-select, +.a2ui-invalid .a2ui-checkbox { + border-color: var(--vscode-errorForeground, #f44747); + outline-color: var(--vscode-errorForeground, #f44747); +} + +.a2ui-field-error { + font-size: 12px; + color: var(--vscode-errorForeground, #f44747); +} + +/* TextField */ +.a2ui-textfield { + background: var(--a2ui-input-bg); + color: var(--a2ui-input-fg); + border: 1px solid var(--a2ui-input-border); + border-radius: var(--a2ui-radius); + padding: 5px 8px; + font-size: var(--a2ui-font-size); + font-family: var(--a2ui-font); + width: 100%; + box-sizing: border-box; +} + +.a2ui-textfield:focus { + outline: 1px solid var(--vscode-focusBorder, #007fd4); +} + +/* Checkbox */ +.a2ui-checkbox-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; +} + +.a2ui-checkbox { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--a2ui-button-bg); +} + +/* Select */ +.a2ui-select { + background: var(--a2ui-input-bg); + color: var(--a2ui-input-fg); + border: 1px solid var(--a2ui-input-border); + border-radius: var(--a2ui-radius); + padding: 5px 8px; + font-size: var(--a2ui-font-size); + font-family: var(--a2ui-font); + cursor: pointer; +} + +/* MermaidDiagram */ +.a2ui-mermaid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.a2ui-mermaid-label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-mermaid-source { + white-space: pre; + font-family: var(--vscode-editor-font-family, monospace); + font-size: calc(var(--a2ui-font-size) - 1px); + background: var(--a2ui-input-bg); + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + overflow-x: auto; + margin: 0; +} + +.a2ui-mermaid-target { + background: #ffffff; + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: 12px; + overflow-x: auto; +} + +.a2ui-mermaid-target svg { + max-width: 100%; + height: auto; +} + +.a2ui-mermaid-details summary { + cursor: pointer; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-mermaid-error { + color: var(--vscode-errorForeground, #f44747); +} + +/* ProgressBar */ +.a2ui-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.a2ui-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.a2ui-progress-label, +.a2ui-progress-value { + font-size: 12px; + font-weight: 600; +} + +.a2ui-progressbar { + width: 100%; + height: 8px; + border-radius: 4px; + border: none; + background: var(--a2ui-input-bg); + accent-color: var(--a2ui-button-bg); +} + +/* Badge */ +.a2ui-badge { + display: inline-block; + background: var(--a2ui-badge-bg); + color: var(--a2ui-badge-fg); + border-radius: 10px; + padding: 2px 8px; + font-size: calc(var(--a2ui-font-size) - 1px); + font-weight: 600; + white-space: nowrap; +} +.a2ui-badge-info { background: var(--vscode-charts-blue, #2596be); color: #fff; } +.a2ui-badge-success { background: var(--vscode-charts-green, #35a151); color: #fff; } +.a2ui-badge-warning { background: var(--vscode-charts-yellow, #c19c00); color: #000; } +.a2ui-badge-danger { background: var(--vscode-charts-red, #c72e2e); color: #fff; } + +/* Table */ +.a2ui-table { + width: 100%; + border-collapse: collapse; + font-size: var(--a2ui-font-size); +} + +.a2ui-table th, +.a2ui-table td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--a2ui-border); +} + +.a2ui-table thead tr { + border-bottom: 2px solid var(--a2ui-border); +} + +.a2ui-table th { + font-weight: 600; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-table tbody tr:hover { + background: var(--a2ui-input-bg); +} + +/* Tabs */ +.a2ui-tabs { + display: flex; + flex-direction: column; + gap: 8px; +} + +.a2ui-tab-header { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--a2ui-border); + padding-bottom: 4px; +} + +.a2ui-tab-button { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 8px 16px; + cursor: pointer; + font-size: var(--a2ui-font-size); + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); + transition: all 0.2s ease; +} + +.a2ui-tab-button:hover { + color: var(--a2ui-fg); + border-bottom-color: var(--a2ui-border); +} + +.a2ui-tab-button-active { + color: var(--a2ui-button-bg); + border-bottom-color: var(--a2ui-button-bg); + font-weight: 600; +} + +.a2ui-tab-content { + padding: 8px 0; +} + +.a2ui-tab-panel { + display: none; +} + +.a2ui-tab-panel[data-tab-active="true"] { + display: block; +} + +/* Toggle */ +.a2ui-toggle { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.a2ui-toggle-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.a2ui-toggle-slider { + position: relative; + display: inline-block; + width: 40px; + height: 20px; + background-color: var(--vscode-input-background, var(--a2ui-input-bg)); + border: 1px solid var(--a2ui-border); + border-radius: 20px; + transition: background-color 0.2s ease; +} + +.a2ui-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 1px; + bottom: 1px; + background-color: var(--a2ui-fg); + border-radius: 50%; + transition: transform 0.2s ease; +} + +.a2ui-toggle-input:checked + .a2ui-toggle-slider { + background-color: var(--a2ui-button-bg); + border-color: var(--a2ui-button-bg); +} + +.a2ui-toggle-input:checked + .a2ui-toggle-slider::before { + transform: translateX(20px); + background-color: white; +} + +.a2ui-toggle-input:disabled + .a2ui-toggle-slider { + opacity: 0.55; + cursor: not-allowed; +} + +.a2ui-toggle-label { + font-size: var(--a2ui-font-size); + color: var(--a2ui-fg); +} + +/* HTML Component */ +.a2ui-html-container { + width: 100%; + overflow: auto; +} + +.a2ui-html-sandbox { + width: 100%; + min-height: 200px; + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); +} + +/* Charts */ +.a2ui-chart-container { + width: 100%; + height: 300px; + position: relative; +} + +.a2ui-chart-svg { + width: 100%; + height: 100%; + font-family: var(--a2ui-font); + font-size: 8px; +} + +.a2ui-chart-title { + font-size: 12px; + font-weight: bold; + fill: var(--a2ui-fg); +} + +.a2ui-chart-error { + padding: var(--a2ui-padding); + color: var(--vscode-errorForeground, #f44747); + background: color-mix(in srgb, var(--a2ui-card-bg) 84%, transparent); + border-radius: var(--a2ui-radius); +} + +/* BarChart */ +.a2ui-bar-rect { + transition: opacity 0.2s; +} +.a2ui-bar-rect:hover { + opacity: 0.8; +} +.a2ui-bar-label { + font-size: 6px; + fill: var(--a2ui-fg); +} +.a2ui-bar-value { + font-size: 6px; + fill: var(--a2ui-fg); + font-weight: bold; +} + +/* LineChart */ +.a2ui-line-path { + vector-effect: non-scaling-stroke; +} +.a2ui-line-point { + transition: r 0.2s; +} +.a2ui-line-point:hover { + r: 6; +} +.a2ui-line-label { + font-size: 8px; + fill: var(--a2ui-fg); +} + +/* PieChart */ +.a2ui-pie-slice { + transition: opacity 0.2s; + cursor: pointer; +} +.a2ui-pie-slice:hover { + opacity: 0.8; +} +.a2ui-legend-text { + font-size: 8px; + fill: var(--a2ui-fg); +} + +/* Error state */ +.a2ui-error { + color: var(--vscode-errorForeground, #f44747); + padding: var(--a2ui-padding); + border: 1px solid var(--vscode-errorForeground, #f44747); + border-radius: var(--a2ui-radius); +} + +/* Streaming indicator — shown when render_ui is called with streaming:true */ +.a2ui-streaming-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 4px; + color: var(--vscode-descriptionForeground, #888); + font-size: calc(var(--a2ui-font-size) - 1px); +} + +.a2ui-streaming-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vscode-descriptionForeground, #888); + animation: a2ui-streaming-pulse 1.2s infinite ease-in-out; +} + +.a2ui-streaming-dot:nth-child(1) { animation-delay: 0s; } +.a2ui-streaming-dot:nth-child(2) { animation-delay: 0.2s; } +.a2ui-streaming-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes a2ui-streaming-pulse { + 0%, 80%, 100% { opacity: 0.25; transform: scale(0.85); } + 40% { opacity: 1; transform: scale(1); } +} + +.a2ui-streaming-label { + margin-left: 2px; +} diff --git a/media/a2ui.html b/media/a2ui.html new file mode 100644 index 0000000..bdc62d6 --- /dev/null +++ b/media/a2ui.html @@ -0,0 +1,21 @@ + + + + + + + {{title}} + + + +
+ {{diagnosticsHtml}} + {{surfaceHtml}} + {{streamingIndicatorHtml}} +
+ + + diff --git a/media/main.css b/media/main.css index ef6b80c..a451a5e 100644 --- a/media/main.css +++ b/media/main.css @@ -1071,20 +1071,31 @@ button:disabled { .input-area { display: flex; align-items: stretch; + gap: 4px; } -.attach-btn { +.input-tools-stack { flex-shrink: 0; - width: 32px; display: flex; - align-items: flex-start; + flex-direction: column; + justify-content: flex-start; + gap: 2px; + padding: 2px 0; +} + +.attach-btn { + width: 24px; + min-height: 24px; + display: flex; + align-items: center; justify-content: center; background: transparent; border: none; cursor: pointer; color: var(--vscode-descriptionForeground); - padding: 8px 4px; - border-radius: 4px; + padding: 2px; + border-radius: 3px; + transition: background-color 0.15s ease, color 0.15s ease; } .attach-btn:hover { @@ -1100,15 +1111,18 @@ button:disabled { .input-area .textarea-wrapper { flex: 1; position: relative; + display: flex; + flex-direction: column; + min-height: 52px; } .input-area .textarea-wrapper textarea { width: 100%; - min-height: auto !important; - max-height: none !important; + min-height: 52px; + max-height: none; /* Height controlled dynamically by JS + resize handle */ height: auto; - padding: 8px 12px 8px 0; + padding: 4px 8px 6px 6px; border: none !important; background: transparent !important; color: var(--vscode-input-foreground); @@ -1118,10 +1132,16 @@ button:disabled { resize: none; /* Focus is handled by parent .input-container:focus-within */ outline: 2px solid transparent; - /* Keeps a11y compliance while visually hidden */ + /* Keeps a11y compliance while vertically hidden */ box-sizing: border-box; + /* Ensure cursor is always text */ + cursor: text; + /* Fix clicking issue - ensure element receives pointer events */ + pointer-events: auto; + /* Let JS control overflow for auto-resize - hidden allows scrollHeight to work correctly */ overflow: hidden; - /* Height controlled dynamically by JS */ + /* Align text to top */ + vertical-align: top; } .input-area .textarea-wrapper textarea:focus { @@ -2259,3 +2279,57 @@ button:disabled { color: var(--vscode-testing-iconUnset); font-size: 12px; } + + +/* Settings Tab Styles */ +.settings-container { + padding: 16px; + max-width: 600px; + margin: 0 auto; +} + +.settings-section-title { + font-size: 1.1em; + font-weight: 600; + margin: 0 0 16px 0; + color: var(--vscode-foreground); + border-bottom: 1px solid var(--vscode-panel-border); + padding-bottom: 8px; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.settings-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: 1px solid var(--vscode-button-border); + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: background 0.2s ease; +} + +.settings-btn:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.settings-btn .codicon { + font-size: 1.2em; +} + +.settings-description { + font-size: 0.85em; + color: var(--vscode-descriptionForeground); + margin: 0; + padding: 0 4px; +} diff --git a/media/webview.html b/media/webview.html index 44c9009..19d1719 100644 --- a/media/webview.html +++ b/media/webview.html @@ -51,7 +51,7 @@

- +

@@ -78,6 +78,14 @@

aria-label="{{historyFilterPlanReview}}"> + +
+
+ + +
- +
@@ -281,6 +293,17 @@

historyFilterAll: "{{historyFilterAll}}", historyFilterAskUser: "{{historyFilterAskUser}}", historyFilterPlanReview: "{{historyFilterPlanReview}}", + historyFilterWhiteboard: "{{historyFilterWhiteboard}}", + whiteboard: "{{whiteboard}}", + openWhiteboard: "{{openWhiteboard}}", + whiteboardSubmitted: "{{whiteboardSubmitted}}", + detailWhiteboard: "{{detailWhiteboard}}", + detailWhiteboardContext: "{{detailWhiteboardContext}}", + detailWhiteboardCanvases: "{{detailWhiteboardCanvases}}", + detailWhiteboardSubmittedCanvases: "{{detailWhiteboardSubmittedCanvases}}", + detailWhiteboardNoCanvases: "{{detailWhiteboardNoCanvases}}", + detailWhiteboardSession: "{{detailWhiteboardSession}}", + detailWhiteboardStatus: "{{detailWhiteboardStatus}}", // Batch selection batchSelectMode: "{{batchSelectMode}}", batchExitSelectMode: "{{batchExitSelectMode}}", @@ -300,6 +323,9 @@

debugMockAskUserMultiStepLongText: "{{debugMockAskUserMultiStepLongText}}", debugMockPlanReview: "{{debugMockPlanReview}}", debugMockWalkthroughReview: "{{debugMockWalkthroughReview}}", + debugSectionWhiteboard: "{{debugSectionWhiteboard}}", + debugMockWhiteboard: "{{debugMockWhiteboard}}", + submitted: "{{submitted}}", }; window.__CONFIG__ = { historyTimeDisplay: "{{historyTimeDisplay}}", @@ -311,4 +337,4 @@

- \ No newline at end of file + diff --git a/media/whiteboard.css b/media/whiteboard.css new file mode 100644 index 0000000..e68b930 --- /dev/null +++ b/media/whiteboard.css @@ -0,0 +1,324 @@ +:root { + color-scheme: light dark; +} + +body { + margin: 0; + padding: 0; + font-family: var(--vscode-font-family); + color: var(--vscode-foreground); + background: var(--vscode-editor-background); +} + +button, +input, +label { + font: inherit; +} + +.whiteboard-shell { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 100vh; + padding: 16px; + box-sizing: border-box; +} + +.whiteboard-header, +.toolbar, +.canvas-library, +.whiteboard-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.whiteboard-header h1 { + margin: 0; + font-size: 1.4rem; +} + +.whiteboard-context { + margin: 4px 0 0; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; +} + +.whiteboard-status { + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--vscode-panel-border); + background: var(--vscode-sideBar-background); +} + +.whiteboard-status[data-state='error'] { + color: var(--vscode-errorForeground, #f14c4c); +} + +.toolbar, +.canvas-library { + padding: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + background: var(--vscode-sideBar-background); + flex-wrap: wrap; +} + +.toolbar-group, +.canvas-actions, +.toolbar-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.tool-btn, +.submit-btn, +.canvas-tab { + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 8px; + padding: 8px 12px; + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tool-btn:hover, +.canvas-tab:hover { + background: var(--vscode-list-hoverBackground); +} + +.tool-btn.active, +.canvas-tab.active { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.tool-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.submit-btn { + border: none; + border-radius: 4px; + padding: 8px 16px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-size: 13px; + font-weight: 500; +} + +.submit-btn:hover { + background: var(--vscode-button-hoverBackground); +} + +.submit-btn-secondary { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); +} + +.submit-btn-secondary:hover { + background: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground)); +} + +label { + display: inline-flex; + align-items: center; + gap: 6px; +} + +input[type='range'] { + width: 120px; +} + +.canvas-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.canvas-surface { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 12px; + flex: 1; + min-height: 0; +} + +.right-sidebar { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sidebar-panel { + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + background: var(--vscode-sideBar-background); + padding: 12px; +} + +.sidebar-panel h3 { + margin: 0 0 8px 0; + font-size: 0.9rem; + color: var(--vscode-descriptionForeground); +} + +.style-controls { + display: flex; + flex-direction: column; + gap: 8px; +} + +.style-controls label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sidebar-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-actions .submit-btn { + width: 100%; + justify-content: center; +} + +.canvas-stage { + overflow: auto; + padding: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); +} + +.whiteboard-hydration-error { + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--vscode-inputValidation-errorBorder, var(--vscode-errorForeground, #f14c4c)); + background: var(--vscode-inputValidation-errorBackground, rgba(241, 76, 76, 0.12)); + color: var(--vscode-inputValidation-errorForeground, var(--vscode-errorForeground, #f14c4c)); +} + +.canvas-surface[data-state='error'] .canvas-stage { + border-color: var(--vscode-inputValidation-errorBorder, var(--vscode-errorForeground, #f14c4c)); +} + +.canvas-surface[data-state='error'] #whiteboard-canvas, +.canvas-surface[data-state='error'] .canvas-stage .canvas-container { + opacity: 0.45; +} + +#whiteboard-canvas, +.canvas-stage .canvas-container, +.canvas-stage .canvas-container canvas { + max-width: 100%; +} + +.canvas-stage .canvas-container { + display: block; + width: 100%; + height: auto !important; + aspect-ratio: var(--whiteboard-aspect-ratio, 16 / 9); +} + +#whiteboard-canvas { + display: block; + width: 100%; + max-width: 100%; + height: 100%; + aspect-ratio: var(--whiteboard-aspect-ratio, 16 / 9); + background: #fff; + border-radius: 10px; + box-shadow: 0 0 0 1px rgba(127, 127, 127, 0.25); +} + +.canvas-stage .canvas-container canvas { + width: 100% !important; + height: 100% !important; +} + +.canvas-help ul { + margin: 0; + padding-left: 18px; + color: var(--vscode-descriptionForeground); +} + +.whiteboard-footer { + justify-content: flex-end; + flex-direction: column; + align-items: stretch; +} + +.comment-section { + width: 100%; + display: none; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.comment-section.visible { + display: flex; +} + +.comment-label { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.comment-textarea { + width: 100%; + min-height: 80px; + padding: 8px 12px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 6px; + background: var(--vscode-input-background, var(--vscode-editor-background)); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font-family: var(--vscode-font-family); + font-size: 13px; + line-height: 1.4; + resize: vertical; + box-sizing: border-box; +} + +.comment-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.comment-textarea::placeholder { + color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); +} + +.comment-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +@media (max-width: 1100px) { + .canvas-surface { + grid-template-columns: 1fr; + } + + .right-sidebar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + } +} diff --git a/media/whiteboard.html b/media/whiteboard.html new file mode 100644 index 0000000..8101d06 --- /dev/null +++ b/media/whiteboard.html @@ -0,0 +1,113 @@ + + + + + + + {{title}} + + + + +
+
+
+

{{title}}

+

Loading whiteboard context…

+
+
Starting whiteboard…
+
+ +
+
+ + + + + + + + + + +
+ +
+ + + + + +
+
+ +
+
+
+ + +
+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ + + + diff --git a/package-lock.json b/package-lock.json index 6a878f6..0d8aaa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,25 @@ "version": "0.1.30", "license": "MIT", "dependencies": { + "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "^1.25.2", "@vscode/codicons": "^0.0.43", + "dompurify": "^3.3.3", + "fabric": "^6.9.1", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", - "zod": "^4.1.13" + "mermaid": "^11.13.0", + "zod": "^4.1.13", + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/jsdom": "^28.0.0", "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.1", "@types/vscode": "^1.104.0", "esbuild": "^0.27.1", + "jsdom": "^28.1.0", "npm-run-all": "^4.1.5", "tsx": "^4.21.0", "typescript": "^5.9.3" @@ -28,6 +36,260 @@ "vscode": "^1.104.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmmirror.com/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", @@ -470,6 +732,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "1.19.7", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", @@ -482,6 +762,93 @@ "hono": "^4" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", @@ -521,69 +888,453 @@ } } }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", "license": "MIT", "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" + "@types/d3-selection": "*" } }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "@types/d3-array": "*", + "@types/geojson": "*" } }, - "node_modules/@types/vscode": { - "version": "1.106.1", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.106.1.tgz", - "integrity": "sha512-R/HV8u2h8CAddSbX8cjpdd7B8/GnE4UjgjpuGuHcbp1xV6yh4OeqU4L1pKjlwujCrSFS0MOpwJAIs/NexMB1fQ==", - "dev": true, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", "license": "MIT" }, - "node_modules/@vscode/codicons": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.43.tgz", - "integrity": "sha512-8sf8WOBoZkyUi8ogCm5ycHJJGhwOEG3E9b64+JIx+m6bCExdkc30VwCwr94cXUU1opmRD0CTCWLcN46I8WLJIg==", - "license": "CC-BY-4.0" + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" + "@types/d3-selection": "*" } }, - "node_modules/ajv": { - "version": "8.17.1", + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmmirror.com/@types/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-A8TBQQC/xAOojy9kM8E46cqT00sF0h7dWjV8t8BJhUi2rG6JRh7XXQo/oLoENuZIQEpXsxLccLCnknyQd7qssQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.22.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.22.0.tgz", + "integrity": "sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.106.1", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.106.1.tgz", + "integrity": "sha512-R/HV8u2h8CAddSbX8cjpdd7B8/GnE4UjgjpuGuHcbp1xV6yh4OeqU4L1pKjlwujCrSFS0MOpwJAIs/NexMB1fQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vscode/codicons": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.43.tgz", + "integrity": "sha512-8sf8WOBoZkyUi8ogCm5ycHJJGhwOEG3E9b64+JIx+m6bCExdkc30VwCwr94cXUU1opmRD0CTCWLcN46I8WLJIg==", + "license": "CC-BY-4.0" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", @@ -615,6 +1366,16 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -628,6 +1389,28 @@ "node": ">=4" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -683,6 +1466,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -703,9 +1493,19 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -734,7 +1534,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -813,6 +1613,32 @@ "node": ">=4" } }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -830,13 +1656,58 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -890,6 +1761,15 @@ "node": ">= 0.10" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -904,298 +1784,680 @@ "node": ">= 8" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "node": ">=20" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "cose-base": "^1.0.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "cose-base": "^2.2.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=12" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", "dependencies": { - "is-arrayish": "^0.2.1" + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" } }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" + "delaunator": "5" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", "dependencies": { - "es-errors": "^1.3.0" + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -1204,150 +2466,764 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", - "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "dev": true, - "hasInstallScript": true, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "optional": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fabric": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/fabric/-/fabric-6.9.1.tgz", + "integrity": "sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "jsdom": "^20.0.1" + } + }, + "node_modules/fabric/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/fabric/node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmmirror.com/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/fabric/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fabric/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/fabric/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fabric/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/fabric/node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, "engines": { - "node": ">=18" + "node": ">=8" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.1", - "@esbuild/android-arm": "0.27.1", - "@esbuild/android-arm64": "0.27.1", - "@esbuild/android-x64": "0.27.1", - "@esbuild/darwin-arm64": "0.27.1", - "@esbuild/darwin-x64": "0.27.1", - "@esbuild/freebsd-arm64": "0.27.1", - "@esbuild/freebsd-x64": "0.27.1", - "@esbuild/linux-arm": "0.27.1", - "@esbuild/linux-arm64": "0.27.1", - "@esbuild/linux-ia32": "0.27.1", - "@esbuild/linux-loong64": "0.27.1", - "@esbuild/linux-mips64el": "0.27.1", - "@esbuild/linux-ppc64": "0.27.1", - "@esbuild/linux-riscv64": "0.27.1", - "@esbuild/linux-s390x": "0.27.1", - "@esbuild/linux-x64": "0.27.1", - "@esbuild/netbsd-arm64": "0.27.1", - "@esbuild/netbsd-x64": "0.27.1", - "@esbuild/openbsd-arm64": "0.27.1", - "@esbuild/openbsd-x64": "0.27.1", - "@esbuild/openharmony-arm64": "0.27.1", - "@esbuild/sunos-x64": "0.27.1", - "@esbuild/win32-arm64": "0.27.1", - "@esbuild/win32-ia32": "0.27.1", - "@esbuild/win32-x64": "0.27.1" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, + "node_modules/fabric/node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/fabric/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, "engines": { - "node": ">=0.8.0" + "node": ">=6" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/fabric/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1" + }, "engines": { - "node": ">= 0.6" + "node": ">=12" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "node_modules/fabric/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "license": "MIT", + "optional": true, "dependencies": { - "eventsource-parser": "^3.0.1" + "xml-name-validator": "^4.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "node_modules/fabric/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "license": "MIT", + "optional": true, "engines": { - "node": ">=18.0.0" + "node": ">=12" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "node_modules/fabric/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "license": "MIT", + "optional": true, "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=12" } }, - "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", + "node_modules/fabric/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "optional": true, "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" + "node": ">=12" } }, "node_modules/fast-deep-equal": { @@ -1409,6 +3285,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1427,6 +3343,39 @@ "node": ">= 0.8" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1482,6 +3431,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -1560,6 +3531,28 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -1596,6 +3589,12 @@ "dev": true, "license": "ISC" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -1664,7 +3663,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1676,6 +3675,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1714,6 +3720,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1734,6 +3753,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -1750,6 +3797,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1771,6 +3830,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1932,10 +4000,20 @@ "call-bound": "^1.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" } }, "node_modules/is-generator-function": { @@ -2001,6 +4079,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2174,6 +4259,73 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -2193,6 +4345,59 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/katex": { + "version": "0.16.38", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2218,6 +4423,48 @@ "node": ">=4" } }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -2235,6 +4482,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2244,6 +4503,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -2280,6 +4546,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mermaid": { + "version": "11.13.0", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2309,7 +4604,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2318,12 +4613,81 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mlly": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2340,6 +4704,68 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -2442,6 +4868,27 @@ "which": "bin/which" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT", + "optional": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2533,6 +4980,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -2547,6 +5000,32 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2556,6 +5035,22 @@ "node": ">= 0.8" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2595,6 +5090,12 @@ "node": ">=4" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, "node_modules/pidtree": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", @@ -2627,6 +5128,33 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -2650,6 +5178,29 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -2674,6 +5225,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT", + "optional": true + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2713,6 +5271,21 @@ "node": ">=4" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -2766,6 +5339,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2794,7 +5374,42 @@ "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" } }, "node_modules/router": { @@ -2813,6 +5428,12 @@ "node": ">= 18" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -2833,6 +5454,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -2874,6 +5516,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", @@ -2921,6 +5576,13 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -3082,6 +5744,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -3141,6 +5851,31 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -3219,6 +5954,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -3229,6 +5977,12 @@ "node": ">=4" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -3255,6 +6009,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3264,6 +6082,41 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -3396,6 +6249,12 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -3415,6 +6274,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3422,6 +6291,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3431,6 +6310,37 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -3451,6 +6361,129 @@ "node": ">= 0.8" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3555,12 +6588,68 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/zod": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", @@ -3571,9 +6660,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index 4ad6c13..a51bd98 100644 --- a/package.json +++ b/package.json @@ -350,6 +350,100 @@ ] } }, + { + "name": "open_whiteboard", + "tags": [ + "whiteboard", + "diagramming", + "visual-context", + "user-interaction", + "seamless-agent" + ], + "toolReferenceName": "openWhiteboard", + "displayName": "Open Whiteboard", + "modelDescription": "Open a standalone whiteboard so the user can sketch or annotate visual context for the agent. This remains an image-first tool: the whiteboard returns exported PNG image URIs, and the model should reason over those images instead of coordinate metadata. Use blankCanvas for an empty board, importImages to preload screenshots or mockups for annotation, or initialCanvases with seedElements/fabricState when you need seeded starter content. The result includes an explicit action: `approved` means use the returned whiteboard images as confirmed user input, while `recreateWithChanges` means the user requested revisions and you MUST address the annotated feedback and call open_whiteboard again before concluding.", + "canBeReferencedInPrompt": true, + "icon": "$(symbol-color)", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "Instructions for the user about what to draw or annotate." + }, + "title": { + "type": "string", + "description": "Title for the whiteboard panel." + }, + "blankCanvas": { + "type": "boolean", + "description": "Open a blank canvas. Defaults to true." + }, + "initialCanvases": { + "type": "array", + "description": "Optional starter canvases. Use seedElements for coordinate-first starter sketches or fabricState to reopen an existing canvas session.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name for the pre-populated canvas." + }, + "fabricState": { + "type": "string", + "description": "Optional serialized Fabric.js canvas state. Prefer seedElements for new starter content." + }, + "seedElements": { + "type": "array", + "description": "Optional coordinate-first starter elements for agent-authored sketches.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "rectangle", + "circle", + "triangle", + "line", + "text" + ] + } + }, + "required": [ + "type" + ] + } + } + }, + "required": [ + "name" + ] + } + }, + "importImages": { + "type": "array", + "description": "Optional images to pre-load onto the canvas for the user to annotate.", + "items": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "File URI of an image to import onto the canvas." + }, + "label": { + "type": "string", + "description": "Optional label for the imported image." + } + }, + "required": [ + "uri" + ] + } + } + } + } + }, { "name": "walkthrough_review", "tags": [ @@ -384,6 +478,248 @@ "plan" ] } + }, + { + "name": "render_ui", + "tags": [ + "ui", + "rendering", + "surface", + "user-interaction", + "seamless-agent" + ], + "toolReferenceName": "renderUI", + "displayName": "Render UI Surface", + "modelDescription": "Render a structured UI surface in a dedicated panel using a flat component list with parentId adjacency. Pass surfaceId to identify or reuse a panel. Author component-specific fields inside component.props; top-level component fields are still accepted for compatibility, and canonical A2UI-style component payloads and JSON Pointer bindings are also supported. Supports layout components (Row, Column, Card, Divider), content components (Text, Heading, Image, Markdown, CodeBlock, MermaidDiagram), form components (Button, TextField, Checkbox, Select), indicator components (ProgressBar, Badge), and chart components (BarChart, LineChart, PieChart). For MermaidDiagram pass the diagram definition in props.diagram (or: definition, source, code, text, content). For BarChart/LineChart/PieChart pass props.data as an array of { label, value } objects plus an optional props.title string. Use enableA2UI to run the built-in validation and enhancement pass, and a2uiLevel to choose basic or strict behavior. Markdown content is rendered as formatted HTML, form controls collect values into userAction.data, and waitForAction: true blocks until the user fires a Button action. Set deleteSurface: true with a surfaceId to close an existing panel instead of rendering. When A2UI is enabled, the result also includes diagnostics and applied enhancements. Use waitForAction: false (default) to render immediately and continue without waiting. The result always includes surfaceId and rendered fields.", + "canBeReferencedInPrompt": true, + "icon": "$(layout)", + "inputSchema": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + "description": "Optional unique surface identifier. Re-using the same surfaceId will update an existing panel." + }, + "title": { + "type": "string", + "description": "Optional panel title displayed in the webview header." + }, + "components": { + "type": "array", + "description": "Flat list of UI components with optional parentId adjacency for nesting. Required unless deleteSurface is true.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique component identifier." + }, + "component": { + "type": "object", + "description": "Component definition. Must include a 'type' field.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Row", + "Column", + "Card", + "Divider", + "Text", + "Heading", + "Image", + "Markdown", + "CodeBlock", + "Button", + "TextField", + "Checkbox", + "Select", + "MermaidDiagram", + "ProgressBar", + "Badge", + "BarChart", + "LineChart", + "PieChart" + ] + }, + "props": { + "type": "object", + "description": "Component-specific properties. Supports $data.path binding syntax. For MermaidDiagram, pass the diagram definition in any of: diagram, definition, source, code, text, or content. For BarChart/LineChart/PieChart, pass a data array of { label, value } objects and an optional title string." + } + } + }, + "parentId": { + "type": "string", + "description": "ID of the parent component. Omit for root-level components." + } + }, + "required": [ + "id", + "component" + ] + } + }, + "dataModel": { + "type": "object", + "description": "Data model for $data.path binding resolution in component props." + }, + "enableA2UI": { + "type": "boolean", + "description": "Enable the built-in A2UI validation and enhancement pass before rendering. Defaults to false." + }, + "a2uiLevel": { + "type": "string", + "enum": [ + "basic", + "strict" + ], + "description": "A2UI processing level. Use strict for stronger validation and helper affordances." + }, + "waitForAction": { + "type": "boolean", + "description": "If true, block until the user fires a Button action. Defaults to false." + }, + "deleteSurface": { + "type": "boolean", + "description": "If true, close and remove an existing surface identified by surfaceId instead of rendering components." + } + }, + "required": [] + } + }, + { + "name": "update_ui", + "tags": [ + "ui", + "rendering", + "surface", + "seamless-agent" + ], + "toolReferenceName": "updateUI", + "displayName": "Update UI Surface", + "modelDescription": "Update the dataModel and/or title of an existing surface identified by surfaceId without resending the full component tree. At least one of title or dataModel must be provided. Providing dataModel triggers a re-render of the panel with the new data bindings resolved. Use this for efficient data refresh cycles after the initial render_ui call. The result includes surfaceId and applied fields; notFound is set to true when the surface does not exist.", + "canBeReferencedInPrompt": true, + "icon": "$(edit)", + "inputSchema": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + "description": "The surface identifier of the panel to update." + }, + "title": { + "type": "string", + "description": "Optional new panel title. Applied immediately to the open panel. Can be combined with dataModel, or used alone." + }, + "dataModel": { + "type": "object", + "description": "Replacement data model for $data.path binding resolution. Triggers a re-render when provided." + } + }, + "required": [ + "surfaceId" + ] + } + }, + { + "name": "append_ui", + "tags": [ + "ui", + "rendering", + "surface", + "seamless-agent" + ], + "toolReferenceName": "appendUI", + "displayName": "Append to UI Surface", + "modelDescription": "Append one or more components onto an existing surface identified by surfaceId without replacing the current component tree. Optionally update the panel title before appending. Use this to progressively build up a surface by adding components incrementally after the initial render_ui call. The result includes surfaceId and applied fields; notFound is set to true when the surface does not exist.", + "canBeReferencedInPrompt": true, + "icon": "$(add)", + "inputSchema": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + "description": "The surface identifier of the panel to append onto." + }, + "components": { + "type": "array", + "description": "Non-empty list of components to append to the existing surface.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique component identifier." + }, + "component": { + "type": "object", + "description": "Component definition. Must include a 'type' field matching a supported catalog type." + }, + "parentId": { + "type": "string", + "description": "ID of the parent component. Omit for root-level components." + } + }, + "required": [ + "id", + "component" + ] + } + }, + "title": { + "type": "string", + "description": "Optional new panel title. When provided, the panel title is updated before the new components are appended." + } + }, + "required": [ + "surfaceId", + "components" + ] + } + }, + { + "name": "close_ui", + "tags": [ + "ui", + "surface", + "seamless-agent" + ], + "toolReferenceName": "closeUI", + "displayName": "Close UI Surface", + "modelDescription": "Close an existing surface panel identified by surfaceId. Use this to dismiss a panel that is no longer needed. The result includes surfaceId and closed fields indicating whether the panel was successfully closed.", + "canBeReferencedInPrompt": true, + "icon": "$(close)", + "inputSchema": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + "description": "The surface identifier of the panel to close." + } + }, + "required": [ + "surfaceId" + ] + } + }, + { + "name": "list_surfaces", + "tags": [ + "ui", + "surface", + "seamless-agent", + "discovery" + ], + "toolReferenceName": "listSurfaces", + "displayName": "List UI Surfaces", + "modelDescription": "List all currently active UI surface panels with their metadata including surfaceId, title, and creation timestamp. Use this tool to discover existing surfaces before updating, appending to, or closing them. An empty surfaces array means no panels are currently open.", + "canBeReferencedInPrompt": true, + "icon": "$(list-tree)", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } } ], "commands": [ @@ -409,6 +745,12 @@ "title": "%command.clearHistory.title%", "icon": "$(trash)", "category": "Seamless Agent" + }, + { + "command": "seamless-agent.showLogs", + "title": "%command.showLogs.title%", + "icon": "$(output)", + "category": "Seamless Agent" } ] }, @@ -427,19 +769,27 @@ "test": "node --import tsx --test \"src/**/*.test.ts\"" }, "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/jsdom": "^28.0.0", "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.1", "@types/vscode": "^1.104.0", "esbuild": "^0.27.1", + "jsdom": "^28.1.0", "npm-run-all": "^4.1.5", "tsx": "^4.21.0", "typescript": "^5.9.3" }, "dependencies": { + "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "^1.25.2", "@vscode/codicons": "^0.0.43", + "dompurify": "^3.3.3", + "fabric": "^6.9.1", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", - "zod": "^4.1.13" + "mermaid": "^11.13.0", + "zod": "^4.1.13", + "zod-to-json-schema": "^3.25.1" } } diff --git a/package.nls.json b/package.nls.json index eb6f6ca..716bf90 100644 --- a/package.nls.json +++ b/package.nls.json @@ -49,6 +49,8 @@ "session.noRecent": "No recent sessions", "session.clearHistory": "Clear History", "console.settings": "Settings", + "console.diagnostics": "Diagnostics", + "console.diagnosticsDescription": "View extension logs and diagnose issues with the badge count.", "approvePlan.button.addComment": "Add Comment", "approvePlan.button.approve": "Approve", "approvePlan.button.cancel": "Cancel", @@ -106,16 +108,32 @@ "detail.response": "Response", "detail.noResponse": "No response", "detail.options": "Options", + "detail.whiteboard": "Whiteboard", + "detail.whiteboardContext": "Context", + "detail.whiteboardCanvases": "Canvases", + "detail.whiteboardSubmittedCanvases": "Submitted canvases", + "detail.whiteboardNoCanvases": "No canvases stored", + "detail.whiteboardSession": "Whiteboard session", + "detail.whiteboardStatus": "Status", + "detail.renderUI": "UI Surface", + "detail.renderUISurfaceId": "Surface ID", + "detail.renderUIComponents": "Components", + "detail.renderUIUserAction": "User action", + "detail.renderUIDismissed": "Dismissed", "history.filter.all": "All", "history.filter.askUser": "Ask User", "history.filter.planReview": "Plan Review", + "history.filter.whiteboard": "Whiteboard", + "history.filter.renderUI": "UI Surface", "history.viewDetail": "View Detail", "command.cancelPendingPlans.title": "Cancel Pending Plans", "command.showPending.title": "Show Pending Requests", "command.showHistory.title": "Show History", "command.clearHistory.title": "Clear History", + "command.showLogs.title": "Show Extension Logs", "status.closed": "Closed", "status.active": "Active", + "status.submitted": "Submitted", "errors.noSuchInteraction": "Interaction not found.", "batch.selectMode": "Select", "batch.exitSelectMode": "Cancel", @@ -131,11 +149,34 @@ "debug.sectionAskUser": "Ask User", "debug.sectionPlanReview": "Plan Review", "debug.sectionWalkthroughReview": "Walkthrough Review", + "debug.sectionWhiteboard": "Whiteboard", "debug.mockAskUser": "Plain Question", "debug.mockAskUserOptions": "Options Question", "debug.mockAskUserMultiStep": "Multi-Step Question", "debug.mockAskUserMultiStepLongText": "Multi-Step Long Text Options", "debug.mockAskUserDedupTest": "Dedup Test (3 concurrent)", "debug.mockPlanReview": "Plan Review", - "debug.mockWalkthroughReview": "Walkthrough Review" + "debug.mockWalkthroughReview": "Walkthrough Review", + "debug.mockWhiteboard": "Open Whiteboard", + "whiteboard": "Whiteboard", + "openWhiteboard": "Open Whiteboard", + "whiteboardContext": "Context/instructions for this whiteboard", + "whiteboardTitle": "Whiteboard", + "whiteboardSubmitted": "Whiteboard submitted", + "whiteboardCancelled": "Whiteboard cancelled", + "whiteboardNewCanvas": "New Canvas", + "whiteboardDeleteCanvas": "Delete Canvas", + "whiteboardSubmit": "Submit", + "whiteboardCancel": "Cancel", + "whiteboardUndo": "Undo", + "whiteboardRedo": "Redo", + "whiteboardClear": "Clear Canvas", + "whiteboardToolPen": "Pen", + "whiteboardToolHighlighter": "Highlighter", + "whiteboardToolRectangle": "Rectangle", + "whiteboardToolCircle": "Circle", + "whiteboardToolLine": "Line", + "whiteboardToolArrow": "Arrow", + "whiteboardToolText": "Text", + "whiteboardToolEraser": "Eraser" } diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index e06e79f..20180b5 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -98,9 +98,17 @@ "detail.response": "Resposta", "detail.noResponse": "Sem resposta", "detail.options": "Opções", + "detail.whiteboard": "Quadro branco", + "detail.whiteboardContext": "Contexto", + "detail.whiteboardCanvases": "Telas", + "detail.whiteboardSubmittedCanvases": "Telas enviadas", + "detail.whiteboardNoCanvases": "Nenhuma tela armazenada", + "detail.whiteboardSession": "Sessão de quadro branco", + "detail.whiteboardStatus": "Status", "history.filter.all": "Tudo", "history.filter.askUser": "Perguntar ao usuário", "history.filter.planReview": "Revisão de Plano", + "history.filter.whiteboard": "Quadro branco", "history.viewDetail": "Visualizar Detalhes", "session.recentSessions": "Sessões Recentes", "session.interactions": "{0} interações", @@ -116,6 +124,7 @@ "command.clearHistory.title": "Limpar Histórico", "status.closed": "Fechado", "status.active": "Ativo", + "status.submitted": "Enviado", "errors.noSuchInteraction": "Interação não encontrada.", "batch.selectMode": "Selecionar", "batch.exitSelectMode": "Cancelar", @@ -126,5 +135,38 @@ "confirm.deleteSelected": "Tem certeza que deseja excluir {0} itens selecionados?", "console.orTypeYourOwn": "Ou digite sua própria resposta abaixo", "response.selectedOptions": "Opções selecionadas:", - "response.additionalResponse": "Resposta adicional:" + "response.additionalResponse": "Resposta adicional:", + "console.debugTools": "Depuração", + "debug.sectionAskUser": "Perguntar ao usuário", + "debug.sectionPlanReview": "Revisão de Plano", + "debug.sectionWalkthroughReview": "Revisão guiada", + "debug.sectionWhiteboard": "Quadro branco", + "debug.mockAskUser": "Pergunta simples", + "debug.mockAskUserOptions": "Pergunta com opções", + "debug.mockAskUserMultiStep": "Pergunta em várias etapas", + "debug.mockAskUserMultiStepLongText": "Pergunta em várias etapas com texto longo", + "debug.mockPlanReview": "Revisão de Plano", + "debug.mockWalkthroughReview": "Revisão guiada", + "debug.mockWhiteboard": "Abrir quadro branco", + "whiteboard": "Quadro branco", + "openWhiteboard": "Abrir quadro branco", + "whiteboardContext": "Contexto/instruções para este quadro branco", + "whiteboardTitle": "Quadro branco", + "whiteboardSubmitted": "Quadro branco enviado", + "whiteboardCancelled": "Quadro branco cancelado", + "whiteboardNewCanvas": "Nova tela", + "whiteboardDeleteCanvas": "Excluir tela", + "whiteboardSubmit": "Enviar", + "whiteboardCancel": "Cancelar", + "whiteboardUndo": "Desfazer", + "whiteboardRedo": "Refazer", + "whiteboardClear": "Limpar tela", + "whiteboardToolPen": "Caneta", + "whiteboardToolHighlighter": "Marca-texto", + "whiteboardToolRectangle": "Retângulo", + "whiteboardToolCircle": "Círculo", + "whiteboardToolLine": "Linha", + "whiteboardToolArrow": "Seta", + "whiteboardToolText": "Texto", + "whiteboardToolEraser": "Borracha" } diff --git a/package.nls.pt.json b/package.nls.pt.json index 9c2f946..5072eb1 100644 --- a/package.nls.pt.json +++ b/package.nls.pt.json @@ -106,9 +106,17 @@ "detail.response": "Resposta", "detail.noResponse": "Sem resposta", "detail.options": "Opções", + "detail.whiteboard": "Quadro branco", + "detail.whiteboardContext": "Contexto", + "detail.whiteboardCanvases": "Telas", + "detail.whiteboardSubmittedCanvases": "Telas submetidas", + "detail.whiteboardNoCanvases": "Nenhuma tela armazenada", + "detail.whiteboardSession": "Sessão de quadro branco", + "detail.whiteboardStatus": "Estado", "history.filter.all": "Tudo", "history.filter.askUser": "Perguntar ao utilizador", "history.filter.planReview": "Revisão de Plano", + "history.filter.whiteboard": "Quadro branco", "history.viewDetail": "Ver Detalhes", "command.cancelPendingPlans.title": "Cancelar Planos Pendentes", "command.showPending.title": "Ver Pendentes", @@ -116,6 +124,7 @@ "command.clearHistory.title": "Limpar Histórico", "status.closed": "Fechado", "status.active": "Ativo", + "status.submitted": "Submetido", "errors.noSuchInteraction": "Interação não encontrada.", "batch.selectMode": "Selecionar", "batch.exitSelectMode": "Cancelar", @@ -126,5 +135,38 @@ "confirm.deleteSelected": "Tem a certeza que deseja eliminar {0} itens selecionados?", "console.orTypeYourOwn": "Ou escreva a sua própria resposta abaixo", "response.selectedOptions": "Opções selecionadas:", - "response.additionalResponse": "Resposta adicional:" + "response.additionalResponse": "Resposta adicional:", + "console.debugTools": "Depuração", + "debug.sectionAskUser": "Perguntar ao utilizador", + "debug.sectionPlanReview": "Revisão de Plano", + "debug.sectionWalkthroughReview": "Revisão guiada", + "debug.sectionWhiteboard": "Quadro branco", + "debug.mockAskUser": "Pergunta simples", + "debug.mockAskUserOptions": "Pergunta com opções", + "debug.mockAskUserMultiStep": "Pergunta em várias etapas", + "debug.mockAskUserMultiStepLongText": "Pergunta em várias etapas com texto longo", + "debug.mockPlanReview": "Revisão de Plano", + "debug.mockWalkthroughReview": "Revisão guiada", + "debug.mockWhiteboard": "Abrir quadro branco", + "whiteboard": "Quadro branco", + "openWhiteboard": "Abrir quadro branco", + "whiteboardContext": "Contexto/instruções para este quadro branco", + "whiteboardTitle": "Quadro branco", + "whiteboardSubmitted": "Quadro branco submetido", + "whiteboardCancelled": "Quadro branco cancelado", + "whiteboardNewCanvas": "Nova tela", + "whiteboardDeleteCanvas": "Eliminar tela", + "whiteboardSubmit": "Submeter", + "whiteboardCancel": "Cancelar", + "whiteboardUndo": "Desfazer", + "whiteboardRedo": "Refazer", + "whiteboardClear": "Limpar tela", + "whiteboardToolPen": "Caneta", + "whiteboardToolHighlighter": "Marcador", + "whiteboardToolRectangle": "Retângulo", + "whiteboardToolCircle": "Círculo", + "whiteboardToolLine": "Linha", + "whiteboardToolArrow": "Seta", + "whiteboardToolText": "Texto", + "whiteboardToolEraser": "Borracha" } diff --git a/resources/debug/whiteboard/test-1.json b/resources/debug/whiteboard/test-1.json new file mode 100644 index 0000000..7c09364 --- /dev/null +++ b/resources/debug/whiteboard/test-1.json @@ -0,0 +1,267 @@ +{ + "title": "Android App UI - Seeded Layout", + "context": "Review the starter Android dashboard mockup, adjust the layout if needed, and submit the final whiteboard.", + "initialCanvases": [ + { + "name": "Android Dashboard", + "seedElements": [ + { + "type": "rectangle", + "x": 80, + "y": 40, + "width": 760, + "height": 64, + "strokeColor": "#1d4ed8", + "fillColor": "#2563eb" + }, + { + "type": "text", + "x": 460, + "y": 78, + "text": "Android App Bar", + "fontSize": 22, + "fontWeight": 700, + "textAlign": "center", + "color": "#ffffff" + }, + { + "type": "rectangle", + "x": 80, + "y": 140, + "width": 250, + "height": 280, + "strokeColor": "#0f766e", + "fillColor": "rgba(15,118,110,0.12)" + }, + { + "type": "text", + "x": 160, + "y": 176, + "text": "Profile Card", + "fontSize": 18, + "fontWeight": 700, + "color": "#115e59" + }, + { + "type": "circle", + "x": 205, + "y": 245, + "radius": 46, + "strokeColor": "#0f766e", + "fillColor": "rgba(45,212,191,0.18)" + }, + { + "type": "text", + "x": 160, + "y": 320, + "text": "Name: John Doe", + "fontSize": 14, + "color": "#134e4a" + }, + { + "type": "text", + "x": 160, + "y": 346, + "text": "Role: Developer", + "fontSize": 13, + "color": "#134e4a" + }, + { + "type": "rectangle", + "x": 370, + "y": 140, + "width": 470, + "height": 210, + "strokeColor": "#b45309", + "fillColor": "rgba(251,191,36,0.14)" + }, + { + "type": "text", + "x": 540, + "y": 176, + "text": "Dashboard Stats", + "fontSize": 18, + "fontWeight": 700, + "color": "#92400e" + }, + { + "type": "rectangle", + "x": 392, + "y": 206, + "width": 120, + "height": 76, + "strokeColor": "#d97706", + "fillColor": "rgba(251,191,36,0.22)", + "rx": 10 + }, + { + "type": "text", + "x": 432, + "y": 234, + "text": "Users", + "fontSize": 12, + "fontWeight": 600, + "textAlign": "center", + "color": "#92400e" + }, + { + "type": "text", + "x": 452, + "y": 262, + "text": "1234", + "fontSize": 20, + "fontWeight": 700, + "textAlign": "center", + "color": "#78350f" + }, + { + "type": "rectangle", + "x": 548, + "y": 206, + "width": 120, + "height": 76, + "strokeColor": "#d97706", + "fillColor": "rgba(251,191,36,0.22)", + "rx": 10 + }, + { + "type": "text", + "x": 588, + "y": 234, + "text": "Sales", + "fontSize": 12, + "fontWeight": 600, + "textAlign": "center", + "color": "#92400e" + }, + { + "type": "text", + "x": 608, + "y": 262, + "text": "$56K", + "fontSize": 20, + "fontWeight": 700, + "textAlign": "center", + "color": "#78350f" + }, + { + "type": "rectangle", + "x": 704, + "y": 206, + "width": 120, + "height": 76, + "strokeColor": "#d97706", + "fillColor": "rgba(251,191,36,0.22)", + "rx": 10 + }, + { + "type": "text", + "x": 744, + "y": 234, + "text": "Tasks", + "fontSize": 12, + "fontWeight": 600, + "textAlign": "center", + "color": "#92400e" + }, + { + "type": "text", + "x": 764, + "y": 262, + "text": "89%", + "fontSize": 20, + "fontWeight": 700, + "textAlign": "center", + "color": "#78350f" + }, + { + "type": "line", + "start": { "x": 80, "y": 454 }, + "end": { "x": 840, "y": 454 }, + "strokeColor": "#cbd5e1", + "strokeWidth": 3 + }, + { + "type": "rectangle", + "x": 92, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#16a34a", + "fillColor": "#16a34a", + "rx": 24 + }, + { + "type": "text", + "x": 167, + "y": 515, + "text": "Add New", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#ffffff" + }, + { + "type": "rectangle", + "x": 276, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#2563eb", + "fillColor": "rgba(37,99,235,0.16)", + "rx": 24 + }, + { + "type": "text", + "x": 351, + "y": 515, + "text": "Edit", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#1d4ed8" + }, + { + "type": "rectangle", + "x": 460, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#dc2626", + "fillColor": "rgba(220,38,38,0.14)", + "rx": 24 + }, + { + "type": "text", + "x": 535, + "y": 515, + "text": "Delete", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#b91c1c" + }, + { + "type": "rectangle", + "x": 644, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#6d28d9", + "fillColor": "rgba(109,40,217,0.14)", + "rx": 24 + }, + { + "type": "text", + "x": 719, + "y": 515, + "text": "Settings", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#5b21b6" + } + ] + } + ] +} \ No newline at end of file diff --git a/resources/debug/whiteboard/test-2.json b/resources/debug/whiteboard/test-2.json new file mode 100644 index 0000000..ac142c3 --- /dev/null +++ b/resources/debug/whiteboard/test-2.json @@ -0,0 +1,186 @@ +{ + "title": "Component Layout - Seeded Review", + "context": "Use this seeded board to review spacing, hierarchy, and action placement. Adjust it if needed, then submit.", + "initialCanvases": [ + { + "name": "Component Review", + "seedElements": [ + { + "type": "rectangle", + "x": 70, + "y": 50, + "width": 760, + "height": 90, + "strokeColor": "#1d4ed8", + "fillColor": "rgba(37,99,235,0.12)" + }, + { + "type": "text", + "x": 450, + "y": 98, + "text": "Header Region", + "fontSize": 22, + "fontWeight": 700, + "textAlign": "center", + "color": "#1e3a8a" + }, + { + "type": "rectangle", + "x": 70, + "y": 180, + "width": 360, + "height": 220, + "strokeColor": "#059669", + "fillColor": "rgba(5,150,105,0.1)" + }, + { + "type": "text", + "x": 130, + "y": 220, + "text": "Content Card A", + "fontSize": 18, + "fontWeight": 700, + "color": "#065f46" + }, + { + "type": "line", + "start": { "x": 100, "y": 246 }, + "end": { "x": 400, "y": 246 }, + "strokeColor": "#6ee7b7", + "strokeWidth": 2 + }, + { + "type": "text", + "x": 110, + "y": 282, + "text": "Primary content block", + "fontSize": 14, + "color": "#065f46" + }, + { + "type": "rectangle", + "x": 470, + "y": 180, + "width": 360, + "height": 220, + "strokeColor": "#c2410c", + "fillColor": "rgba(249,115,22,0.1)" + }, + { + "type": "text", + "x": 530, + "y": 220, + "text": "Content Card B", + "fontSize": 18, + "fontWeight": 700, + "color": "#9a3412" + }, + { + "type": "circle", + "x": 650, + "y": 300, + "radius": 34, + "strokeColor": "#f97316", + "fillColor": "rgba(249,115,22,0.16)" + }, + { + "type": "text", + "x": 650, + "y": 306, + "text": "Icon", + "fontSize": 12, + "fontWeight": 700, + "textAlign": "center", + "color": "#9a3412" + }, + { + "type": "line", + "start": { "x": 70, "y": 450 }, + "end": { "x": 830, "y": 450 }, + "strokeColor": "#cbd5e1", + "strokeWidth": 3 + }, + { + "type": "rectangle", + "x": 88, + "y": 490, + "width": 170, + "height": 56, + "strokeColor": "#dc2626", + "fillColor": "rgba(220,38,38,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 173, + "y": 525, + "text": "Button A", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#991b1b" + }, + { + "type": "rectangle", + "x": 282, + "y": 490, + "width": 170, + "height": 56, + "strokeColor": "#1d4ed8", + "fillColor": "rgba(37,99,235,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 367, + "y": 525, + "text": "Button B", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#1e3a8a" + }, + { + "type": "rectangle", + "x": 476, + "y": 490, + "width": 170, + "height": 56, + "strokeColor": "#0891b2", + "fillColor": "rgba(8,145,178,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 561, + "y": 525, + "text": "Button C", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#155e75" + }, + { + "type": "rectangle", + "x": 670, + "y": 490, + "width": 160, + "height": 56, + "strokeColor": "#475569", + "fillColor": "rgba(71,85,105,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 750, + "y": 525, + "text": "Button D", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#334155" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/a2ui/catalog.test.ts b/src/a2ui/catalog.test.ts new file mode 100644 index 0000000..07ef951 --- /dev/null +++ b/src/a2ui/catalog.test.ts @@ -0,0 +1,24 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('A2UI Catalog – Chart Components', () => { + it('should include BarChart in allowed component types', async () => { + const { isAllowedComponentType } = await import('./catalog'); + assert.ok(isAllowedComponentType('BarChart'), 'BarChart should be allowed'); + }); + + it('should include LineChart in allowed component types', async () => { + const { isAllowedComponentType } = await import('./catalog'); + assert.ok(isAllowedComponentType('LineChart'), 'LineChart should be allowed'); + }); + + it('should include PieChart in allowed component types', async () => { + const { isAllowedComponentType } = await import('./catalog'); + assert.ok(isAllowedComponentType('PieChart'), 'PieChart should be allowed'); + }); + + it('should still allow MermaidDiagram', async () => { + const { isAllowedComponentType } = await import('./catalog'); + assert.ok(isAllowedComponentType('MermaidDiagram'), 'MermaidDiagram should still be allowed'); + }); +}); diff --git a/src/a2ui/catalog.ts b/src/a2ui/catalog.ts new file mode 100644 index 0000000..2e20a5c --- /dev/null +++ b/src/a2ui/catalog.ts @@ -0,0 +1,31 @@ +import type { A2UIComponentType } from './types'; + +export const ALLOWED_COMPONENT_TYPES: ReadonlySet = new Set([ + 'Row', + 'Column', + 'Card', + 'Divider', + 'Text', + 'Heading', + 'Image', + 'Markdown', + 'CodeBlock', + 'Button', + 'TextField', + 'Checkbox', + 'Select', + 'MermaidDiagram', + 'ProgressBar', + 'Badge', + 'Table', + 'Tabs', + 'Toggle', + 'HTML', + 'BarChart', + 'LineChart', + 'PieChart', +]); + +export function isAllowedComponentType(type: string): type is A2UIComponentType { + return ALLOWED_COMPONENT_TYPES.has(type); +} diff --git a/src/a2ui/charts-integration.test.ts b/src/a2ui/charts-integration.test.ts new file mode 100644 index 0000000..bc702f8 --- /dev/null +++ b/src/a2ui/charts-integration.test.ts @@ -0,0 +1,143 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('A2UI Charts – Integration Demo', () => { + it('renders a complete dashboard with all chart types', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'row1', + component: { type: 'Row', props: {} }, + }, + { + id: 'barchart1', + parentId: 'row1', + component: { + type: 'BarChart', + props: { + title: 'Sales by Region', + data: [ + { label: 'North', value: 120 }, + { label: 'South', value: 90 }, + { label: 'East', value: 150 }, + { label: 'West', value: 80 }, + ], + color: '#4CAF50', + showValues: true, + }, + }, + }, + { + id: 'linechart1', + parentId: 'row1', + component: { + type: 'LineChart', + props: { + title: 'Revenue Trend', + data: [ + { label: 'Jan', value: 100 }, + { label: 'Feb', value: 120 }, + { label: 'Mar', value: 140 }, + { label: 'Apr', value: 130 }, + { label: 'May', value: 160 }, + ], + color: '#2196F3', + showPoints: true, + }, + }, + }, + { + id: 'piechart1', + component: { + type: 'PieChart', + props: { + title: 'Market Share', + data: [ + { label: 'Product A', value: 45, color: '#4CAF50' }, + { label: 'Product B', value: 30, color: '#2196F3' }, + { label: 'Product C', value: 25, color: '#FF9800' }, + ], + showLegend: true, + }, + }, + }, + ], + }); + + // Verify all charts are rendered + assert.ok(html.includes('a2ui-barchart'), 'Should render BarChart'); + assert.ok(html.includes('a2ui-linechart'), 'Should render LineChart'); + assert.ok(html.includes('a2ui-piechart'), 'Should render PieChart'); + + // Verify data is present + assert.ok(html.includes('Sales by Region'), 'Should show BarChart title'); + assert.ok(html.includes('Revenue Trend'), 'Should show LineChart title'); + assert.ok(html.includes('Market Share'), 'Should show PieChart title'); + + // Verify values are displayed + assert.ok(html.includes('120'), 'Should show bar chart values'); + assert.ok(html.includes('May'), 'Should show line chart label'); + assert.ok(html.includes('45'), 'Should show pie chart percentage'); + }); + + it('renders Option A: Mermaid diagrams for comparison', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'mermaid1', + component: { + type: 'MermaidDiagram', + props: { + text: 'pie title Project Status\n"Completed": 60\n"In Progress": 30\n"Pending": 10', + }, + }, + }, + { + id: 'mermaid2', + component: { + type: 'MermaidDiagram', + props: { + text: 'graph LR\nA[Start] --> B[Process]\nB --> C[End]', + }, + }, + }, + ], + }); + + // Verify Mermaid diagrams are rendered + assert.ok(html.includes('a2ui-mermaid'), 'Should render MermaidDiagram'); + assert.ok(html.includes('pie title Project Status'), 'Should show pie chart text'); + assert.ok(html.includes('graph LR'), 'Should show flowchart text'); + }); + + it('shows Option B: Native SVG charts are more customizable', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'pie1', + component: { + type: 'PieChart', + props: { + title: 'Custom Colors', + doughnut: true, + data: [ + { label: 'Red', value: 33, color: '#FF0000' }, + { label: 'Green', value: 33, color: '#00FF00' }, + { label: 'Blue', value: 34, color: '#0000FF' }, + ], + }, + }, + }, + ], + }); + + // Verify doughnut chart with custom colors + assert.ok(html.includes('a2ui-piechart'), 'Should render PieChart'); + assert.ok(html.includes('#FF0000'), 'Should have custom red color'); + assert.ok(html.includes('#00FF00'), 'Should have custom green color'); + assert.ok(html.includes('#0000FF'), 'Should have custom blue color'); + }); +}); diff --git a/src/a2ui/charts.test.ts b/src/a2ui/charts.test.ts new file mode 100644 index 0000000..4dc7058 --- /dev/null +++ b/src/a2ui/charts.test.ts @@ -0,0 +1,615 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Helper: build a minimal A2UISurface for a single component +function surface( + type: string, + extraProps: Record = {}, + entryExtras: Record = {}, +) { + return { + components: [ + { + id: 'c1', + component: { type, props: extraProps }, + ...entryExtras, + }, + ], + }; +} + +describe('A2UI Renderer – BarChart Component', () => { + it('renders basic bar chart', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [ + { label: 'A', value: 10 }, + { label: 'B', value: 20 }, + ], + }), + ); + assert.ok(html.includes('a2ui-barchart'), `Expected a2ui-barchart class. Got: ${html}`); + assert.ok(html.includes('a2ui-chart-container'), `Expected chart container. Got: ${html}`); + }); + + it('renders horizontal bar chart when horizontal=true', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [{ label: 'A', value: 10 }], + horizontal: true, + }), + ); + assert.ok(html.includes('a2ui-barchart'), `Expected a2ui-barchart class. Got: ${html}`); + }); + + it('shows values when showValues=true', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [{ label: 'A', value: 42 }], + showValues: true, + }), + ); + assert.ok(html.includes('42'), `Expected value 42 to be shown. Got: ${html}`); + }); + + it('applies custom color', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [{ label: 'A', value: 10 }], + color: '#FF5733', + }), + ); + assert.ok(html.includes('#FF5733'), `Expected custom color. Got: ${html}`); + }); + + it('renders title when provided', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [{ label: 'A', value: 10 }], + title: 'Sales Data', + }), + ); + assert.ok(html.includes('Sales Data'), `Expected title. Got: ${html}`); + }); + + it('handles empty data gracefully', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [], + }), + ); + assert.ok(html.includes('a2ui-barchart') || html.includes('No data'), `Expected chart or error message. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – LineChart Component', () => { + it('renders basic line chart', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }), + ); + assert.ok(html.includes('a2ui-linechart'), `Expected a2ui-linechart class. Got: ${html}`); + assert.ok(html.includes('a2ui-chart-container'), `Expected chart container. Got: ${html}`); + }); + + it('shows data points when showPoints=true', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [{ label: 'A', value: 15 }], + showPoints: true, + }), + ); + assert.ok(html.includes('a2ui-line-point'), `Expected data points. Got: ${html}`); + }); + + it('renders smooth curve when smooth=true', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [{ label: 'A', value: 10 }], + smooth: true, + }), + ); + assert.ok(html.includes('a2ui-linechart'), `Expected line chart. Got: ${html}`); + }); + + it('applies custom color', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [{ label: 'A', value: 10 }], + color: '#2196F3', + }), + ); + assert.ok(html.includes('#2196F3'), `Expected custom color. Got: ${html}`); + }); + + it('handles single data point', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [{ label: 'Only', value: 100 }], + }), + ); + assert.ok(html.includes('a2ui-linechart'), `Expected line chart. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – PieChart Component', () => { + it('renders basic pie chart', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'A', value: 30 }, + { label: 'B', value: 70 }, + ], + }), + ); + assert.ok(html.includes('a2ui-piechart'), `Expected a2ui-piechart class. Got: ${html}`); + assert.ok(html.includes('a2ui-chart-container'), `Expected chart container. Got: ${html}`); + }); + + it('renders doughnut chart when doughnut=true', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [{ label: 'A', value: 100 }], + doughnut: true, + }), + ); + assert.ok(html.includes('a2ui-piechart'), `Expected pie chart. Got: ${html}`); + }); + + it('shows legend when showLegend=true', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'Red', value: 50 }, + { label: 'Blue', value: 50 }, + ], + showLegend: true, + }), + ); + assert.ok(html.includes('a2ui-legend'), `Expected legend. Got: ${html}`); + }); + + it('uses custom colors when provided', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'A', value: 50, color: '#FF0000' }, + { label: 'B', value: 50, color: '#00FF00' }, + ], + }), + ); + assert.ok(html.includes('#FF0000'), `Expected custom color #FF0000. Got: ${html}`); + assert.ok(html.includes('#00FF00'), `Expected custom color #00FF00. Got: ${html}`); + }); + + it('calculates percentages correctly', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'A', value: 25 }, + { label: 'B', value: 75 }, + ], + }), + ); + // Should show 25% and 75% in tooltips or legend + assert.ok(html.includes('25'), `Expected 25%. Got: ${html}`); + assert.ok(html.includes('75'), `Expected 75%. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – MermaidDiagram Examples (Option A)', () => { + it('renders pie chart Mermaid diagram', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('MermaidDiagram', { + text: 'pie title Data\n"A": 70\n"B": 30', + }), + ); + assert.ok(html.includes('a2ui-mermaid'), `Expected mermaid class. Got: ${html}`); + assert.ok(html.includes('pie title Data'), `Expected diagram text. Got: ${html}`); + }); + + it('renders flowchart Mermaid diagram', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('MermaidDiagram', { + text: 'graph TD\nA[Start] --> B[End]', + }), + ); + assert.ok(html.includes('a2ui-mermaid'), `Expected mermaid class. Got: ${html}`); + assert.ok(html.includes('graph TD'), `Expected diagram text. Got: ${html}`); + }); + + it('renders gantt chart Mermaid diagram', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('MermaidDiagram', { + text: 'gantt\n title Project\n dateFormat YYYY-MM-DD\n section Phase 1\n Task 1 :2024-01-01, 30d', + }), + ); + assert.ok(html.includes('a2ui-mermaid'), `Expected mermaid class. Got: ${html}`); + assert.ok(html.includes('gantt'), `Expected diagram text. Got: ${html}`); + }); +}); + +// ─── Regression: props.data as a JSON string ───────────────────────────────── +// When the LLM serialises the data array to a string before passing it through +// the tool schema, `props.data` arrives as a stringified JSON array instead of +// a native array. The renderer must normalise it so charts render correctly +// instead of falling back to the `.a2ui-chart-error` sentinel. +// ----------------------------------------------------------------- +describe('A2UI Renderer – BarChart accepts stringified JSON data (regression)', () => { + it('renders bar chart when data is a JSON string', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: JSON.stringify([ + { label: 'Alpha', value: 42 }, + { label: 'Beta', value: 17 }, + ]), + }), + ); + assert.ok( + !html.includes('a2ui-chart-error'), + `Expected no chart-error fallback when data is a JSON string. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-barchart'), + `Expected a2ui-barchart class when data is a JSON string. Got: ${html}`, + ); + }); + + it('renders BarChart bar labels from stringified JSON data', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: JSON.stringify([{ label: 'Gamma', value: 99 }]), + showValues: true, + }), + ); + assert.ok( + html.includes('Gamma'), + `Expected label "Gamma" to appear when data is a JSON string. Got: ${html}`, + ); + }); +}); + +describe('A2UI Renderer – LineChart accepts stringified JSON data (regression)', () => { + it('renders line chart when data is a JSON string', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: JSON.stringify([ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ]), + }), + ); + assert.ok( + !html.includes('a2ui-chart-error'), + `Expected no chart-error fallback when data is a JSON string. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-linechart'), + `Expected a2ui-linechart class when data is a JSON string. Got: ${html}`, + ); + }); +}); + +describe('A2UI Renderer – PieChart accepts stringified JSON data (regression)', () => { + it('renders pie chart when data is a JSON string', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: JSON.stringify([ + { label: 'X', value: 60 }, + { label: 'Y', value: 40 }, + ]), + }), + ); + assert.ok( + !html.includes('a2ui-chart-error'), + `Expected no chart-error fallback when data is a JSON string. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-piechart'), + `Expected a2ui-piechart class when data is a JSON string. Got: ${html}`, + ); + }); +}); + +// ─── Regression: all-zero values must not emit NaN geometry ────────────────── +// When every data item has value 0, division-by-zero in the vertical BarChart +// (barHeight = value / maxValue) and in PieChart (angle = value / total) would +// previously produce NaN coordinates in the SVG output. Both must render +// cleanly (no NaN, no chart-error) when all values are zero. +// ───────────────────────────────────────────────────────────────────────────── + +describe('A2UI Renderer – BarChart with all-zero values (regression)', () => { + it('vertical BarChart does not emit NaN when all values are zero', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [ + { label: 'A', value: 0 }, + { label: 'B', value: 0 }, + { label: 'C', value: 0 }, + ], + }), + ); + assert.ok( + !html.includes('NaN'), + `Expected no NaN in SVG output for all-zero vertical BarChart. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-barchart'), + `Expected a2ui-barchart class for all-zero data. Got: ${html}`, + ); + }); + + it('horizontal BarChart does not emit NaN when all values are zero', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [ + { label: 'A', value: 0 }, + { label: 'B', value: 0 }, + ], + horizontal: true, + }), + ); + assert.ok( + !html.includes('NaN'), + `Expected no NaN in SVG output for all-zero horizontal BarChart. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-barchart'), + `Expected a2ui-barchart class for all-zero horizontal data. Got: ${html}`, + ); + }); +}); + +describe('A2UI Renderer – PieChart with all-zero values (regression)', () => { + it('PieChart does not emit NaN when all values are zero', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'X', value: 0 }, + { label: 'Y', value: 0 }, + ], + }), + ); + assert.ok( + !html.includes('NaN'), + `Expected no NaN in SVG output for all-zero PieChart. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-piechart'), + `Expected a2ui-piechart class for all-zero data. Got: ${html}`, + ); + }); + + it('doughnut PieChart does not emit NaN when all values are zero', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'X', value: 0 }, + { label: 'Y', value: 0 }, + ], + doughnut: true, + }), + ); + assert.ok( + !html.includes('NaN'), + `Expected no NaN in SVG output for all-zero doughnut PieChart. Got: ${html}`, + ); + assert.ok( + html.includes('a2ui-piechart'), + `Expected a2ui-piechart class for all-zero doughnut data. Got: ${html}`, + ); + }); +}); + +// ─── Regression: string-numeric `value` fields must be coerced to numbers ──── +// When the LLM or tool schema serialises numeric values as strings (e.g. +// `value: "42"` instead of `value: 42`), the renderer must coerce them to +// actual numbers so bars/lines/slices are plotted with correct non-zero +// geometry rather than collapsing everything to 0. +// ───────────────────────────────────────────────────────────────────────────── + +describe('A2UI Renderer – BarChart coerces string numeric values (regression)', () => { + it('vertical BarChart renders non-zero bar heights when values are numeric strings', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [ + { label: 'A', value: '10' }, + { label: 'B', value: '40' }, + ], + }), + ); + assert.ok( + html.includes('a2ui-barchart'), + `Expected a2ui-barchart class. Got: ${html}`, + ); + // With string values collapsed to 0, maxValue=0 → every bar gets height="0". + // After coercion the tallest bar should have height="70" (100% of range). + assert.ok( + !html.includes('height="0"'), + `Expected non-zero bar heights when values are numeric strings. Got: ${html}`, + ); + }); + + it('horizontal BarChart renders non-zero bar widths when values are numeric strings', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [ + { label: 'X', value: '20' }, + { label: 'Y', value: '80' }, + ], + horizontal: true, + }), + ); + assert.ok( + html.includes('a2ui-barchart'), + `Expected a2ui-barchart class. Got: ${html}`, + ); + // With string values collapsed to 0, every bar gets width="0". + assert.ok( + !html.includes('width="0"'), + `Expected non-zero bar widths when values are numeric strings. Got: ${html}`, + ); + }); + + it('showValues displays the original numeric string value coerced correctly', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('BarChart', { + data: [{ label: 'A', value: '99' }], + showValues: true, + }), + ); + assert.ok( + html.includes('99'), + `Expected value "99" to appear in rendered output. Got: ${html}`, + ); + }); +}); + +describe('A2UI Renderer – LineChart coerces string numeric values (regression)', () => { + it('renders non-flat polyline when values are numeric strings', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [ + { label: 'Jan', value: '10' }, + { label: 'Feb', value: '50' }, + { label: 'Mar', value: '30' }, + ], + }), + ); + assert.ok( + html.includes('a2ui-linechart'), + `Expected a2ui-linechart class. Got: ${html}`, + ); + // When all values collapse to 0 the range becomes 1 (0-0 → fallback 1) + // and every point lands on y=85. The polyline therefore has identical y + // values: "5,85 50,85 95,85". After coercion the y values must differ. + // We detect the collapsed case by checking that NOT all y-coords are 85. + const allAtBottom = /\d+,85 \d+,85 \d+,85/.test(html); + assert.ok( + !allAtBottom, + `Expected varied y-coordinates when values are numeric strings (not all collapsed to y=85). Got: ${html}`, + ); + }); + + it('single-point LineChart coerces string value without error', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('LineChart', { + data: [{ label: 'Only', value: '42' }], + }), + ); + assert.ok( + html.includes('a2ui-linechart'), + `Expected a2ui-linechart class. Got: ${html}`, + ); + assert.ok( + !html.includes('NaN'), + `Expected no NaN in output when single value is a numeric string. Got: ${html}`, + ); + }); +}); + +describe('A2UI Renderer – PieChart coerces string numeric values (regression)', () => { + it('renders large-arc flag when one slice exceeds 180° with string values', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'A', value: '30' }, + { label: 'B', value: '70' }, + ], + }), + ); + assert.ok( + html.includes('a2ui-piechart'), + `Expected a2ui-piechart class. Got: ${html}`, + ); + // The 70% slice spans 252° (> 180°) so its arc command must have largeArcFlag=1. + // The SVG arc syntax is: A rx ry x-rotation large-arc-flag sweep-flag x y + // → "A 50 50 0 1 1" appears only when a non-zero >180° arc is drawn. + // Without coercion total=0 → angle=0 for every slice → largeArcFlag is always 0 + // → "A 50 50 0 1 1" never appears. + assert.ok( + html.includes('A 50 50 0 1 1'), + `Expected "A 50 50 0 1 1" (large-arc) in PieChart SVG for the 70% slice. Got: ${html}`, + ); + }); + + it('doughnut PieChart has large-arc inner segment when slice > 180° with string values', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'X', value: '60' }, + { label: 'Y', value: '40' }, + ], + doughnut: true, + }), + ); + assert.ok( + !html.includes('NaN'), + `Expected no NaN in doughnut PieChart when values are numeric strings. Got: ${html}`, + ); + // The 60% slice spans 216° > 180° → inner arc also uses largeArcFlag=1. + // Inner arc command ends with "0" sweep-flag: "A 0 1 0" + // r=50, innerR=30 → "A 30 30 0 1 0" + assert.ok( + html.includes('A 30 30 0 1 0'), + `Expected "A 30 30 0 1 0" (large-arc inner) in doughnut PieChart for the 60% slice. Got: ${html}`, + ); + }); + + it('PieChart legend shows correct percentages from string values', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface('PieChart', { + data: [ + { label: 'Quarter', value: '25' }, + { label: 'Rest', value: '75' }, + ], + showLegend: true, + }), + ); + // 25/(25+75)*100 = 25%; without coercion total=0 → all legend items show "0%". + // Check for the literal text "25%" which can only come from percent.toFixed(0). + assert.ok( + html.includes('25%'), + `Expected percentage text "25%" in legend when values are numeric strings. Got: ${html}`, + ); + }); +}); diff --git a/src/a2ui/engine.ts b/src/a2ui/engine.ts new file mode 100644 index 0000000..3d2421c --- /dev/null +++ b/src/a2ui/engine.ts @@ -0,0 +1,262 @@ +import type { A2UIComponent } from './types'; + +export type A2UIIssueSeverity = 'error' | 'warning' | 'info'; +export type A2UIPrinciple = 'clarity' | 'accessibility' | 'error_prevention' | 'action_orientation' | 'progressive_disclosure'; +export type A2UILevel = 'basic' | 'strict'; + +export interface A2UIIssue { + severity: A2UIIssueSeverity; + principle: A2UIPrinciple; + message: string; + suggestion: string; + componentId?: string; +} + +export interface A2UIReport { + enabled: true; + level: A2UILevel; + score: number; + issues: A2UIIssue[]; + appliedEnhancements: string[]; +} + +export interface A2UIProcessingResult { + components: A2UIComponent[]; + report: A2UIReport; +} + +type MutableComponent = { + id: string; + parentId?: string; + component: Record; +}; + +const GENERIC_BUTTON_LABELS = new Set(['ok', 'yes', 'no', 'go', 'run', 'click']); +const DESTRUCTIVE_ACTION_PATTERN = /delete|remove|destroy|purge|wipe/i; + +function cloneComponents(components: A2UIComponent[]): MutableComponent[] { + return components.map((entry) => ({ + id: entry.id, + ...(entry.parentId ? { parentId: entry.parentId } : {}), + component: { ...entry.component }, + })); +} + +function extractProps(component: Record): Record { + const props = component.props; + if (props && typeof props === 'object' && !Array.isArray(props)) { + return { ...(props as Record) }; + } + + const extractedProps: Record = {}; + for (const [key, value] of Object.entries(component)) { + if (key === 'type') { + continue; + } + extractedProps[key] = value; + } + return extractedProps; +} + +function assignProps(component: MutableComponent, props: Record): void { + component.component = { + type: component.component.type, + props, + }; +} + +function pushIssue(issues: A2UIIssue[], severity: A2UIIssueSeverity, principle: A2UIPrinciple, message: string, suggestion: string, componentId?: string): void { + issues.push({ severity, principle, message, suggestion, ...(componentId ? { componentId } : {}) }); +} + +function computeScore(issues: A2UIIssue[]): number { + const penalty = issues.reduce((total, issue) => total + (issue.severity === 'error' ? 0.2 : issue.severity === 'warning' ? 0.1 : 0.04), 0); + return Math.max(0, Number((1 - penalty).toFixed(2))); +} + +export function processA2UIComponents(components: A2UIComponent[], level: A2UILevel): A2UIProcessingResult { + const mutableComponents = cloneComponents(components); + const issues: A2UIIssue[] = []; + const appliedEnhancements: string[] = []; + + let cancelButtonInjected = false; + const buttonEntries = mutableComponents.filter((entry) => entry.component.type === 'Button'); + const hasInteractiveFields = mutableComponents.some((entry) => entry.component.type === 'TextField' || entry.component.type === 'Select' || entry.component.type === 'Checkbox'); + const rootCount = mutableComponents.filter((entry) => !entry.parentId).length; + const hasStructuralContainers = mutableComponents.some((entry) => entry.component.type === 'Card' || entry.component.type === 'Divider'); + + if (hasInteractiveFields && buttonEntries.length === 0) { + pushIssue( + issues, + 'warning', + 'action_orientation', + 'Interactive controls are present without a submit or confirm action.', + 'Add a clear action button so the user knows how to complete the interaction.', + ); + } + + if (buttonEntries.length > 2 && buttonEntries.every((entry) => { + const props = extractProps(entry.component); + return typeof props.variant !== 'string'; + })) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'action_orientation', + 'The surface has several actions but no explicit emphasis hierarchy.', + 'Use variant or layout grouping to distinguish primary, secondary, and destructive actions.', + ); + } + + if (rootCount > 6 && !hasStructuralContainers) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'progressive_disclosure', + 'The surface exposes many root-level elements without structural grouping.', + 'Group related content into cards or sections so the user can scan the surface progressively.', + ); + } + + for (const entry of mutableComponents) { + const type = String(entry.component.type ?? ''); + const props = extractProps(entry.component); + let mutated = false; + + if (type === 'Button') { + const label = String(props.label ?? '').trim(); + const action = String(props.action ?? entry.id); + const variant = String(props.variant ?? ''); + const isDestructive = variant === 'danger' || DESTRUCTIVE_ACTION_PATTERN.test(label) || DESTRUCTIVE_ACTION_PATTERN.test(action); + + if (label.length > 0 && (label.length < 4 || GENERIC_BUTTON_LABELS.has(label.toLowerCase()))) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'clarity', + `Button \"${label}\" is underspecified.`, + 'Use a more descriptive button label so the action is obvious without extra context.', + entry.id, + ); + } + + if (typeof props.ariaLabel !== 'string' || props.ariaLabel.trim().length === 0) { + props.ariaLabel = label || action; + mutated = true; + appliedEnhancements.push(`Added ariaLabel to button ${entry.id}.`); + pushIssue( + issues, + 'info', + 'accessibility', + 'Button is missing ariaLabel metadata.', + 'Provide ariaLabel for actionable controls.', + entry.id, + ); + } + + if (isDestructive && !cancelButtonInjected) { + const siblingCancel = mutableComponents.some((candidate) => { + if (candidate.component.type !== 'Button') { + return false; + } + if (candidate.parentId !== entry.parentId) { + return false; + } + const candidateProps = extractProps(candidate.component); + const candidateLabel = String(candidateProps.label ?? '').toLowerCase(); + return candidateLabel.includes('cancel') || candidateLabel.includes('back'); + }); + + if (!siblingCancel) { + mutableComponents.push({ + id: `auto_cancel_${entry.id}`, + ...(entry.parentId ? { parentId: entry.parentId } : {}), + component: { + type: 'Button', + props: { + label: 'Cancel', + action: `cancel_${entry.id}`, + variant: 'secondary', + ariaLabel: 'Cancel and return without applying the destructive action', + }, + }, + }); + cancelButtonInjected = true; + appliedEnhancements.push(`Injected cancel safeguard next to destructive button ${entry.id}.`); + pushIssue( + issues, + 'warning', + 'error_prevention', + 'Destructive action did not include a cancel alternative.', + 'Pair destructive buttons with an adjacent cancel or safe alternative.', + entry.id, + ); + } + } + } + + if (type === 'TextField' || type === 'Select' || type === 'Checkbox') { + const label = String(props.label ?? '').trim(); + + if (label.length === 0) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'clarity', + `${type} is missing a visible label.`, + 'Provide a concise label so the control is understandable in isolation.', + entry.id, + ); + } + + if (typeof props.ariaLabel !== 'string' || props.ariaLabel.trim().length === 0) { + props.ariaLabel = label || entry.id; + mutated = true; + appliedEnhancements.push(`Added ariaLabel to ${type} ${entry.id}.`); + } + + if (level === 'strict' && props.required === true && typeof props.helperText !== 'string') { + props.helperText = 'Required field'; + mutated = true; + appliedEnhancements.push(`Added helper text to required ${type} ${entry.id}.`); + pushIssue( + issues, + 'info', + 'error_prevention', + `${type} is required but does not explain that state.`, + 'Add helper text for required inputs so the user understands what is expected.', + entry.id, + ); + } + } + + if (type === 'Image') { + const alt = String(props.alt ?? '').trim(); + if (!alt) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'accessibility', + 'Image is missing alt text.', + 'Provide alt text so non-visual users understand the image purpose.', + entry.id, + ); + } + } + + if (mutated) { + assignProps(entry, props); + } + } + + return { + components: mutableComponents, + report: { + enabled: true, + level, + score: computeScore(issues), + issues, + appliedEnhancements, + }, + }; +} \ No newline at end of file diff --git a/src/a2ui/panel.test.ts b/src/a2ui/panel.test.ts new file mode 100644 index 0000000..347944f --- /dev/null +++ b/src/a2ui/panel.test.ts @@ -0,0 +1,971 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +describe('A2UIPanel', () => { + const modulePath = require.resolve('./panel.ts'); + let originalLoad: typeof Module._load; + + beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + it('reuses the same pending waiter when a second waitForAction call reuses the same surfaceId', async () => { + const messageHandlers: Array<(message: unknown) => void> = []; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { + One: 1, + }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(value: { toString(): string }) { + return value; + }, + onDidReceiveMessage(handler: (message: unknown) => void) { + messageHandlers.push(handler); + return { + dispose() { }, + }; + }, + }, + onDidDispose(handler: () => void) { + disposeHandler = handler; + return { + dispose() { }, + }; + }, + reveal() { }, + dispose() { + disposeHandler?.(); + }, + }; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((part) => typeof part === 'string' ? part : part.path || part.fsPath || '').join('/'), + }), + }, + }; + } + + if (request === 'fs') { + return { + readFileSync() { + return '
{{surfaceHtml}}
'; + }, + }; + } + + if (request === 'path') { + return { + join: (...parts: string[]) => parts.join('/'), + }; + } + + if (request === 'crypto') { + return { + randomBytes() { + return { + toString() { + return 'nonce'; + }, + }; + }, + }; + } + + if (request === './renderer') { + return { + renderSurface() { + return ''; + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surface = { + surfaceId: 'surface_shared', + title: 'Shared surface', + components: [ + { + id: 'button_1', + component: { + type: 'Button', + props: { + label: 'Submit', + action: 'submit', + }, + }, + }, + ], + }; + + const firstPromise = A2UIPanel.showSurface({ fsPath: '/extension' } as any, surface, true); + const secondPromise = A2UIPanel.showSurface({ fsPath: '/extension' } as any, surface, true); + + const pendingResult = await Promise.race([ + Promise.all([firstPromise, secondPromise]).then(() => 'resolved'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 50)), + ]); + + assert.equal(pendingResult, 'timeout', 'expected both waiters to remain pending until user action'); + + const latestHandler = messageHandlers.at(-1); + assert.ok(latestHandler, 'expected a webview message handler'); + latestHandler({ + type: 'userAction', + name: 'submit', + data: { + value: 'ok', + }, + }); + + const expectedResult = { + dismissed: false, + userAction: { + name: 'submit', + data: { + value: 'ok', + }, + }, + }; + + await assert.doesNotReject(() => Promise.all([firstPromise, secondPromise])); + assert.deepStrictEqual(await firstPromise, expectedResult); + assert.deepStrictEqual(await secondPromise, expectedResult); + }); + + it('returns renderer errors when the surface cannot be rendered', async () => { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { + One: 1, + }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(value: { toString(): string }) { + return value; + }, + onDidReceiveMessage() { + return { + dispose() { }, + }; + }, + }, + onDidDispose(handler: () => void) { + disposeHandler = handler; + return { + dispose() { }, + }; + }, + reveal() { }, + dispose() { + disposeHandler?.(); + }, + }; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((part) => typeof part === 'string' ? part : part.path || part.fsPath || '').join('/'), + }), + }, + }; + } + + if (request === 'fs') { + return { + readFileSync() { + return '{{surfaceHtml}}'; + }, + }; + } + + if (request === 'path') { + return { + join: (...parts: string[]) => parts.join('/'), + }; + } + + if (request === 'crypto') { + return { + randomBytes() { + return { + toString() { + return 'nonce'; + }, + }; + }, + }; + } + + if (request === './renderer') { + return { + renderSurface() { + throw new Error('Unsupported component type: Table (id: table_1)'); + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const result = await A2UIPanel.showSurface( + { fsPath: '/extension' } as any, + { + surfaceId: 'surface_error', + components: [ + { + id: 'table_1', + component: { type: 'Table' }, + }, + ], + }, + false, + ); + + assert.deepStrictEqual(result, { + dismissed: false, + renderErrors: [ + { + source: 'renderer', + message: 'Unsupported component type: Table (id: table_1)', + }, + ], + }); + }); + + it('preserves literal dollar replacement patterns in rendered HTML', async () => { + let createdPanel: + | { + webview: { + html: string; + }; + } + | undefined; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { + One: 1, + }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + createdPanel = { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(value: { toString(): string }) { + return value; + }, + onDidReceiveMessage() { + return { + dispose() { }, + }; + }, + }, + onDidDispose(handler: () => void) { + disposeHandler = handler; + return { + dispose() { }, + }; + }, + reveal() { }, + dispose() { + disposeHandler?.(); + }, + } as any; + + return createdPanel; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((part) => typeof part === 'string' ? part : part.path || part.fsPath || '').join('/'), + }), + }, + }; + } + + if (request === 'fs') { + return { + readFileSync() { + return '{{surfaceHtml}}'; + }, + }; + } + + if (request === 'path') { + return { + join: (...parts: string[]) => parts.join('/'), + }; + } + + if (request === 'crypto') { + return { + randomBytes() { + return { + toString() { + return 'nonce'; + }, + }; + }, + }; + } + + if (request === './renderer') { + return { + renderSurface() { + return '

literal $& marker

'; + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + await A2UIPanel.showSurface( + { fsPath: '/extension' } as any, + { + surfaceId: 'surface_literal', + components: [ + { + id: 'text_1', + component: { type: 'Text', props: { content: 'ignored' } }, + }, + ], + }, + false, + ); + + assert.ok(createdPanel, 'expected a panel to be created'); + assert.match(createdPanel.webview.html, /\$& marker/); + assert.doesNotMatch(createdPanel.webview.html, /\{\{surfaceHtml\}\}/); + }); + + // ---------- updateDataModel tests ---------- + + it('updateDataModel returns { found: false } when the surface does not exist', () => { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { activeTextEditor: undefined, createWebviewPanel() { return {} as any; } }, + Uri: { joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ toString: () => '' }) }, + }; + } + if (request === 'fs') { return { readFileSync() { return ''; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { return { renderSurface() { return ''; } }; } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const result = A2UIPanel.updateDataModel('nonexistent_surface', { key: 'value' }); + assert.deepStrictEqual(result, { found: false }); + }); + + it('updateDataModel updates the dataModel of an existing surface and re-renders', async () => { + let renderCallCount = 0; + let lastRenderedSurface: unknown; + let renderedHtmlSnapshots: string[] = []; + let webviewHtmlRef = { value: '' }; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + const panel = { + webview: { + get html() { return webviewHtmlRef.value; }, + set html(v: string) { webviewHtmlRef.value = v; renderedHtmlSnapshots.push(v); }, + cspSource: 'vscode-webview://test', + asWebviewUri(value: { toString(): string }) { return value; }, + onDidReceiveMessage() { return { dispose() {} }; }, + }, + onDidDispose(handler: () => void) { disposeHandler = handler; return { dispose() {} }; }, + reveal() {}, + dispose() { disposeHandler?.(); }, + }; + return panel; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : p.path || p.fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { + return { + renderSurface(surface: { dataModel?: Record }) { + renderCallCount++; + lastRenderedSurface = surface; + return `

data=${JSON.stringify(surface.dataModel ?? {})}

`; + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + await A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId: 'surface_update', + components: [{ id: 'c1', component: { type: 'Text' } }], + dataModel: { initial: true }, + }, false); + + const countBefore = renderCallCount; + const result = A2UIPanel.updateDataModel('surface_update', { updated: true }); + + assert.deepStrictEqual(result, { found: true }); + assert.equal(renderCallCount, countBefore + 1, 'expected one additional render call'); + assert.ok((lastRenderedSurface as any).dataModel?.updated === true, 'expected dataModel to be updated'); + }); + + it('updateDataModel surfaces render errors when the renderer throws', async () => { + let shouldThrow = false; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage() { return { dispose() {} }; }, + }, + onDidDispose(handler: () => void) { disposeHandler = handler; return { dispose() {} }; }, + reveal() {}, + dispose() { disposeHandler?.(); }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { + return { + renderSurface() { + if (shouldThrow) { throw new Error('render failure after update'); } + return '

ok

'; + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + await A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId: 'surface_update_err', + components: [{ id: 'c1', component: { type: 'Text' } }], + }, false); + + shouldThrow = true; + const result = A2UIPanel.updateDataModel('surface_update_err', { x: 1 }); + + assert.deepStrictEqual(result, { + found: true, + renderErrors: [{ source: 'renderer', message: 'render failure after update' }], + }); + }); + + it('updateDataModel preserves the pending waiter when the surface is waiting for an action', async () => { + const messageHandlers: Array<(message: unknown) => void> = []; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage(handler: (m: unknown) => void) { + messageHandlers.push(handler); + return { dispose() {} }; + }, + }, + onDidDispose(handler: () => void) { disposeHandler = handler; return { dispose() {} }; }, + reveal() {}, + dispose() { disposeHandler?.(); }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { return { renderSurface() { return '

ok

'; } }; } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surfaceId = 'surface_wait_update'; + const waitPromise = A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId, + components: [{ id: 'c1', component: { type: 'Button' } }], + }, true); + + // Mutate the dataModel while the surface is waiting + const updateResult = A2UIPanel.updateDataModel(surfaceId, { refreshed: true }); + assert.deepStrictEqual(updateResult, { found: true }); + + // Promise should still be pending + const raceResult = await Promise.race([ + waitPromise.then(() => 'resolved'), + new Promise((r) => setTimeout(() => r('timeout'), 50)), + ]); + assert.equal(raceResult, 'timeout', 'expected wait promise to remain pending after updateDataModel'); + + // Resolve via user action to avoid dangling promise + const latestHandler = messageHandlers.at(-1); + assert.ok(latestHandler); + latestHandler({ type: 'userAction', name: 'done', data: {} }); + await waitPromise; + }); + + // ---------- updateTitle tests ---------- + + it('updateTitle returns { found: false } when the surface does not exist', () => { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { activeTextEditor: undefined, createWebviewPanel() { return {} as any; } }, + Uri: { joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ toString: () => '' }) }, + }; + } + if (request === 'fs') { return { readFileSync() { return ''; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { return { renderSurface() { return ''; } }; } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const result = A2UIPanel.updateTitle('nonexistent_surface', 'New Title'); + assert.deepStrictEqual(result, { found: false }); + }); + + it('updateTitle updates panel title, re-renders the webview with the new title, and returns { found: true }', async () => { + let renderCallCount = 0; + let panelTitleSet = ''; + let capturedHtml = ''; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel(_viewType: string, initialTitle: string) { + panelTitleSet = initialTitle; + return { + webview: { + get html() { return capturedHtml; }, + set html(v: string) { capturedHtml = v; }, + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage() { return { dispose() {} }; }, + }, + onDidDispose() { return { dispose() {} }; }, + reveal() {}, + dispose() {}, + get title() { return panelTitleSet; }, + set title(v: string) { panelTitleSet = v; }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{title}}{{surfaceHtml}}{{diagnosticsHtml}}{{surfaceId}}{{nonce}}{{cspSource}}{{styleUri}}{{scriptUri}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { + return { + renderSurface() { + renderCallCount++; + return '

ok

'; + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surfaceId = 'surface_title_rerender'; + await A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId, + title: 'Original Title', + components: [{ id: 'c1', component: { type: 'Text' } }], + }, false); + + const renderCountBefore = renderCallCount; + const result = A2UIPanel.updateTitle(surfaceId, 'New Title'); + + assert.deepStrictEqual(result, { found: true }); + assert.strictEqual(panelTitleSet, 'New Title', 'panel.title should be updated'); + // updateTitle MUST trigger a re-render so {{title}} in the HTML template is refreshed + assert.strictEqual(renderCallCount, renderCountBefore + 1, 'updateTitle should trigger exactly one re-render'); + assert.ok(capturedHtml.includes('New Title'), 'rendered HTML should contain the new title'); + assert.ok(!capturedHtml.includes('Original Title'), 'rendered HTML should not contain the old title'); + }); + + it('updateTitle surfaces render errors when the renderer throws', async () => { + let panelTitleSet = ''; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel(_viewType: string, initialTitle: string) { + panelTitleSet = initialTitle; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage() { return { dispose() {} }; }, + }, + onDidDispose() { return { dispose() {} }; }, + reveal() {}, + dispose() {}, + get title() { return panelTitleSet; }, + set title(v: string) { panelTitleSet = v; }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { + let callCount = 0; + return { + renderSurface() { + callCount++; + if (callCount >= 2) { + throw new Error('Renderer exploded on re-render'); + } + return '

ok

'; + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surfaceId = 'surface_title_render_err'; + await A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId, + title: 'Original Title', + components: [{ id: 'c1', component: { type: 'Text' } }], + }, false); + + const result = A2UIPanel.updateTitle(surfaceId, 'Error Title'); + + assert.deepStrictEqual(result, { + found: true, + renderErrors: [{ source: 'renderer', message: 'Renderer exploded on re-render' }], + }); + }); + + // ---------- appendComponents tests ---------- + + it('appendComponents returns { found: false } when the surface does not exist', () => { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { activeTextEditor: undefined, createWebviewPanel() { return {} as any; } }, + Uri: { joinPath: () => ({ toString: () => '' }) }, + }; + } + if (request === 'fs') { return { readFileSync() { return ''; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { return { renderSurface() { return ''; } }; } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const result = A2UIPanel.appendComponents('nonexistent_surface', [{ id: 'x', component: { type: 'Text' } }]); + assert.deepStrictEqual(result, { found: false }); + }); + + it('appendComponents adds new components to existing ones and re-renders', async () => { + let lastRenderedComponents: unknown[] = []; + let renderCallCount = 0; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage() { return { dispose() {} }; }, + }, + onDidDispose(handler: () => void) { disposeHandler = handler; return { dispose() {} }; }, + reveal() {}, + dispose() { disposeHandler?.(); }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { + return { + renderSurface(surface: { components: unknown[] }) { + renderCallCount++; + lastRenderedComponents = surface.components; + return '

rendered

'; + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surfaceId = 'surface_append'; + await A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId, + components: [{ id: 'original', component: { type: 'Text' } }], + }, false); + + const countBefore = renderCallCount; + const result = A2UIPanel.appendComponents(surfaceId, [ + { id: 'appended_1', component: { type: 'Button' } }, + ]); + + assert.deepStrictEqual(result, { found: true }); + assert.equal(renderCallCount, countBefore + 1, 'expected one additional render call'); + assert.equal(lastRenderedComponents.length, 2, 'expected two components after append'); + assert.deepStrictEqual( + (lastRenderedComponents as Array<{ id: string }>).map((c) => c.id), + ['original', 'appended_1'], + ); + }); + + it('appendComponents surfaces render errors when the renderer throws', async () => { + let shouldThrow = false; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage() { return { dispose() {} }; }, + }, + onDidDispose(handler: () => void) { disposeHandler = handler; return { dispose() {} }; }, + reveal() {}, + dispose() { disposeHandler?.(); }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { + return { + renderSurface() { + if (shouldThrow) { throw new Error('append render failure'); } + return '

ok

'; + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + await A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId: 'surface_append_err', + components: [{ id: 'c1', component: { type: 'Text' } }], + }, false); + + shouldThrow = true; + const result = A2UIPanel.appendComponents('surface_append_err', [ + { id: 'c2', component: { type: 'Button' } }, + ]); + + assert.deepStrictEqual(result, { + found: true, + renderErrors: [{ source: 'renderer', message: 'append render failure' }], + }); + }); + + it('appendComponents preserves the pending waiter when the surface is waiting for an action', async () => { + const messageHandlers: Array<(message: unknown) => void> = []; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { One: 1 }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(v: { toString(): string }) { return v; }, + onDidReceiveMessage(handler: (m: unknown) => void) { + messageHandlers.push(handler); + return { dispose() {} }; + }, + }, + onDidDispose(handler: () => void) { disposeHandler = handler; return { dispose() {} }; }, + reveal() {}, + dispose() { disposeHandler?.(); }, + } as any; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((p) => typeof p === 'string' ? p : (p as any).path || (p as any).fsPath || '').join('/'), + }), + }, + }; + } + if (request === 'fs') { return { readFileSync() { return '{{surfaceHtml}}'; } }; } + if (request === 'path') { return { join: (...p: string[]) => p.join('/') }; } + if (request === 'crypto') { return { randomBytes() { return { toString() { return 'nonce'; } }; } }; } + if (request === './renderer') { return { renderSurface() { return '

ok

'; } }; } + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surfaceId = 'surface_wait_append'; + const waitPromise = A2UIPanel.showSurface({ fsPath: '/extension' } as any, { + surfaceId, + components: [{ id: 'c1', component: { type: 'Button' } }], + }, true); + + const appendResult = A2UIPanel.appendComponents(surfaceId, [{ id: 'c2', component: { type: 'Text' } }]); + assert.deepStrictEqual(appendResult, { found: true }); + + const raceResult = await Promise.race([ + waitPromise.then(() => 'resolved'), + new Promise((r) => setTimeout(() => r('timeout'), 50)), + ]); + assert.equal(raceResult, 'timeout', 'expected wait promise to remain pending after appendComponents'); + + const latestHandler = messageHandlers.at(-1); + assert.ok(latestHandler); + latestHandler({ type: 'userAction', name: 'done', data: {} }); + await waitPromise; + }); +}); diff --git a/src/a2ui/panel.ts b/src/a2ui/panel.ts new file mode 100644 index 0000000..b410661 --- /dev/null +++ b/src/a2ui/panel.ts @@ -0,0 +1,346 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import type { A2UIComponent, A2UIDataModel, A2UIRenderIssue, A2UISurface, A2UIUserAction, DroppedStyleEntry } from './types'; +import { renderSurface } from './renderer'; + +export interface A2UIPanelResult { + dismissed: boolean; + renderErrors?: A2UIRenderIssue[]; + userAction?: A2UIUserAction; + droppedStyles?: DroppedStyleEntry[]; +} + +export interface A2UIPanelUpdateResult { + found: boolean; + renderErrors?: A2UIRenderIssue[]; + droppedStyles?: DroppedStyleEntry[]; +} + +type FromWebviewMessage = + | { type: 'userAction'; name: string; data: Record }; + +function escHtml(str: string): string { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderA2UIDiagnostics(surface: A2UISurface): string { + const report = surface.a2uiReport; + if (!report) { + return ''; + } + + const issuesHtml = report.issues.length > 0 + ? `
    ${report.issues.map((issue) => `
  • ${escHtml(issue.principle)}: ${escHtml(issue.message)}
  • `).join('')}
` + : '

No validation findings.

'; + const enhancementsHtml = report.appliedEnhancements.length > 0 + ? `
    ${report.appliedEnhancements.map((item) => `
  • ${escHtml(item)}
  • `).join('')}
` + : '

No automatic enhancements applied.

'; + + return `
A2UI DiagnosticsScore ${escHtml(String(Math.round(report.score * 100)))}%

Findings

${issuesHtml}

Enhancements

${enhancementsHtml}
`; +} + +export class A2UIPanel { + public static readonly viewType = 'seamlessAgent.a2ui'; + + private static _panels: Map = new Map(); + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _surface: A2UISurface; + private readonly _surfaceKey: string; + private _disposables: vscode.Disposable[] = []; + private _lastRenderErrors: A2UIRenderIssue[] = []; + private _lastDroppedStyles: DroppedStyleEntry[] = []; + private _pendingResult?: Promise; + private _resolvePromise?: (result: A2UIPanelResult) => void; + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + surface: A2UISurface, + surfaceKey: string, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._surface = surface; + this._surfaceKey = surfaceKey; + + this._renderIntoWebview(); + this._panel.onDidDispose(() => this._dispose(), null, this._disposables); + this._panel.webview.onDidReceiveMessage( + (message: FromWebviewMessage) => void this._handleMessage(message), + null, + this._disposables, + ); + } + + /** + * Shows a surface panel. + * If waitForAction is false, returns immediately after creating the panel. + * If waitForAction is true, blocks until the user fires an action or closes the panel. + */ + public static async showSurface( + extensionUri: vscode.Uri, + surface: A2UISurface, + waitForAction: boolean, + ): Promise { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + const key = surface.surfaceId ?? crypto.randomBytes(8).toString('hex'); + + if (!waitForAction) { + const existing = A2UIPanel._panels.get(key); + if (existing) { + const renderErrors = existing._setSurface(surface); + const droppedStyles = existing._lastDroppedStyles; + existing._panel.reveal(column); + return { + dismissed: false, + ...(renderErrors.length > 0 ? { renderErrors } : {}), + ...(droppedStyles.length > 0 ? { droppedStyles } : {}), + }; + } else { + const webviewPanel = vscode.window.createWebviewPanel( + A2UIPanel.viewType, + surface.title ?? 'UI Surface', + column, + A2UIPanel._webviewOptions(extensionUri), + ); + const instance = new A2UIPanel(webviewPanel, extensionUri, surface, key); + A2UIPanel._panels.set(key, instance); + return { + dismissed: false, + ...(instance._lastRenderErrors.length > 0 ? { renderErrors: instance._lastRenderErrors } : {}), + ...(instance._lastDroppedStyles.length > 0 ? { droppedStyles: instance._lastDroppedStyles } : {}), + }; + } + } + + const existing = A2UIPanel._panels.get(key); + if (existing) { + existing._setSurface(surface); + existing._panel.reveal(column); + return existing._ensurePendingResult(); + } + + const webviewPanel = vscode.window.createWebviewPanel( + A2UIPanel.viewType, + surface.title ?? 'UI Surface', + column, + A2UIPanel._webviewOptions(extensionUri), + ); + const instance = new A2UIPanel(webviewPanel, extensionUri, surface, key); + A2UIPanel._panels.set(key, instance); + return instance._ensurePendingResult(); + } + + public static closeIfOpen(surfaceId: string): boolean { + const panel = A2UIPanel._panels.get(surfaceId); + if (panel) { + panel._panel.dispose(); + return true; + } + return false; + } + + /** + * Lists all currently active surfaces with their metadata. + */ + public static listSurfaces(): Array<{ surfaceId: string; title: string; created: string }> { + const surfaces: Array<{ surfaceId: string; title: string; created: string }> = []; + + for (const [surfaceId, panel] of A2UIPanel._panels.entries()) { + surfaces.push({ + surfaceId, + title: panel._surface.title ?? '', + created: new Date().toISOString(), // Use current time since we don't track creation time + }); + } + + return surfaces; + } + + /** + * Updates only the `dataModel` of an existing surface and re-renders it. + * Returns `{ found: false }` when no surface with the given id is open. + * The pending waiter (if any) is preserved unchanged. + */ + public static updateDataModel(surfaceId: string, dataModel: A2UIDataModel): A2UIPanelUpdateResult { + const panel = A2UIPanel._panels.get(surfaceId); + if (!panel) { + return { found: false }; + } + panel._surface = { ...panel._surface, dataModel }; + const renderErrors = panel._renderIntoWebview(); + const droppedStyles = panel._lastDroppedStyles; + return { found: true, ...(renderErrors.length > 0 ? { renderErrors } : {}), ...(droppedStyles.length > 0 ? { droppedStyles } : {}) }; + } + + /** + * Updates only the title of an existing surface panel and re-renders the webview. + * Returns `{ found: false }` when no surface with the given id is open. + */ + public static updateTitle(surfaceId: string, title: string): A2UIPanelUpdateResult { + const panel = A2UIPanel._panels.get(surfaceId); + if (!panel) { + return { found: false }; + } + panel._surface = { ...panel._surface, title }; + panel._panel.title = title; + const renderErrors = panel._renderIntoWebview(); + const droppedStyles = panel._lastDroppedStyles; + return { found: true, ...(renderErrors.length > 0 ? { renderErrors } : {}), ...(droppedStyles.length > 0 ? { droppedStyles } : {}) }; + } + + /** + * Appends `components` to the existing component list of a surface and re-renders. + * Returns `{ found: false }` when no surface with the given id is open. + * Prior components are preserved. The pending waiter (if any) is preserved unchanged. + */ + public static appendComponents(surfaceId: string, components: A2UIComponent[], finalize?: boolean): A2UIPanelUpdateResult { + const panel = A2UIPanel._panels.get(surfaceId); + if (!panel) { + return { found: false }; + } + const updatedSurface = { ...panel._surface, components: [...panel._surface.components, ...components] }; + if (finalize) { + updatedSurface.streaming = false; + } + panel._surface = updatedSurface; + const renderErrors = panel._renderIntoWebview(); + const droppedStyles = panel._lastDroppedStyles; + return { found: true, ...(renderErrors.length > 0 ? { renderErrors } : {}), ...(droppedStyles.length > 0 ? { droppedStyles } : {}) }; + } + + private static _webviewOptions(extensionUri: vscode.Uri): vscode.WebviewPanelOptions & vscode.WebviewOptions { + return { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(extensionUri, 'media'), + vscode.Uri.joinPath(extensionUri, 'dist'), + ], + }; + } + + private async _handleMessage(message: FromWebviewMessage): Promise { + if (message.type === 'userAction') { + const action: A2UIUserAction = { + name: message.name, + data: message.data, + }; + this._resolve({ + dismissed: false, + ...(this._lastRenderErrors.length > 0 ? { renderErrors: this._lastRenderErrors } : {}), + ...(this._lastDroppedStyles.length > 0 ? { droppedStyles: this._lastDroppedStyles } : {}), + userAction: action, + }); + this._panel.dispose(); + } + } + + private _ensurePendingResult(): Promise { + if (!this._pendingResult) { + this._pendingResult = new Promise((resolve) => { + this._resolvePromise = resolve; + }); + } + + return this._pendingResult; + } + + private _resolve(result: A2UIPanelResult): void { + if (this._resolvePromise) { + this._resolvePromise(result); + this._resolvePromise = undefined; + this._pendingResult = undefined; + } + } + + private _dispose(): void { + A2UIPanel._panels.delete(this._surfaceKey); + if (this._resolvePromise) { + this._resolve({ + dismissed: true, + ...(this._lastRenderErrors.length > 0 ? { renderErrors: this._lastRenderErrors } : {}), + ...(this._lastDroppedStyles.length > 0 ? { droppedStyles: this._lastDroppedStyles } : {}), + }); + } + for (const d of this._disposables) { + d.dispose(); + } + this._disposables = []; + } + + private _setSurface(surface: A2UISurface): A2UIRenderIssue[] { + this._surface = surface; + this._panel.title = surface.title ?? 'UI Surface'; + return this._renderIntoWebview(); + } + + private _renderIntoWebview(): A2UIRenderIssue[] { + const { html, renderErrors, droppedStyles } = this._getHtmlContent(); + this._lastRenderErrors = renderErrors; + this._lastDroppedStyles = droppedStyles; + this._panel.webview.html = html; + return renderErrors; + } + + private _getHtmlContent(): { html: string; renderErrors: A2UIRenderIssue[]; droppedStyles: DroppedStyleEntry[] } { + const webview = this._panel.webview; + const nonce = crypto.randomBytes(16).toString('hex'); + const renderErrors: A2UIRenderIssue[] = []; + + const cssUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'media', 'a2ui.css'), + ); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'dist', 'a2ui.js'), + ); + const cspSource = webview.cspSource; + + const droppedMap = new Map(); + let renderedHtml: string; + try { + renderedHtml = renderSurface(this._surface, droppedMap); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to render surface.'; + renderErrors.push({ + source: 'renderer', + message, + }); + renderedHtml = `

${escHtml(message)}

`; + } + + const droppedStyles: DroppedStyleEntry[] = Array.from(droppedMap.entries()).map(([componentId, properties]) => ({ componentId, properties })); + + const streamingIndicatorHtml = this._surface.streaming + ? '
Generating…
' + : ''; + + const htmlPath = path.join(this._extensionUri.fsPath, 'media', 'a2ui.html'); + let html = fs.readFileSync(htmlPath, 'utf8'); + + html = html + .replace(/\{\{nonce\}\}/g, nonce) + .replace(/\{\{cspSource\}\}/g, cspSource) + .replace(/\{\{styleUri\}\}/g, cssUri.toString()) + .replace(/\{\{scriptUri\}\}/g, scriptUri.toString()) + .replace(/\{\{title\}\}/g, escHtml(this._surface.title ?? 'UI Surface')) + .replace(/\{\{surfaceId\}\}/g, escHtml(this._surfaceKey)); + + html = html.replace(/\{\{diagnosticsHtml\}\}/g, () => renderA2UIDiagnostics(this._surface)); + html = html.replace(/\{\{surfaceHtml\}\}/g, () => renderedHtml); + html = html.replace(/\{\{streamingIndicatorHtml\}\}/g, () => streamingIndicatorHtml); + + return { + html, + renderErrors, + droppedStyles, + }; + } +} diff --git a/src/a2ui/reactivity.test.ts b/src/a2ui/reactivity.test.ts new file mode 100644 index 0000000..1878a4b --- /dev/null +++ b/src/a2ui/reactivity.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('A2UI Reactivity – parsePredicate', () => { + it('accepts a valid field equals predicate', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ field: 'myField', equals: 'hello' }); + assert.deepStrictEqual(pred, { field: 'myField', equals: 'hello' }); + }); + + it('accepts a valid field notEquals predicate', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ field: 'status', notEquals: 'closed' }); + assert.deepStrictEqual(pred, { field: 'status', notEquals: 'closed' }); + }); + + it('accepts a valid field isTruthy predicate', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ field: 'approved', isTruthy: true }); + assert.deepStrictEqual(pred, { field: 'approved', isTruthy: true }); + }); + + it('accepts a valid field isFalsy predicate', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ field: 'loading', isFalsy: true }); + assert.deepStrictEqual(pred, { field: 'loading', isFalsy: true }); + }); + + it('accepts an all combinator with nested predicates', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ + all: [ + { field: 'a', isTruthy: true }, + { field: 'b', equals: 42 }, + ], + }); + assert.deepStrictEqual(pred, { + all: [ + { field: 'a', isTruthy: true }, + { field: 'b', equals: 42 }, + ], + }); + }); + + it('accepts an any combinator with nested predicates', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ + any: [ + { field: 'x', isFalsy: true }, + { field: 'y', notEquals: null }, + ], + }); + assert.deepStrictEqual(pred, { + any: [ + { field: 'x', isFalsy: true }, + { field: 'y', notEquals: null }, + ], + }); + }); + + it('accepts deeply nested combinators', async () => { + const { parsePredicate } = await import('./reactivity'); + const pred = parsePredicate({ + all: [ + { any: [{ field: 'a', isTruthy: true }, { field: 'b', isTruthy: true }] }, + { field: 'c', equals: 'done' }, + ], + }); + assert.ok(pred); + }); + + it('throws on a completely invalid predicate shape', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate({ foo: 'bar' }), + /Invalid predicate shape/i, + ); + }); + + it('throws when field key is missing from a leaf predicate', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate({ equals: 'hello' }), + /Invalid predicate shape/i, + ); + }); + + it('throws on a non-object predicate value', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate('visibleIf'), + /Invalid predicate shape/i, + ); + }); + + it('throws when isTruthy is not literal true', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate({ field: 'x', isTruthy: 1 }), + /Invalid predicate shape/i, + ); + }); + + it('throws when isFalsy is not literal true', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate({ field: 'x', isFalsy: false }), + /Invalid predicate shape/i, + ); + }); + + it('throws when an all combinator includes unexpected keys', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate({ all: [{ field: 'x', isTruthy: true }], extra: 'nope' }), + /Invalid predicate shape/i, + ); + }); + + it('throws when an any combinator includes unexpected keys', async () => { + const { parsePredicate } = await import('./reactivity'); + assert.throws( + () => parsePredicate({ any: [{ field: 'x', isFalsy: true }], extra: 'nope' }), + /Invalid predicate shape/i, + ); + }); +}); + +describe('A2UI Reactivity – serializePredicate', () => { + it('serializes a leaf predicate to JSON', async () => { + const { parsePredicate, serializePredicate } = await import('./reactivity'); + const pred = parsePredicate({ field: 'status', equals: 'active' }); + const json = serializePredicate(pred); + assert.strictEqual(json, JSON.stringify({ field: 'status', equals: 'active' })); + }); + + it('serializes a combinator predicate to JSON', async () => { + const { parsePredicate, serializePredicate } = await import('./reactivity'); + const raw = { all: [{ field: 'x', isTruthy: true }] }; + const pred = parsePredicate(raw); + const json = serializePredicate(pred); + assert.strictEqual(json, JSON.stringify(raw)); + }); +}); + +describe('A2UI Reactivity – INTERACTIVE_COMPONENT_TYPES', () => { + it('includes Button, TextField, Checkbox, Select', async () => { + const { INTERACTIVE_COMPONENT_TYPES } = await import('./reactivity'); + assert.ok(INTERACTIVE_COMPONENT_TYPES.has('Button')); + assert.ok(INTERACTIVE_COMPONENT_TYPES.has('TextField')); + assert.ok(INTERACTIVE_COMPONENT_TYPES.has('Checkbox')); + assert.ok(INTERACTIVE_COMPONENT_TYPES.has('Select')); + }); + + it('does not include non-interactive types', async () => { + const { INTERACTIVE_COMPONENT_TYPES } = await import('./reactivity'); + assert.ok(!INTERACTIVE_COMPONENT_TYPES.has('Text')); + assert.ok(!INTERACTIVE_COMPONENT_TYPES.has('Row')); + assert.ok(!INTERACTIVE_COMPONENT_TYPES.has('Badge')); + }); +}); diff --git a/src/a2ui/reactivity.ts b/src/a2ui/reactivity.ts new file mode 100644 index 0000000..8ab61b5 --- /dev/null +++ b/src/a2ui/reactivity.ts @@ -0,0 +1,153 @@ +/** + * Shared declarative predicate contracts for A2UI reactivity (Phase 2, Slice 1). + * + * Predicates are used by `visibleIf` and `enabledIf` on A2UIComponent entries. + * They are validated at render time and serialized into `data-*` HTML attributes + * for consumption by the webview runtime (implemented in the next slice). + */ + +import { z } from 'zod'; + +/** + * Predicate field references are exact lookups against the browser-side form state map, + * which is keyed by rendered `data-field` / component ids. + * + * They are intentionally not JSON Pointer paths or `$data.*` bindings. + */ +const FieldReferenceSchema = z.string().min(1); + +// --------------------------------------------------------------------------- +// Leaf (field) predicate schemas +// --------------------------------------------------------------------------- + +const FieldEqualsSchema = z.strictObject({ + field: FieldReferenceSchema, + equals: z.unknown(), +}); + +const FieldNotEqualsSchema = z.strictObject({ + field: FieldReferenceSchema, + notEquals: z.unknown(), +}); + +const FieldIsTruthySchema = z.strictObject({ + field: FieldReferenceSchema, + isTruthy: z.literal(true), +}); + +const FieldIsFalsySchema = z.strictObject({ + field: FieldReferenceSchema, + isFalsy: z.literal(true), +}); + +const FieldPredicateSchema = z.union([ + FieldEqualsSchema, + FieldNotEqualsSchema, + FieldIsTruthySchema, + FieldIsFalsySchema, +]); + +export type FieldEqualsPredicate = z.infer; +export type FieldNotEqualsPredicate = z.infer; +export type FieldIsTruthyPredicate = z.infer; +export type FieldIsFalsyPredicate = z.infer; +export type FieldPredicate = z.infer; + +// --------------------------------------------------------------------------- +// Combinator types (recursive) +// --------------------------------------------------------------------------- + +export type AllPredicate = { all: A2UIPredicate[] }; +export type AnyPredicate = { any: A2UIPredicate[] }; + +export type A2UIPredicate = FieldPredicate | AllPredicate | AnyPredicate; + +// z.lazy is required for the recursive combinator references +const A2UIPredicateSchema: z.ZodType = z.lazy(() => + z.union([ + FieldPredicateSchema, + z.strictObject({ all: z.array(A2UIPredicateSchema) }), + z.strictObject({ any: z.array(A2UIPredicateSchema) }), + ]), +); + +export { A2UIPredicateSchema }; + +// --------------------------------------------------------------------------- +// Interactive component set (the only types that support enabledIf) +// --------------------------------------------------------------------------- + +export const INTERACTIVE_COMPONENT_TYPES: ReadonlySet = new Set([ + 'Button', + 'TextField', + 'Checkbox', + 'Select', +]); + +// --------------------------------------------------------------------------- +// Public helpers +// --------------------------------------------------------------------------- + +/** + * Parse and validate a raw value as an A2UIPredicate. + * Throws a descriptive Error if the shape is invalid. + */ +export function parsePredicate(raw: unknown): A2UIPredicate { + const result = A2UIPredicateSchema.safeParse(raw); + if (!result.success) { + throw new Error(`Invalid predicate shape: ${result.error.message}`); + } + return result.data; +} + +/** + * Serialize a validated predicate to a JSON string suitable for a data-* attribute. + */ +export function serializePredicate(predicate: A2UIPredicate): string { + return JSON.stringify(predicate); +} + +/** + * Evaluate a predicate against a field state map. + * + * @param predicate - A validated A2UIPredicate (leaf or combinator). + * @param fieldState - Current field values keyed by field id. Missing fields + * resolve to `undefined` (falsy, not equal to any value). + * @returns `true` if the predicate condition is satisfied, `false` otherwise. + */ +export function evaluatePredicate( + predicate: A2UIPredicate, + fieldState: Record, +): boolean { + if ('field' in predicate) { + if ('all' in (predicate as Record) || 'any' in (predicate as Record)) { + throw new Error('Invalid predicate shape: field predicates cannot also declare combinators.'); + } + + const conditionCount = Number('equals' in predicate) + + Number('notEquals' in predicate) + + Number('isTruthy' in predicate) + + Number('isFalsy' in predicate); + if (conditionCount !== 1) { + throw new Error('Invalid predicate shape: field predicates must declare exactly one condition.'); + } + + const value = fieldState[(predicate as FieldPredicate).field]; + if ('equals' in predicate) return value === (predicate as FieldEqualsPredicate).equals; + if ('notEquals' in predicate) return value !== (predicate as FieldNotEqualsPredicate).notEquals; + if ('isTruthy' in predicate) return Boolean(value); + if ('isFalsy' in predicate) return !value; + + throw new Error('Invalid predicate shape: unsupported field predicate condition.'); + } + if ('all' in predicate) { + if ('any' in (predicate as Record)) { + throw new Error('Invalid predicate shape: combinator predicates cannot declare both all and any.'); + } + return (predicate as AllPredicate).all.every((p) => evaluatePredicate(p, fieldState)); + } + if ('any' in predicate) { + return (predicate as AnyPredicate).any.some((p) => evaluatePredicate(p, fieldState)); + } + throw new Error('Invalid predicate shape: unsupported predicate.'); +} diff --git a/src/a2ui/renderer.test.ts b/src/a2ui/renderer.test.ts new file mode 100644 index 0000000..ae06c52 --- /dev/null +++ b/src/a2ui/renderer.test.ts @@ -0,0 +1,1153 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Helper: build a minimal A2UISurface for a single component +function surface( + type: string, + extraProps: Record = {}, + entryExtras: Record = {}, +) { + return { + components: [ + { + id: 'c1', + component: { type, props: extraProps }, + ...entryExtras, + }, + ], + }; +} + +describe('A2UI Renderer – visibleIf metadata', () => { + it('emits data-visible-if on a Text component', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface( + 'Text', + { content: 'Hello' }, + { visibleIf: { field: 'showText', isTruthy: true } }, + ), + ); + assert.ok( + html.includes('data-visible-if='), + `Expected data-visible-if attribute. Got: ${html}`, + ); + // The attribute value is HTML-escaped JSON; the field name value has no special chars + assert.ok( + html.includes('showText'), + `Expected field name value in attribute. Got: ${html}`, + ); + }); + + it('emits data-visible-if on a Button component', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface( + 'Button', + { label: 'OK', action: 'submit' }, + { visibleIf: { field: 'ready', equals: true } }, + ), + ); + assert.ok(html.includes('data-visible-if='), `Got: ${html}`); + }); + + it('emits data-visible-if with a combinator predicate', async () => { + const { renderSurface } = await import('./renderer'); + const predicate = { all: [{ field: 'a', isTruthy: true }, { field: 'b', equals: 'yes' }] }; + const html = renderSurface( + surface('Badge', { label: 'Active' }, { visibleIf: predicate }), + ); + assert.ok(html.includes('data-visible-if='), `Got: ${html}`); + // "all" is a JSON key, HTML-escaped as "all" in the attribute value + assert.ok(html.includes('"all"'), `Got: ${html}`); + }); + + it('does NOT emit data-visible-if when visibleIf is absent', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface(surface('Text', { content: 'Hi' })); + assert.ok(!html.includes('data-visible-if'), `Unexpected attribute. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – enabledIf metadata', () => { + const interactiveTypes = [ + { type: 'Button', props: { label: 'OK', action: 'submit' } }, + { type: 'TextField', props: { label: 'Name' } }, + { type: 'Checkbox', props: { label: 'Agree' } }, + { type: 'Select', props: { label: 'Pick one', options: ['A', 'B'] } }, + ]; + + for (const { type, props } of interactiveTypes) { + it(`emits data-enabled-if on ${type}`, async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface( + surface(type, props, { enabledIf: { field: 'active', isTruthy: true } }), + ); + assert.ok( + html.includes('data-enabled-if='), + `Expected data-enabled-if on ${type}. Got: ${html}`, + ); + }); + } + + it('does NOT emit data-enabled-if when enabledIf is absent', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface(surface('Button', { label: 'OK', action: 'go' })); + assert.ok(!html.includes('data-enabled-if'), `Unexpected attribute. Got: ${html}`); + }); + + it('throws RendererError when enabledIf is used on Text (non-interactive)', async () => { + const { renderSurface, RendererError } = await import('./renderer'); + assert.throws( + () => renderSurface( + surface('Text', { content: 'Hi' }, { enabledIf: { field: 'x', isTruthy: true } }), + ), + (err: unknown) => err instanceof RendererError && /enabledIf/i.test(err.message), + ); + }); + + it('throws RendererError when enabledIf is used on Row (non-interactive)', async () => { + const { renderSurface, RendererError } = await import('./renderer'); + assert.throws( + () => renderSurface( + surface('Row', {}, { enabledIf: { field: 'x', isFalsy: true } }), + ), + (err: unknown) => err instanceof RendererError && /enabledIf/i.test(err.message), + ); + }); + + it('throws RendererError when enabledIf is used on Badge (non-interactive)', async () => { + const { renderSurface, RendererError } = await import('./renderer'); + assert.throws( + () => renderSurface( + surface('Badge', { label: 'Tag' }, { enabledIf: { field: 'y', equals: 1 } }), + ), + (err: unknown) => err instanceof RendererError && /enabledIf/i.test(err.message), + ); + }); +}); + +describe('A2UI Renderer – invalid predicate shapes', () => { + it('throws RendererError for invalid visibleIf shape', async () => { + const { renderSurface, RendererError } = await import('./renderer'); + assert.throws( + () => renderSurface( + surface('Text', { content: 'Hi' }, { visibleIf: { bad: 'shape' } }), + ), + (err: unknown) => err instanceof RendererError && /predicate/i.test(err.message), + ); + }); + + it('throws RendererError for invalid enabledIf shape on interactive component', async () => { + const { renderSurface, RendererError } = await import('./renderer'); + assert.throws( + () => renderSurface( + surface('Button', { label: 'Go', action: 'go' }, { enabledIf: 'notAnObject' }), + ), + (err: unknown) => err instanceof RendererError && /predicate/i.test(err.message), + ); + }); + + it('throws RendererError for visibleIf with missing field key', async () => { + const { renderSurface, RendererError } = await import('./renderer'); + assert.throws( + () => renderSurface( + surface('Heading', { text: 'Title' }, { visibleIf: { equals: 'foo' } }), + ), + (err: unknown) => err instanceof RendererError && /predicate/i.test(err.message), + ); + }); +}); + +describe('A2UI Renderer – backward compatibility', () => { + it('renders Text without predicates unchanged', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface(surface('Text', { content: 'Hello world' })); + assert.ok(html.includes('Hello world'), `Got: ${html}`); + assert.ok(!html.includes('data-visible-if'), `Got: ${html}`); + assert.ok(!html.includes('data-enabled-if'), `Got: ${html}`); + }); + + it('renders Button without predicates unchanged', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface(surface('Button', { label: 'Click me', action: 'click' })); + assert.ok(html.includes('data-action="click"'), `Got: ${html}`); + assert.ok(!html.includes('data-visible-if'), `Got: ${html}`); + assert.ok(!html.includes('data-enabled-if'), `Got: ${html}`); + }); + + it('renders a nested surface without predicates unchanged', async () => { + const { renderSurface } = await import('./renderer'); + const result = renderSurface({ + components: [ + { id: 'row1', component: { type: 'Row' } }, + { + id: 'btn1', + parentId: 'row1', + component: { type: 'Button', props: { label: 'OK', action: 'ok' } }, + }, + ], + }); + assert.ok(result.includes('a2ui-row'), `Got: ${result}`); + assert.ok(result.includes('a2ui-button'), `Got: ${result}`); + assert.ok(!result.includes('data-visible-if'), `Got: ${result}`); + }); +}); + +describe('A2UI Renderer – CSS flexibility (Phase 1)', () => { + it('Column should NOT have flex: 1 in CSS class', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { id: 'col1', component: { type: 'Column', props: {} } }, + { + id: 'text1', + parentId: 'col1', + component: { type: 'Text', props: { content: 'Hello' } }, + }, + ], + }); + + // Verify the column is rendered + assert.ok(html.includes('a2ui-column'), `Expected a2ui-column class. Got: ${html}`); + + // Read the CSS file and verify flex: 1 is NOT present + const fs = await import('fs/promises'); + const cssPath = './media/a2ui.css'; + const cssContent = await fs.readFile(cssPath, 'utf-8'); + + // Check that .a2ui-column does NOT contain flex: 1 + const columnCssMatch = cssContent.match(/\.a2ui-column\s*{([^}]+)}/); + assert.ok(columnCssMatch, `Could not find .a2ui-column in CSS`); + + const columnCss = columnCssMatch[1]; + assert.ok( + !columnCss.includes('flex: 1') && !columnCss.includes('flex:1'), + `.a2ui-column should NOT contain 'flex: 1' to allow natural sizing. Found: ${columnCss}` + ); + }); + + it('Multiple columns in Row should not be forced to equal widths', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { id: 'row1', component: { type: 'Row', props: {} } }, + { + id: 'col1', + parentId: 'row1', + component: { type: 'Column', props: {} }, + }, + { + id: 'text1', + parentId: 'col1', + component: { type: 'Text', props: { content: 'Short' } }, + }, + { + id: 'col2', + parentId: 'row1', + component: { type: 'Column', props: {} }, + }, + { + id: 'text2', + parentId: 'col2', + component: { type: 'Text', props: { content: 'Much longer text content here' } }, + }, + ], + }); + + // Verify both columns are rendered + assert.ok(html.includes('a2ui-column'), `Expected a2ui-column classes. Got: ${html}`); + assert.ok(html.includes('Short'), `Expected 'Short' text. Got: ${html}`); + assert.ok(html.includes('Much longer text content here'), `Expected long text. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – Width/Height Props (Phase 2)', () => { + it('Row should render with inline width style when width prop is provided', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { id: 'row1', component: { type: 'Row', props: { width: '500px' } } }, + { + id: 'text1', + parentId: 'row1', + component: { type: 'Text', props: { content: 'Hello' } }, + }, + ], + }); + + // Verify the row is rendered + assert.ok(html.includes('a2ui-row'), `Expected a2ui-row class. Got: ${html}`); + + // Verify inline style with width is present + assert.ok( + html.includes('style="') && html.includes('width: 500px'), + `Expected inline style with width: 500px. Got: ${html}` + ); + }); + + it('Column should render with inline height style when height prop is provided', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { id: 'col1', component: { type: 'Column', props: { height: '300px' } } }, + { + id: 'text1', + parentId: 'col1', + component: { type: 'Text', props: { content: 'Hello' } }, + }, + ], + }); + + // Verify the column is rendered + assert.ok(html.includes('a2ui-column'), `Expected a2ui-column class. Got: ${html}`); + + // Verify inline style with height is present + assert.ok( + html.includes('style="') && html.includes('height: 300px'), + `Expected inline style with height: 300px. Got: ${html}` + ); + }); + + it('Card should render with inline width and height styles when both props are provided', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'card1', + component: { type: 'Card', props: { width: '400px', height: '200px' } }, + }, + { + id: 'text1', + parentId: 'card1', + component: { type: 'Text', props: { content: 'Card content' } }, + }, + ], + }); + + // Verify the card is rendered + assert.ok(html.includes('a2ui-card'), `Expected a2ui-card class. Got: ${html}`); + + // Verify inline styles with width and height are present + assert.ok( + html.includes('style="') && html.includes('width: 400px') && html.includes('height: 200px'), + `Expected inline style with width: 400px and height: 200px. Got: ${html}` + ); + }); + + it('Row should not render inline style when width prop is not provided', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { id: 'row1', component: { type: 'Row', props: {} } }, + { + id: 'text1', + parentId: 'row1', + component: { type: 'Text', props: { content: 'Hello' } }, + }, + ], + }); + + // Verify the row is rendered + assert.ok(html.includes('a2ui-row'), `Expected a2ui-row class. Got: ${html}`); + + // Verify no inline style is present (or if present, doesn't have width) + const rowMatch = html.match(/
]*)>/); + assert.ok(rowMatch, `Could not find Row element. Got: ${html}`); + + const attrs = rowMatch[1]; + assert.ok( + !attrs.includes('style='), + `Expected no inline style when width prop is absent. Found: ${attrs}` + ); + }); + + it('Column should render with percentage width', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { id: 'col1', component: { type: 'Column', props: { width: '50%' } } }, + { + id: 'text1', + parentId: 'col1', + component: { type: 'Text', props: { content: 'Half width' } }, + }, + ], + }); + + // Verify the column is rendered + assert.ok(html.includes('a2ui-column'), `Expected a2ui-column class. Got: ${html}`); + + // Verify inline style with percentage width is present + assert.ok( + html.includes('style="') && html.includes('width: 50%'), + `Expected inline style with width: 50%. Got: ${html}` + ); + }); +}); + +describe('A2UI Renderer – Table Component (Phase 3.1)', () => { + it('should render Table with data and columns props', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'table1', + component: { + type: 'Table', + props: { + data: [ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ], + columns: [ + { key: 'name', label: 'Name' }, + { key: 'age', label: 'Age' }, + ], + }, + }, + }, + ], + }); + + // Verify the table is rendered + assert.ok(html.includes('a2ui-table'), `Expected a2ui-table class. Got: ${html}`); + + // Verify table structure + assert.ok(html.includes(' element. Got: ${html}`); + assert.ok(html.includes(''), `Expected . Got: ${html}`); + assert.ok(html.includes(''), `Expected . Got: ${html}`); + assert.ok(html.includes(''), `Expected elements. Got: ${html}`); + + // Verify headers + assert.ok(html.includes('Name'), `Expected 'Name' header. Got: ${html}`); + assert.ok(html.includes('Age'), `Expected 'Age' header. Got: ${html}`); + + // Verify data rows + assert.ok(html.includes('Alice'), `Expected 'Alice' data. Got: ${html}`); + assert.ok(html.includes('Bob'), `Expected 'Bob' data. Got: ${html}`); + assert.ok(html.includes('30'), `Expected '30' data. Got: ${html}`); + assert.ok(html.includes('25'), `Expected '25' data. Got: ${html}`); + }); + + it('should render empty Table when data array is empty', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'table1', + component: { + type: 'Table', + props: { + data: [], + columns: [ + { key: 'name', label: 'Name' }, + { key: 'age', label: 'Age' }, + ], + }, + }, + }, + ], + }); + + // Verify the table is rendered + assert.ok(html.includes('a2ui-table'), `Expected a2ui-table class. Got: ${html}`); + + // Verify headers are still present + assert.ok(html.includes('Name'), `Expected 'Name' header. Got: ${html}`); + assert.ok(html.includes('Age'), `Expected 'Age' header. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – Tabs Component (Phase 3.2)', () => { + it('should render Tabs with tabs prop and activeTab', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'tabs1', + component: { + type: 'Tabs', + props: { + tabs: [ + { id: 'tab1', label: 'First Tab' }, + { id: 'tab2', label: 'Second Tab' }, + { id: 'tab3', label: 'Third Tab' }, + ], + activeTab: 'tab2', + }, + }, + }, + ], + }); + + // Verify the tabs container is rendered + assert.ok(html.includes('a2ui-tabs'), `Expected a2ui-tabs class. Got: ${html}`); + + // Verify tab buttons are rendered + assert.ok(html.includes('a2ui-tab-button'), `Expected a2ui-tab-button class. Got: ${html}`); + assert.ok(html.includes('First Tab'), `Expected 'First Tab' label. Got: ${html}`); + assert.ok(html.includes('Second Tab'), `Expected 'Second Tab' label. Got: ${html}`); + assert.ok(html.includes('Third Tab'), `Expected 'Third Tab' label. Got: ${html}`); + + // Verify active tab is marked + assert.ok(html.includes('data-active-tab="tab2"'), `Expected data-active-tab attribute. Got: ${html}`); + }); + + it('should render Tabs with content panels for each tab', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'tabs1', + component: { + type: 'Tabs', + props: { + tabs: [ + { id: 'tab1', label: 'Overview' }, + { id: 'tab2', label: 'Details' }, + ], + activeTab: 'tab1', + }, + }, + }, + { + id: 'content-tab1', + parentId: 'tabs1', + component: { + type: 'Text', + props: { content: 'This is the overview content' }, + }, + }, + { + id: 'content-tab2', + parentId: 'tabs1', + component: { + type: 'Text', + props: { content: 'This is the details content' }, + }, + }, + ], + }); + + // Verify tab panels are rendered + assert.ok(html.includes('a2ui-tab-panel'), `Expected a2ui-tab-panel class. Got: ${html}`); + assert.ok(html.includes('This is the overview content'), `Expected overview content. Got: ${html}`); + assert.ok(html.includes('This is the details content'), `Expected details content. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – Toggle Component (Phase 3.3)', () => { + it('should render Toggle with checked and label props', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'toggle1', + component: { + type: 'Toggle', + props: { + label: 'Enable Feature', + checked: true, + }, + }, + }, + ], + }); + + // Verify the toggle is rendered + assert.ok(html.includes('a2ui-toggle'), `Expected a2ui-toggle class. Got: ${html}`); + + // Verify label is rendered + assert.ok(html.includes('Enable Feature'), `Expected 'Enable Feature' label. Got: ${html}`); + + // Verify checked state + assert.ok(html.includes('checked'), `Expected checked attribute. Got: ${html}`); + }); + + it('should render Toggle with unchecked state', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'toggle1', + component: { + type: 'Toggle', + props: { + label: 'Dark Mode', + checked: false, + }, + }, + }, + ], + }); + + // Verify the toggle is rendered + assert.ok(html.includes('a2ui-toggle'), `Expected a2ui-toggle class. Got: ${html}`); + + // Verify label is rendered + assert.ok(html.includes('Dark Mode'), `Expected 'Dark Mode' label. Got: ${html}`); + + // Verify unchecked state (no checked attribute) + const toggleMatch = html.match(/]*>/); + assert.ok(toggleMatch, `Expected checkbox input. Got: ${html}`); + assert.ok(!toggleMatch[0].includes('checked'), `Expected no checked attribute. Got: ${toggleMatch[0]}`); + }); +}); + +describe('A2UI Renderer – Style Prop with Whitelist (Phase 4)', () => { + it('should render allowed style properties', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'card1', + component: { + type: 'Card', + props: { + style: { + color: 'red', + backgroundColor: '#f0f0f0', + margin: '10px', + padding: '20px', + width: '300px', + height: '200px', + border: '1px solid black', + borderRadius: '8px', + }, + }, + }, + }, + { + id: 'text1', + parentId: 'card1', + component: { type: 'Text', props: { content: 'Styled Card' } }, + }, + ], + }); + + // Verify the card is rendered + assert.ok(html.includes('a2ui-card'), `Expected a2ui-card class. Got: ${html}`); + + // Verify allowed styles are rendered + assert.ok(html.includes('color: red'), `Expected 'color: red'. Got: ${html}`); + assert.ok(html.includes('background-color: #f0f0f0'), `Expected 'background-color: #f0f0f0'. Got: ${html}`); + assert.ok(html.includes('margin: 10px'), `Expected 'margin: 10px'. Got: ${html}`); + assert.ok(html.includes('padding: 20px'), `Expected 'padding: 20px'. Got: ${html}`); + assert.ok(html.includes('width: 300px'), `Expected 'width: 300px'. Got: ${html}`); + assert.ok(html.includes('height: 200px'), `Expected 'height: 200px'. Got: ${html}`); + assert.ok(html.includes('border: 1px solid black'), `Expected 'border: 1px solid black'. Got: ${html}`); + assert.ok(html.includes('border-radius: 8px'), `Expected 'border-radius: 8px'. Got: ${html}`); + }); + + it('should reject dangerous style properties', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'card1', + component: { + type: 'Card', + props: { + style: { + position: 'absolute', + top: '0px', + left: '0px', + right: '0px', + bottom: '0px', + overflow: 'hidden', + zIndex: '9999', + }, + }, + }, + }, + { + id: 'text1', + parentId: 'card1', + component: { type: 'Text', props: { content: 'Dangerous Styles' } }, + }, + ], + }); + + // Verify the card is rendered + assert.ok(html.includes('a2ui-card'), `Expected a2ui-card class. Got: ${html}`); + + // Verify dangerous styles are NOT rendered + assert.ok(!html.includes('position: absolute'), `Should not include 'position: absolute'. Got: ${html}`); + assert.ok(!html.includes('top: 0px'), `Should not include 'top: 0px'. Got: ${html}`); + assert.ok(!html.includes('left: 0px'), `Should not include 'left: 0px'. Got: ${html}`); + assert.ok(!html.includes('right: 0px'), `Should not include 'right: 0px'. Got: ${html}`); + assert.ok(!html.includes('bottom: 0px'), `Should not include 'bottom: 0px'. Got: ${html}`); + assert.ok(!html.includes('z-index: 9999'), `Should not include 'z-index: 9999'. Got: ${html}`); + }); + + it('should handle empty style object', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'card1', + component: { + type: 'Card', + props: { + style: {}, + }, + }, + }, + { + id: 'text1', + parentId: 'card1', + component: { type: 'Text', props: { content: 'No Styles' } }, + }, + ], + }); + + // Verify the card is rendered + assert.ok(html.includes('a2ui-card'), `Expected a2ui-card class. Got: ${html}`); + + // Verify no inline style attribute is rendered + const cardMatch = html.match(/
]*)>/); + assert.ok(cardMatch, `Could not find Card element. Got: ${html}`); + + const attrs = cardMatch[1]; + // Should not have style attribute, or if it does, it should be empty + if (attrs.includes('style=')) { + assert.ok(attrs.includes('style=""'), `Expected empty style attribute. Found: ${attrs}`); + } + }); + + it('should mix style prop with width/height props', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [ + { + id: 'card1', + component: { + type: 'Card', + props: { + width: '400px', + height: '300px', + style: { + backgroundColor: 'blue', + padding: '10px', + }, + }, + }, + }, + { + id: 'text1', + parentId: 'card1', + component: { type: 'Text', props: { content: 'Mixed Styles' } }, + }, + ], + }); + + // Verify the card is rendered + assert.ok(html.includes('a2ui-card'), `Expected a2ui-card class. Got: ${html}`); + + // Verify both props and style are rendered + assert.ok(html.includes('width: 400px'), `Expected 'width: 400px'. Got: ${html}`); + assert.ok(html.includes('height: 300px'), `Expected 'height: 300px'. Got: ${html}`); + assert.ok(html.includes('background-color: blue'), `Expected 'background-color: blue'. Got: ${html}`); + assert.ok(html.includes('padding: 10px'), `Expected 'padding: 10px'. Got: ${html}`); + }); +}); + +describe('A2UI Renderer – HTML Component (Phase 5)', () => { + it('renders basic HTML content', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'html1', + component: { + type: 'HTML', + props: { + html: '
Hello World
' + } + } + }] + }); + assert.ok(html.includes('
Hello World
'), `Expected HTML content. Got: ${html}`); + assert.ok(html.includes('a2ui-html-container'), `Expected a2ui-html-container class. Got: ${html}`); + }); + + it('sanitizes dangerous HTML (script tags removed)', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'html1', + component: { + type: 'HTML', + props: { + html: '

Safe content

' + } + } + }] + }); + assert.ok(!html.includes('

after

' }, + }, + }], + }); + assert.ok(!html.includes(' { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { html: '

safe

' }, + }, + }], + }); + assert.ok(!html.includes('onload'), `onload handler must be stripped. Got: ${html}`); + assert.ok(html.includes('safe'), `Safe content must survive. Got: ${html}`); + }); + + it('strips onerror handler on img tags', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { html: '' }, + }, + }], + }); + assert.ok(!html.includes('onerror'), `onerror must be stripped. Got: ${html}`); + // img itself and src may survive – only the handler is stripped + }); + + it('strips onclick and other on* handlers from arbitrary elements', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { html: '' }, + }, + }], + }); + assert.ok(!html.includes('onclick'), `onclick must be stripped. Got: ${html}`); + assert.ok(html.includes('click me'), `Button text must survive. Got: ${html}`); + }); + + it('strips javascript: protocol from href attributes', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { html: 'link text' }, + }, + }], + }); + assert.ok(!html.includes('javascript:'), `javascript: href must be stripped. Got: ${html}`); + assert.ok(html.includes('link text'), `Link text must survive. Got: ${html}`); + }); + + it('strips javascript: protocol from src attributes (iframe)', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { html: '

safe

' }, + }, + }], + }); + assert.ok(!html.includes('src="javascript:'), `javascript: iframe src must be stripped. Got: ${html}`); + assert.ok(html.includes('safe'), `Content after iframe must survive. Got: ${html}`); + }); + + it('preserves safe HTML structure unchanged', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { + html: '

Title

Body text.

', + }, + }, + }], + }); + assert.ok(html.includes('
'), `Safe div class must survive. Got: ${html}`); + assert.ok(html.includes('

Title

'), `Safe h2 must survive. Got: ${html}`); + assert.ok(html.includes('text'), `Safe strong must survive. Got: ${html}`); + }); + + it('strips noscript tags and their content', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ + id: 'h1', + component: { + type: 'HTML', + props: { html: '

visible

' }, + }, + }], + }); + assert.ok(!html.includes(' { + it('renders code prop (schema-correct prop name)', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ id: 'cb1', component: { type: 'CodeBlock', props: { code: 'const x = 1;', language: 'typescript' } } }], + }); + assert.ok(html.includes('const x = 1;'), `code prop must render. Got: ${html}`); + assert.ok(html.includes('language-typescript'), `language class must be set. Got: ${html}`); + }); + + it('auto-upgrades CodeBlock with language=mermaid to MermaidDiagram structure', async () => { + const { renderSurface } = await import('./renderer'); + const html = renderSurface({ + components: [{ id: 'cb2', component: { type: 'CodeBlock', props: { code: 'graph TD\n A --> B', language: 'mermaid' } } }], + }); + assert.ok(html.includes('a2ui-mermaid'), `auto-upgrade must emit .a2ui-mermaid. Got: ${html}`); + assert.ok(html.includes('a2ui-mermaid-target'), `must include render target. Got: ${html}`); + assert.ok(html.includes('graph TD'), `mermaid source must be preserved. Got: ${html}`); + assert.ok(!html.includes('a2ui-codeblock'), `must NOT be a plain code block. Got: ${html}`); + }); +}); + +describe('Markdown mermaid auto-upgrade', () => { + it('auto-upgrades mermaid fenced code block inside Markdown to MermaidDiagram structure', async () => { + const { renderSurface } = await import('./renderer'); + const content = 'Here is a diagram:\n\n```mermaid\ngraph TD\n A --> B\n```\n\nEnd.'; + const html = renderSurface({ + components: [{ id: 'md1', component: { type: 'Markdown', props: { content } } }], + }); + assert.ok(html.includes('a2ui-mermaid'), `mermaid fence must be auto-upgraded. Got: ${html}`); + assert.ok(html.includes('a2ui-mermaid-target'), `must include render target. Got: ${html}`); + assert.ok(html.includes('graph TD'), `mermaid source must be preserved. Got: ${html}`); + assert.ok(html.includes('Here is a diagram'), `surrounding text must survive. Got: ${html}`); + }); +}); diff --git a/src/a2ui/renderer.ts b/src/a2ui/renderer.ts new file mode 100644 index 0000000..ae44b04 --- /dev/null +++ b/src/a2ui/renderer.ts @@ -0,0 +1,1269 @@ +import MarkdownIt from 'markdown-it'; + +import type { A2UISurface, A2UIDataModel, DroppedStyleEntry } from './types'; +import { isAllowedComponentType } from './catalog'; +import { parsePredicate, serializePredicate, INTERACTIVE_COMPONENT_TYPES } from './reactivity'; + +/** + * DOM-free HTML sanitizer for the HTML component. + * + * Removes dangerous elements (script, noscript, style, object, embed …) together + * with their inner content, then strips event-handler attributes (on*) and + * `javascript:` protocol URLs from all remaining tags. + * + * Why not DOMPurify + jsdom? + * The previous implementation called `new JSDOM('')` at module load time. + * When esbuild bundles the extension, jsdom tries to open its bundled + * `browser/default-stylesheet.css` asset relative to its original install + * path, which no longer exists inside the dist bundle → ENOENT crash. + * This replacement has no runtime dependencies beyond Node built-ins and + * works safely inside the bundled extension host. + * + * Trade-offs vs DOMPurify + jsdom: + * - Does not require jsdom; safe to bundle with esbuild. + * - Pattern-based: handles all XSS vectors exercised by the test suite + * (script injection, on* handlers with/without whitespace, javascript: + * protocol in href/src). Deeply-encoded or mutation-based bypasses + * (HTML entities inside attribute values, etc.) are not addressed at + * this layer – they are mitigated by the sandboxed-iframe rendering path + * (sandbox: true) which provides defence-in-depth. + */ +function sanitizeUrl(url: string): string { + // Block vbscript and javascript + if (/^(javascript|vbscript):/i.test(url)) return ''; + // Block dangerous data: MIME types (svg+xml can execute JS, text/html, text/javascript) + if (/^data:/i.test(url)) { + const safeMime = /^data:image\/(png|jpeg|jpg|gif|webp);/i.test(url); + if (!safeMime) return ''; + } + return url; +} + +function sanitizeHTML(html: string): string { + // Step 1 – strip dangerous block elements and ALL their content. + // These tags can contain or execute arbitrary code regardless of attributes. + const BLOCK_STRIP = [ + 'script', 'noscript', 'style', 'object', 'embed', + 'applet', 'base', 'meta', 'link', + ] as const; + for (const tag of BLOCK_STRIP) { + // Non-void form: + html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, 'gi'), ''); + // Void / self-closing form: or + html = html.replace(new RegExp(`<${tag}(?:[\\s/][^>]*)?>`, 'gi'), ''); + } + + // Step 2 – for every remaining tag, strip dangerous attributes. + html = html.replace( + /<([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)(\/?>)/g, + (_: string, tagName: string, attrs: string, end: string): string => { + // 2a. Strip on* event-handler attributes. + // `[\s/]*` before "on" handles (slash, no space). + let clean = attrs.replace( + /[\s/]*on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]*)/gi, + '', + ); + + // 2b. Sanitize URL protocols from URL-bearing attributes (blocks javascript:, vbscript:, + // and dangerous data: URIs while allowing safe image data: URIs). + // Quoted form: href="…" + clean = clean.replace( + /((?:href|src|action|formaction|xlink:href)\s*=\s*)(['"])\s*([^'"]*)\2/gi, + (_: string, prefix: string, quote: string, url: string): string => { + const safe = sanitizeUrl(url.trim()); + return `${prefix}${quote}${safe !== '' ? safe : 'about:blank'}${quote}`; + }, + ); + // Unquoted form: href=… + clean = clean.replace( + /((?:href|src|action|formaction|xlink:href)\s*=\s*)([^\s>"']*)/gi, + (_: string, prefix: string, url: string): string => { + const safe = sanitizeUrl(url.trim()); + return `${prefix}${safe !== '' ? safe : 'about:blank'}`; + }, + ); + + return `<${tagName}${clean}${end}`; + }, + ); + + return html; +} + +const markdownRenderer = new MarkdownIt({ + html: false, + linkify: true, + breaks: false, +}); + +export class RendererError extends Error { + constructor(message: string) { + super(message); + this.name = 'RendererError'; + } +} + +function escHtml(str: string): string { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Whitelist of safe CSS properties that can be used with the style prop. + * Dangerous properties like position, overflow, z-index are excluded. + */ +const SAFE_CSS_PROPERTIES = new Set([ + // Colors + 'color', + 'backgroundColor', + 'borderColor', + // Spacing + 'margin', + 'marginTop', + 'marginRight', + 'marginBottom', + 'marginLeft', + 'padding', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + // Dimensions + 'width', + 'height', + 'minWidth', + 'minHeight', + 'maxWidth', + 'maxHeight', + // Borders + 'border', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', + 'borderRadius', + 'borderWidth', + 'borderStyle', + // Typography + 'fontSize', + 'fontWeight', + 'fontFamily', + 'lineHeight', + 'textAlign', + 'textDecoration', + 'whiteSpace', + 'textOverflow', + // Display & Flexbox + 'display', + 'flexDirection', + 'justifyContent', + 'alignItems', + 'alignSelf', + 'gap', + 'flex', + 'flexGrow', + 'flexShrink', + 'flexBasis', + 'flexWrap', + // CSS Grid + 'gridTemplateColumns', + 'gridTemplateRows', + 'gridColumn', + 'gridRow', + // Overflow & visibility + 'overflow', + 'overflowX', + 'overflowY', + // Box model utilities + 'boxSizing', + // Image + 'objectFit', + 'objectPosition', + // Miscellaneous safe properties + 'cursor', + 'opacity', +]); + +/** + * Allowed CSS dimension value pattern. + * Accepts: , percentage, "auto", "inherit", or bare "0". + * Rejects anything containing semicolons or other characters that could + * break out of an inline style attribute. + */ +const SAFE_DIMENSION_RE = /^(\d+(\.\d+)?(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|fr)|auto|inherit|0)$/i; + +/** + * Allowed CSS calc() expression pattern. + * Blocks dangerous characters that could escape a style attribute or inject + * css functions like url(), expression(), or script content. + */ +const SAFE_CALC_RE = /^calc\([^;{}'"`<>]*\)$/i; + +/** + * Returns the trimmed dimension string if it is a safe CSS length/percentage + * value, or null if it contains disallowed characters. + */ +function sanitizeDimension(value: string): string | null { + const trimmed = value.trim(); + return SAFE_DIMENSION_RE.test(trimmed) || SAFE_CALC_RE.test(trimmed) ? trimmed : null; +} + +/** + * Renders a style object into an inline style string with whitelist validation. + * Only safe CSS properties are allowed; dangerous ones are filtered out. + * When `droppedOut` and `componentId` are provided, dropped property names are + * accumulated so callers can report them back to the agent. + */ +function renderStyle(styleObj: unknown, droppedOut?: string[], componentId?: string): string { + if (!styleObj || typeof styleObj !== 'object' || Array.isArray(styleObj)) { + return ''; + } + + const styleParts: string[] = []; + for (const [key, value] of Object.entries(styleObj)) { + // Check if property is in whitelist + if (!SAFE_CSS_PROPERTIES.has(key)) { + if (droppedOut) { + droppedOut.push(key); + } + continue; // Skip unsafe properties + } + + // Validate value is a string + if (typeof value !== 'string') { + continue; + } + + // Convert camelCase to kebab-case for CSS + const cssKey = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + styleParts.push(`${cssKey}: ${value}`); + } + + return styleParts.join('; '); +} + +function sanitizeCssValue(value: string): string { + // Block CSS expression() (IE legacy, defense-in-depth) + // Block javascript: and vbscript: in url() values + // Block @import + if (/expression\s*\(/i.test(value)) return ''; + if (/url\s*\(\s*['"]?\s*(javascript|vbscript|data:text)/i.test(value)) return ''; + if (/@import/i.test(value)) return ''; + return value; +} + +/** + * Parses a declarative CSS string and validates/filter unsafe properties. + * Handles both inline styles and CSS rules with selectors. + * Returns a filtered CSS string with only safe properties. + */ +function parseDeclarativeStyle(cssString: string): string { + if (typeof cssString !== 'string') { + return ''; + } + + // Parse CSS rules: selector { property: value; } + const ruleRegex = /([^{]+)\{([^}]+)\}/g; + let match; + const filteredRules: string[] = []; + + while ((match = ruleRegex.exec(cssString)) !== null) { + const selector = match[1].trim(); + const declarations = match[2].trim(); + + // Parse and filter each declaration + const decls = declarations.split(';').filter(d => d.trim().length > 0); + const filteredDecls: string[] = []; + + for (const decl of decls) { + const colonIndex = decl.indexOf(':'); + if (colonIndex === -1) continue; + + const property = decl.slice(0, colonIndex).trim(); + const value = decl.slice(colonIndex + 1).trim(); + + if (property && value) { + // Convert kebab-case to camelCase for whitelist check + const camelKey = property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + + // Check if property is in whitelist + if (SAFE_CSS_PROPERTIES.has(camelKey)) { + const safeValue = sanitizeCssValue(value); + if (safeValue !== '') { + filteredDecls.push(`${property}: ${safeValue}`); + } + } + } + } + + if (filteredDecls.length > 0) { + filteredRules.push(`${selector} { ${filteredDecls.join('; ')} }`); + } + } + + // Also handle inline styles (no selector) + if (!cssString.includes('{') && cssString.includes(':')) { + const decls = cssString.split(';').filter(d => d.trim().length > 0); + const filteredDecls: string[] = []; + + for (const decl of decls) { + const colonIndex = decl.indexOf(':'); + if (colonIndex === -1) continue; + + const property = decl.slice(0, colonIndex).trim(); + const value = decl.slice(colonIndex + 1).trim(); + + if (property && value) { + const camelKey = property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + if (SAFE_CSS_PROPERTIES.has(camelKey)) { + const safeValue = sanitizeCssValue(value); + if (safeValue !== '') { + filteredDecls.push(`${property}: ${safeValue}`); + } + } + } + } + + return filteredDecls.join('; '); + } + + return filteredRules.join('\n'); +} + +function decodeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function resolveJsonPointer(path: string, data: unknown): unknown { + if (path === '' || path === '/') { + return data; + } + + if (!path.startsWith('/')) { + return undefined; + } + + const parts = path.slice(1).split('/').map(decodeJsonPointerSegment); + let current = data; + for (const part of parts) { + if (current === null || typeof current !== 'object') { + return undefined; + } + + if (Array.isArray(current)) { + const index = Number(part); + if (!Number.isInteger(index)) { + return undefined; + } + current = current[index]; + continue; + } + + current = (current as Record)[part]; + } + + return current; +} + +function resolveLegacyDataPath(path: string, data: A2UIDataModel): unknown { + if (!path.startsWith('$data.')) { + return undefined; + } + + const parts = path.slice(6).split('.'); + let current: unknown = data; + for (const part of parts) { + if (current === null || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +function extractLiteralBoundValue(value: Record): unknown { + if ('literalString' in value) { + return value.literalString; + } + if ('literalNumber' in value) { + return value.literalNumber; + } + if ('literalBoolean' in value) { + return value.literalBoolean; + } + if ('literalArray' in value) { + return value.literalArray; + } + + return undefined; +} + +function isBoundValueObject(value: unknown): value is Record { + return Boolean( + value + && typeof value === 'object' + && !Array.isArray(value) + && ('path' in value || 'literalString' in value || 'literalNumber' in value || 'literalBoolean' in value || 'literalArray' in value) + ); +} + +function resolveBinding(value: unknown, scopeData: unknown, rootData: A2UIDataModel): unknown { + if (typeof value === 'string' && value.startsWith('$data.')) { + return resolveLegacyDataPath(value, rootData); + } + + if (!isBoundValueObject(value)) { + return value; + } + + const literalValue = extractLiteralBoundValue(value); + const path = typeof value.path === 'string' ? value.path : undefined; + if (!path) { + return literalValue; + } + + const resolved = resolveJsonPointer(path, scopeData); + return resolved === undefined ? literalValue : resolved; +} + +function interpolateBindings(value: string, rootData: A2UIDataModel): string { + return value.replace(/\$data(?:\.[A-Za-z0-9_]+)+/g, (match) => { + const resolved = resolveLegacyDataPath(match, rootData); + if (resolved === undefined || resolved === null) { + return ''; + } + if (typeof resolved === 'object') { + return JSON.stringify(resolved); + } + return String(resolved); + }); +} + +function resolveValue(value: unknown, scopeData: unknown, rootData: A2UIDataModel): unknown { + if (isBoundValueObject(value)) { + return resolveBinding(value, scopeData, rootData); + } + + if (typeof value === 'string' && value.includes('$data.')) { + if (value.startsWith('$data.') && !value.includes(' ')) { + return resolveLegacyDataPath(value, rootData); + } + return interpolateBindings(value, rootData); + } + + if (Array.isArray(value)) { + return value.map((entry) => resolveValue(entry, scopeData, rootData)); + } + + if (value && typeof value === 'object') { + const resolvedObject: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + resolvedObject[key] = resolveValue(nestedValue, scopeData, rootData); + } + return resolvedObject; + } + + return resolveBinding(value, scopeData, rootData); +} + +function resolveProps( + props: Record, + scopeData: unknown, + rootData: A2UIDataModel, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(props)) { + resolved[key] = resolveValue(value, scopeData, rootData); + } + return resolved; +} + +function extractComponentProps(component: Record): Record { + if (typeof component.type !== 'string') { + const entries = Object.entries(component); + if (entries.length === 1) { + const [type, props] = entries[0]; + if (isAllowedComponentType(type) && props && typeof props === 'object' && !Array.isArray(props)) { + return props as Record; + } + } + } + + const props = component.props; + if (props && typeof props === 'object' && !Array.isArray(props)) { + return props as Record; + } + + const extractedProps: Record = {}; + for (const [key, value] of Object.entries(component)) { + if (key === 'type') { + continue; + } + extractedProps[key] = value; + } + + return extractedProps; +} + +function extractComponentType(component: Record): string { + if (typeof component.type === 'string') { + return component.type; + } + + const entries = Object.entries(component); + if (entries.length === 1 && isAllowedComponentType(entries[0][0])) { + return entries[0][0]; + } + + return String(component.type ?? ''); +} + +function resolveTemplateItems(children: unknown, scopeData: unknown): Array<{ componentId: string; itemData: unknown }> { + if (!children || typeof children !== 'object' || Array.isArray(children)) { + return []; + } + + const template = (children as Record).template; + if (!template || typeof template !== 'object' || Array.isArray(template)) { + return []; + } + + const templateRecord = template as Record; + const binding = typeof templateRecord.dataBinding === 'string' ? templateRecord.dataBinding : undefined; + const componentId = typeof templateRecord.componentId === 'string' ? templateRecord.componentId : undefined; + if (!binding || !componentId) { + return []; + } + + const items = resolveJsonPointer(binding, scopeData); + if (!Array.isArray(items)) { + return []; + } + + return items.map((item) => ({ componentId, itemData: item })); +} + +/** + * Normalise a chart `data` prop to a plain array. + * + * The LLM sometimes serialises the array to a JSON string before the value + * reaches the renderer (e.g. when the tool schema coerces unknown types to + * strings). Accept both a native array and a stringified JSON array so that + * charts render correctly in either case. + * + * Returns an empty array when the value is absent, not an array, or not a + * valid JSON string that parses to an array. + */ +function parseChartData>(raw: unknown): T[] { + if (Array.isArray(raw)) { + return raw as T[]; + } + if (typeof raw === 'string' && raw.trim().startsWith('[')) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed as T[]; + } + } catch { + // fall through to empty array + } + } + return []; +} + +/** + * Coerce a chart item `value` to a finite number. + * + * Chart data arriving from the LLM may have numeric values serialised as + * strings (e.g. `"42"` instead of `42`). Treating non-`number` typed values + * as 0 collapses all plotted geometry to nothing. This helper accepts both + * `number` and numeric `string` inputs and returns the parsed finite value, or + * 0 for anything that is absent, non-numeric, or non-finite (NaN / ±Infinity). + */ +function toFiniteNum(v: unknown): number { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +// SVG Chart coordinate system: +// ViewBox: 0 0 600 200 +// Chart area: x=30 to 570 (width=540), y=30 to 180 (height=150) +// Title area: y=10 to 25 +// Left axis labels: x=0 to 28 +// Bottom axis labels: y=182 to 200 +const CHART_LIMITS = { + OPTIMAL: 2000, + WARNING: 5000, + SOFT_LIMIT: 10000, + HARD_LIMIT: 25000, +}; + +function isValidCssColor(color: string): boolean { + return /^#[0-9a-fA-F]{3,8}$|^rgb\(|^rgba\(|^hsl\(|^hsla\(|^[a-zA-Z][a-zA-Z0-9-]*$/.test(color.trim()); +} + +function generateSmoothPath(points: Array<{ x: number; y: number }>): string { + if (points.length < 2) return ''; + let d = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + // Control points: 1/3 of the way between adjacent points + const cp1x = prev.x + (curr.x - prev.x) / 3; + const cp1y = prev.y; + const cp2x = curr.x - (curr.x - prev.x) / 3; + const cp2y = curr.y; + d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`; + } + return d; +} + +function renderTag( + type: string, + id: string, + props: Record, + children: string, + droppedMapOut?: Map, +): string { + const labelText = typeof props.label === 'string' ? props.label : ''; + const disabled = props.disabled === true ? ' disabled' : ''; + const ariaLabel = typeof props.ariaLabel === 'string' && props.ariaLabel.trim().length > 0 + ? ` aria-label="${escHtml(props.ariaLabel)}"` + : ''; + const helperText = typeof props.helperText === 'string' ? props.helperText : ''; + const requiredMarker = props.required ? '' : ''; + const requiredAttribute = props.required ? ' required' : ''; + + // Build inline style from width/height props (Phase 2) and style prop (Phase 4) + const styleParts: string[] = []; + if (typeof props.width === 'string' && props.width.trim().length > 0) { + const safeWidth = sanitizeDimension(props.width); + if (safeWidth !== null) { + styleParts.push(`width: ${safeWidth}`); + } + } + if (typeof props.height === 'string' && props.height.trim().length > 0) { + const safeHeight = sanitizeDimension(props.height); + if (safeHeight !== null) { + styleParts.push(`height: ${safeHeight}`); + } + } + // Add style prop with whitelist validation + const droppedProps: string[] = []; + const customStyle = renderStyle(props.style, droppedProps); + if (droppedProps.length > 0 && droppedMapOut) { + droppedMapOut.set(id, droppedProps); + } + if (customStyle) { + styleParts.push(customStyle); + } + const styleAttr = styleParts.length > 0 ? ` style="${escHtml(styleParts.join('; '))}"` : ''; + + switch (type) { + case 'Row': + return `
${children}
`; + + case 'Column': + return `
${children}
`; + + case 'Card': + return `
${children}
`; + + case 'Divider': + return `
`; + + case 'Text': + return `

${escHtml(String(props.text ?? props.content ?? ''))}

`; + + case 'Heading': { + const lvl = Math.min(6, Math.max(1, Number(props.level ?? 2))); + return `${escHtml(String(props.text ?? props.content ?? ''))}`; + } + + case 'Image': + return `${escHtml(String(props.alt ?? ''))}`; + + case 'Markdown': { + const rendered = markdownRenderer.render(String(props.text ?? props.content ?? '')); + // Auto-upgrade ```mermaid fenced code blocks to MermaidDiagram rendering so AI + // agents that use Markdown+fence instead of MermaidDiagram are handled gracefully + const withMermaid = rendered.replace( + /
([\/\s\S]*?)<\/code><\/pre>/g,
+                (_, escapedSrc) =>
+                    `
Mermaid Diagram
Diagram source
${escapedSrc}
` + ); + return `
${withMermaid}
`; + } + + case 'CodeBlock': { + // Fix: schema says prop is "code"; also accept "content" as fallback + const codeContent = String(props.code ?? props.content ?? ''); + const lang = String(props.language ?? 'text'); + // Auto-upgrade CodeBlock with language=mermaid to MermaidDiagram rendering + if (lang === 'mermaid') { + return `
Mermaid Diagram
Diagram source
${escHtml(codeContent)}
`; + } + return `
${escHtml(codeContent)}
`; + } + + case 'Button': + return ``; + + case 'TextField': + return ``; + + case 'Checkbox': { + const checked = props.checked ? ' checked' : ''; + return ``; + } + + case 'Select': { + const opts = Array.isArray(props.options) ? props.options : []; + const currentValue = String(props.value ?? ''); + const placeholder = typeof props.placeholder === 'string' ? props.placeholder : undefined; + const optsHtml = opts + .map((o: unknown) => { + const isObjectOption = o !== null && typeof o === 'object' && !Array.isArray(o); + const label = isObjectOption + ? String((o as Record).label ?? (o as Record).value ?? '') + : String(o); + const value = isObjectOption + ? String((o as Record).value ?? (o as Record).label ?? '') + : String(o); + const selected = value === currentValue ? ' selected' : ''; + return ``; + }) + .join(''); + const placeholderHtml = placeholder + ? `` + : ''; + return ``; + } + + case 'MermaidDiagram': + // Accept multiple prop names for flexibility: diagram, definition, source, code, text, content + const mermaidContent = String( + props.diagram ?? + props.definition ?? + props.source ?? + props.code ?? + props.text ?? + props.content ?? + '' + ); + return `
${escHtml(String(props.label ?? 'Mermaid Diagram'))}
Diagram source
${escHtml(mermaidContent)}
`; + + case 'ProgressBar': { + const val = Number(props.value ?? 0); + const max = Number(props.max ?? 100); + const percent = max > 0 ? Math.round((val / max) * 100) : 0; + const progressLabel = typeof props.label === 'string' ? props.label : ''; + const showValue = props.showValue !== false; + return `
${escHtml(progressLabel)}${showValue ? `${escHtml(String(percent))}%` : ''}
${percent}%
`; + } + + case 'Badge': { + const VALID_BADGE_VARIANTS = new Set(['info', 'success', 'warning', 'error', 'default', 'primary', 'secondary']); + const variantStr = typeof props.variant === 'string' && VALID_BADGE_VARIANTS.has(props.variant) + ? props.variant + : 'default'; + const badgeVariant = ` a2ui-badge-${variantStr}`; + return `${escHtml(String(props.label ?? ''))}`; + } + + case 'Table': { + const columns = Array.isArray(props.columns) ? props.columns : []; + const data = Array.isArray(props.data) ? props.data : []; + + const validColumns = columns.filter((c): c is Record => + c !== null && typeof c === 'object' && !Array.isArray(c) && typeof (c as Record).key === 'string' && ((c as Record).key as string).length > 0 + ); + if (validColumns.length === 0 && columns.length > 0) { + return `
Table columns missing required "key" property
`; + } + + // Build header row + const headerHtml = columns + .map((col: unknown) => { + const colObj = col !== null && typeof col === 'object' && !Array.isArray(col) + ? col as Record + : null; + if (!colObj) return ''; + const label = typeof colObj.label === 'string' ? colObj.label : ''; + return `${escHtml(label)}`; + }) + .join(''); + + // Build data rows + const rowsHtml = data + .map((row: unknown) => { + const rowObj = row !== null && typeof row === 'object' && !Array.isArray(row) + ? row as Record + : null; + if (!rowObj) return ''; + const cellsHtml = columns + .map((col: unknown) => { + const colObj = col !== null && typeof col === 'object' && !Array.isArray(col) + ? col as Record + : null; + if (!colObj || typeof colObj.key !== 'string') return ''; + const cellValue = rowObj[colObj.key]; + return `${escHtml(String(cellValue ?? ''))}`; + }) + .join(''); + return `${cellsHtml}`; + }) + .join(''); + + return `${headerHtml}${rowsHtml}
`; + } + + case 'Tabs': { + const tabs = Array.isArray(props.tabs) ? props.tabs : []; + const activeTab = typeof props.activeTab === 'string' ? props.activeTab : ''; + + // Build tab buttons + const buttonsHtml = tabs + .map((tab: unknown) => { + const tabObj = tab !== null && typeof tab === 'object' && !Array.isArray(tab) + ? tab as Record + : null; + if (!tabObj || typeof tabObj.id !== 'string' || typeof tabObj.label !== 'string') { + return ''; + } + const isActive = tabObj.id === activeTab; + return ``; + }) + .join(''); + + // Create tab panels - each panel gets children that reference it + // For now, render all children in panels and let webview JS handle switching + const tabPanelsHtml = tabs + .map((tab: unknown) => { + const tabObj = tab !== null && typeof tab === 'object' && !Array.isArray(tab) + ? tab as Record + : null; + if (!tabObj || typeof tabObj.id !== 'string') { + return ''; + } + const tabId = tabObj.id; + const isActive = tabId === activeTab; + // All children go into all panels for now + // In a real implementation, we'd map specific children to specific tabs + return `
${children}
`; + }) + .join(''); + + return `
${buttonsHtml}
${tabPanelsHtml}
`; + } + + case 'Toggle': { + const toggleLabel = typeof props.label === 'string' ? props.label : ''; + const checked = Boolean(props.checked); + const toggleDisabled = props.disabled === true ? ' disabled' : ''; + + return ``; + } + + case 'HTML': { + // Validate html is present + const htmlContent = typeof props.html === 'string' ? props.html : ''; + if (!htmlContent) { + throw new RendererError( + 'HTML component requires an "html" prop' + ); + } + + // Sanitize HTML to prevent XSS + const cleanHtml = sanitizeHTML(htmlContent); + + const cssContent = typeof props.css === 'string' ? props.css : ''; + const useSandbox = props.sandbox === true; + + if (useSandbox) { + // Use iframe with srcdoc for sandboxed rendering + const escapedHtml = escHtml(cleanHtml); + // Note: allow-same-origin is intentionally omitted – combining it + // with allow-scripts lets scripts remove the sandbox attribute. + return ``; + } else { + // Direct rendering with scoped styles + // Parse CSS and filter unsafe properties + const cleanCss = parseDeclarativeStyle(cssContent); + return `
+ ${cleanCss ? `` : ''} + ${cleanHtml} +
`; + } + } + + case 'BarChart': { + const data = parseChartData<{label?: string, value?: number}>(props.data); + const title = typeof props.title === 'string' ? props.title : ''; + const color = typeof props.color === 'string' && isValidCssColor(props.color) ? props.color : '#4CAF50'; + const horizontal = props.horizontal === true; + const showValues = props.showValues === true; + + if (data.length === 0) { + return `
No data provided
`; + } + if (data.length > CHART_LIMITS.HARD_LIMIT) { + return `
Chart data exceeds maximum of ${CHART_LIMITS.HARD_LIMIT} points (got ${data.length}). Consider aggregating your data.
`; + } + const sizeWarning = data.length > CHART_LIMITS.SOFT_LIMIT + ? `` + : ''; + + const allValues = data.map(d => toFiniteNum(d.value)); + const maxValue = Math.max(0, ...allValues); + const minValue = Math.min(0, ...allValues); + const range = maxValue - minValue || 1; + + let svgContent = ''; + + if (title) { + svgContent += `${escHtml(title)}`; + } + + // Render bars + // Coordinate space: viewBox "0 0 600 200" + data.forEach((item, index) => { + const label = typeof item.label === 'string' ? item.label : `Item ${index + 1}`; + const value = toFiniteNum(item.value); + const percent = (value - minValue) / range * 100; + + if (horizontal) { + const y = 20 + index * (160 / data.length); + svgContent += ` + + ${escHtml(label)} + + ${showValues ? `${value}` : ''} + + `; + } else { + const x = 30 + index * (540 / data.length); + const barHeight = (value - minValue) / range * 140; + const y = 180 - barHeight; + svgContent += ` + + + ${showValues ? `${value}` : ''} + ${escHtml(label)} + + `; + } + }); + + return `${sizeWarning}
+ + ${svgContent} + +
`; + } + + case 'LineChart': { + const data = parseChartData<{label?: string, value?: number}>(props.data); + const title = typeof props.title === 'string' ? props.title : ''; + const color = typeof props.color === 'string' && isValidCssColor(props.color) ? props.color : '#2196F3'; + const showPoints = props.showPoints !== false; // default true + const smooth = props.smooth === true; + + if (data.length === 0) { + return `
No data provided
`; + } + if (data.length > CHART_LIMITS.HARD_LIMIT) { + return `
Chart data exceeds maximum of ${CHART_LIMITS.HARD_LIMIT} points (got ${data.length}). Consider aggregating your data.
`; + } + const sizeWarning = data.length > CHART_LIMITS.SOFT_LIMIT + ? `` + : ''; + + const maxValue = Math.max(...data.map(d => toFiniteNum(d.value))); + const minValue = Math.min(0, ...data.map(d => toFiniteNum(d.value))); + const range = maxValue - minValue || 1; + + let svgContent = ''; + + if (title) { + svgContent += `${escHtml(title)}`; + } + + // Coordinate space: viewBox "0 0 600 200" + // Generate point objects + const pointObjects = data.map((item, index) => { + const x = 30 + (index / (data.length - 1 || 1)) * 540; + const value = toFiniteNum(item.value); + const y = 170 - ((value - minValue) / range) * 150; + return { x, y }; + }); + + // Draw line + if (smooth) { + const pathD = generateSmoothPath(pointObjects); + svgContent += ``; + } else { + const points = pointObjects.map(p => `${p.x},${p.y}`).join(' '); + svgContent += ``; + } + + // Draw points if enabled + if (showPoints) { + data.forEach((item, index) => { + const x = 30 + (index / (data.length - 1 || 1)) * 540; + const value = toFiniteNum(item.value); + const y = 170 - ((value - minValue) / range) * 150; + const label = typeof item.label === 'string' ? item.label : `Item ${index + 1}`; + svgContent += ` + + ${escHtml(label)} + `; + }); + } + + return `${sizeWarning}
+ + ${svgContent} + +
`; + } + + case 'PieChart': { + const data = parseChartData<{label?: string, value?: number, color?: string}>(props.data); + const title = typeof props.title === 'string' ? props.title : ''; + const doughnut = props.doughnut === true; + const showLegend = props.showLegend !== false; // default true + + if (data.length === 0) { + return `
No data provided
`; + } + if (data.length > CHART_LIMITS.HARD_LIMIT) { + return `
Chart data exceeds maximum of ${CHART_LIMITS.HARD_LIMIT} points (got ${data.length}). Consider aggregating your data.
`; + } + const sizeWarning = data.length > CHART_LIMITS.SOFT_LIMIT + ? `` + : ''; + + const total = data.reduce((sum, item) => sum + toFiniteNum(item.value), 0); + const defaultColors = ['#4CAF50', '#2196F3', '#FF9800', '#F44336', '#9C27B0', '#00BCD4']; + + let svgContent = ''; + + if (title) { + svgContent += `${escHtml(title)}`; + } + + let currentAngle = 0; + // Coordinate space: viewBox "0 0 200 200" — preserveAspectRatio="xMidYMid meet" keeps circles round + const cx = 70, cy = 104, r = 50; + + data.forEach((item, index) => { + const value = toFiniteNum(item.value); + const label = typeof item.label === 'string' ? item.label : `Item ${index + 1}`; + const color = typeof item.color === 'string' && isValidCssColor(item.color) ? item.color : defaultColors[index % defaultColors.length]; + const percent = total > 0 ? (value / total) * 100 : 0; + const angle = total > 0 ? (value / total) * 360 : 0; + + // Calculate slice path + const startAngle = (currentAngle - 90) * Math.PI / 180; + const endAngle = (currentAngle + angle - 90) * Math.PI / 180; + + const x1 = cx + r * Math.cos(startAngle); + const y1 = cy + r * Math.sin(startAngle); + const x2 = cx + r * Math.cos(endAngle); + const y2 = cy + r * Math.sin(endAngle); + + const largeArc = angle > 180 ? 1 : 0; + + let pathData: string; + if (doughnut) { + const innerR = r * 0.6; + const ix1 = cx + innerR * Math.cos(startAngle); + const iy1 = cy + innerR * Math.sin(startAngle); + const ix2 = cx + innerR * Math.cos(endAngle); + const iy2 = cy + innerR * Math.sin(endAngle); + pathData = `M ${ix1} ${iy1} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix1} ${iy1} Z`; + } else { + pathData = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`; + } + + svgContent += ` + ${escHtml(label)}: ${value} (${percent.toFixed(1)}%) + `; + + // Add legend on the right side + if (showLegend) { + const legendY = 40 + index * 14; + svgContent += ` + + + ${escHtml(label)} (${percent.toFixed(0)}%) + + `; + } + + currentAngle += angle; + }); + + return `${sizeWarning}
+ + ${svgContent} + +
`; + } + + default: + return ''; + } +} + +/** + * Converts a flat component list (A2UISurface) into an HTML string. + * Validates component types against the catalog; throws RendererError on failure. + * Root components are those without a parentId; nesting is determined by parentId adjacency. + * + * Pass an optional `droppedMapOut` Map to collect CSS properties dropped by the style whitelist. + * The map is keyed by component id; values are arrays of dropped property names. + */ +export function renderSurface(surface: A2UISurface, droppedMapOut?: Map): string { + const data = surface.dataModel ?? {}; + + // Build lookup maps from the flat array + const componentMap = new Map>(); + const childrenMap = new Map(); // parentId -> ordered child ids + const predicateMap = new Map(); + + for (const entry of surface.components) { + componentMap.set(entry.id, entry.component); + if (entry.parentId !== undefined) { + if (!childrenMap.has(entry.parentId)) { + childrenMap.set(entry.parentId, []); + } + childrenMap.get(entry.parentId)!.push(entry.id); + } + if (entry.visibleIf !== undefined || entry.enabledIf !== undefined) { + predicateMap.set(entry.id, { visibleIf: entry.visibleIf, enabledIf: entry.enabledIf }); + } + } + + // Validate all component types upfront + for (const entry of surface.components) { + const type = extractComponentType(entry.component); + if (typeof type !== 'string' || !isAllowedComponentType(type)) { + throw new RendererError( + `Unsupported component type: ${String(type)} (id: ${entry.id})`, + ); + } + } + + function renderComponent(id: string, scopeData: unknown = data): string { + const component = componentMap.get(id); + if (!component) { + throw new RendererError(`Component not found: ${id}`); + } + + const type = extractComponentType(component); + const props = resolveProps(extractComponentProps(component), scopeData, data); + const explicitChildIds = (() => { + if (typeof props.child === 'string') { + return [props.child]; + } + + const children = props.children; + if (!children || typeof children !== 'object' || Array.isArray(children)) { + return childrenMap.get(id) ?? []; + } + + const explicitList = (children as Record).explicitList; + if (Array.isArray(explicitList)) { + return explicitList.filter((childId): childId is string => typeof childId === 'string'); + } + + return childrenMap.get(id) ?? []; + })(); + const templateItems = resolveTemplateItems(props.children, scopeData); + const childrenHtml = [ + ...explicitChildIds.map((childId) => renderComponent(childId, scopeData)), + ...templateItems.map(({ componentId, itemData }) => renderComponent(componentId, itemData)), + ].join(''); + + const predicateMeta = predicateMap.get(id); + const reactivityAttrs = buildReactivityAttrs(id, type, predicateMeta); + + const html = renderTag(type, id, props, childrenHtml, droppedMapOut); + return injectReactivityAttrs(html, `id="${escHtml(id)}"`, reactivityAttrs); + } + + // Render all root components (those with no parentId) in declaration order + const roots = surface.components + .filter((e) => e.parentId === undefined) + .map((e) => e.id); + + return roots.map((id) => renderComponent(id)).join(''); +} + +/** + * Validate predicates and build a space-prefixed data-* attribute string. + * Throws RendererError for invalid shapes or enabledIf on non-interactive types. + */ +function buildReactivityAttrs( + id: string, + type: string, + meta: { visibleIf?: unknown; enabledIf?: unknown } | undefined, +): string { + if (!meta) { + return ''; + } + + let attrs = ''; + + if (meta.visibleIf !== undefined) { + try { + const pred = parsePredicate(meta.visibleIf); + attrs += ` data-visible-if="${escHtml(serializePredicate(pred))}"`; + } catch (err) { + throw new RendererError( + `Invalid visibleIf predicate for component "${id}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (meta.enabledIf !== undefined) { + if (!INTERACTIVE_COMPONENT_TYPES.has(type)) { + throw new RendererError( + `enabledIf is only supported on interactive components (Button, TextField, Checkbox, Select). ` + + `Component "${id}" has type "${type}".`, + ); + } + try { + const pred = parsePredicate(meta.enabledIf); + attrs += ` data-enabled-if="${escHtml(serializePredicate(pred))}"`; + } catch (err) { + throw new RendererError( + `Invalid enabledIf predicate for component "${id}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + return attrs; +} + +/** + * Inject reactivity data-* attributes immediately after the id attribute + * of the component root element. No-op when attrs is empty. + */ +function injectReactivityAttrs(html: string, idAttr: string, attrs: string): string { + if (!attrs) { + return html; + } + const idx = html.indexOf(idAttr); + if (idx === -1) { + return html; + } + const insertAt = idx + idAttr.length; + return html.slice(0, insertAt) + attrs + html.slice(insertAt); +} diff --git a/src/a2ui/types.test.ts b/src/a2ui/types.test.ts new file mode 100644 index 0000000..0a384aa --- /dev/null +++ b/src/a2ui/types.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { A2UIComponentType } from './types'; + +describe('A2UI Types – Chart Components', () => { + it('should include BarChart in A2UIComponentType union', async () => { + // This test verifies that BarChart is a valid component type + // This will FAIL until we add BarChart to the type union + const validTypes: A2UIComponentType[] = [ + 'BarChart', + 'LineChart', + 'PieChart', + ]; + assert.ok(validTypes.length === 3, 'Expected 3 chart types'); + }); + + it('should have MermaidDiagram type with proper documentation', async () => { + // This test verifies MermaidDiagram is still available + const mermaidType: A2UIComponentType = 'MermaidDiagram'; + assert.strictEqual(mermaidType, 'MermaidDiagram'); + }); +}); diff --git a/src/a2ui/types.ts b/src/a2ui/types.ts new file mode 100644 index 0000000..c54167e --- /dev/null +++ b/src/a2ui/types.ts @@ -0,0 +1,124 @@ +// A2UI Protocol Types – Phase 2 A2UI surface system + +import type { A2UIReport } from './engine'; + +/** + * Mermaid diagram component for charts and graphs + * + * Accepts multiple prop names for the diagram content: `definition`, `text`, `content`, `source`, `code`, or `diagram`. + * + * @example + * // Pie chart (using 'definition' prop) + * { type: 'MermaidDiagram', props: { definition: 'pie title Data\n"A": 70\n"B": 30' } } + * + * @example + * // Flowchart (using 'text' prop) + * { type: 'MermaidDiagram', props: { text: 'graph TD\nA[Start] --> B[End]' } } + * + * @example + * // Gantt chart (using 'content' prop) + * { type: 'MermaidDiagram', props: { content: 'gantt\n title Project\n dateFormat YYYY-MM-DD\n section Phase 1\n Task 1 :2024-01-01, 30d' } } + */ +export type MermaidDiagramComponent = { + type: 'MermaidDiagram'; + props: { + /** Mermaid diagram definition. Multiple prop names accepted: definition, text, content, source, code, diagram */ + definition?: string; + text?: string; + content?: string; + source?: string; + code?: string; + diagram?: string; + /** Optional label for the diagram */ + label?: string; + }; +}; + +export type A2UIComponentType = + | 'Row' + | 'Column' + | 'Card' + | 'Divider' + | 'Text' + | 'Heading' + | 'Image' + | 'Markdown' + | 'CodeBlock' + | 'Button' + | 'TextField' + | 'Checkbox' + | 'Select' + | 'MermaidDiagram' + | 'ProgressBar' + | 'Badge' + | 'Table' + | 'Tabs' + | 'Toggle' + | 'HTML' + | 'BarChart' + | 'LineChart' + | 'PieChart'; + +export interface A2UIComponent { + id: string; + component: Record; + parentId?: string; + /** Declarative predicate controlling visibility. Validated and emitted as data-visible-if. */ + visibleIf?: unknown; + /** Declarative predicate controlling enabled state. Only valid on interactive components. */ + enabledIf?: unknown; +} + +export type A2UIDataModel = Record; + +export interface A2UIRenderIssue { + source: 'renderer' | 'webview'; + message: string; + componentId?: string; +} + +/** + * Records CSS properties that were silently dropped by the style whitelist. + * Returned in the render_ui result as `droppedStyles` to help agents + * identify when to use the HTML component instead. + */ +export interface DroppedStyleEntry { + componentId: string; + properties: string[]; +} + +export interface A2UIUserAction { + name: string; + data: Record; +} + +export interface A2UISurface { + surfaceId?: string; + title?: string; + components: A2UIComponent[]; + dataModel?: A2UIDataModel; + a2uiReport?: A2UIReport; + /** When true the panel shows a "Generating…" loading indicator at the bottom. Dismiss with append_ui(finalize:true). */ + streaming?: boolean; +} + +export interface RenderUIInput extends A2UISurface { + waitForAction?: boolean; +} + +export interface RenderUIToolResult { + surfaceId: string; + rendered: boolean; + deleted?: boolean; + renderErrors?: A2UIRenderIssue[]; + userAction?: { + name: string; + data: Record; + }; + /** + * CSS properties that were silently dropped because they are not on the + * style whitelist. If entries are present, consider using the HTML component + * type for sections that need those properties. + */ + droppedStyles?: DroppedStyleEntry[]; +} diff --git a/src/a2ui/webview.test.ts b/src/a2ui/webview.test.ts new file mode 100644 index 0000000..6d24f7e --- /dev/null +++ b/src/a2ui/webview.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for A2UI webview browser-side reactivity logic (Phase 2, Slice 2). + * + * Pure helpers are tested without any browser runtime. + * DOM-dependent helpers are tested with jsdom. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +// --------------------------------------------------------------------------- +// evaluatePredicate – pure tests (no DOM) +// --------------------------------------------------------------------------- + +describe('evaluatePredicate', () => { + it('evaluates equals – matching value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'status', equals: 'active' }, { status: 'active' }), true); + }); + + it('evaluates equals – non-matching value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'status', equals: 'active' }, { status: 'closed' }), false); + }); + + it('evaluates equals – field absent (undefined) does not equal a value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'x', equals: 'y' }, {}), false); + }); + + it('evaluates notEquals – non-matching value (returns true)', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'status', notEquals: 'closed' }, { status: 'open' }), true); + }); + + it('evaluates notEquals – matching value (returns false)', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'status', notEquals: 'closed' }, { status: 'closed' }), false); + }); + + it('evaluates isTruthy – truthy value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'approved', isTruthy: true }, { approved: true }), true); + assert.strictEqual(evaluatePredicate({ field: 'name', isTruthy: true }, { name: 'Alice' }), true); + }); + + it('evaluates isTruthy – falsy value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'approved', isTruthy: true }, { approved: false }), false); + assert.strictEqual(evaluatePredicate({ field: 'name', isTruthy: true }, { name: '' }), false); + }); + + it('evaluates isTruthy – absent field is falsy', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'absent', isTruthy: true }, {}), false); + }); + + it('evaluates isFalsy – falsy value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'loading', isFalsy: true }, { loading: false }), true); + assert.strictEqual(evaluatePredicate({ field: 'msg', isFalsy: true }, { msg: '' }), true); + }); + + it('evaluates isFalsy – truthy value', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ field: 'loading', isFalsy: true }, { loading: true }), false); + }); + + it('evaluates all – all true', async () => { + const { evaluatePredicate } = await import('./reactivity'); + const result = evaluatePredicate( + { all: [{ field: 'a', isTruthy: true }, { field: 'b', equals: 1 }] }, + { a: true, b: 1 }, + ); + assert.strictEqual(result, true); + }); + + it('evaluates all – one false makes all false', async () => { + const { evaluatePredicate } = await import('./reactivity'); + const result = evaluatePredicate( + { all: [{ field: 'a', isTruthy: true }, { field: 'b', equals: 1 }] }, + { a: true, b: 2 }, + ); + assert.strictEqual(result, false); + }); + + it('evaluates any – at least one true', async () => { + const { evaluatePredicate } = await import('./reactivity'); + const result = evaluatePredicate( + { any: [{ field: 'a', isTruthy: true }, { field: 'b', equals: 1 }] }, + { a: false, b: 1 }, + ); + assert.strictEqual(result, true); + }); + + it('evaluates any – none true', async () => { + const { evaluatePredicate } = await import('./reactivity'); + const result = evaluatePredicate( + { any: [{ field: 'a', isTruthy: true }, { field: 'b', equals: 1 }] }, + { a: false, b: 2 }, + ); + assert.strictEqual(result, false); + }); + + it('evaluates deeply nested combinators', async () => { + const { evaluatePredicate } = await import('./reactivity'); + const result = evaluatePredicate( + { all: [{ any: [{ field: 'a', isTruthy: true }, { field: 'b', isTruthy: true }] }, { field: 'c', equals: 'go' }] }, + { a: false, b: true, c: 'go' }, + ); + assert.strictEqual(result, true); + }); + + it('empty all combinator returns true', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ all: [] }, {}), true); + }); + + it('empty any combinator returns false', async () => { + const { evaluatePredicate } = await import('./reactivity'); + assert.strictEqual(evaluatePredicate({ any: [] }, {}), false); + }); + + it('throws on malformed field predicates with multiple conditions', async () => { + const { evaluatePredicate } = await import('./reactivity'); + const malformed = { field: 'status', equals: 'active', notEquals: 'closed' } as unknown as Parameters[0]; + assert.throws(() => evaluatePredicate(malformed, { status: 'active' }), /exactly one condition/); + }); +}); + +// --------------------------------------------------------------------------- +// DOM helpers – helpers tested with jsdom +// --------------------------------------------------------------------------- + +function makeDoc(html: string): Document { + const dom = new JSDOM(`${html}`); + return dom.window.document; +} + +/** Escape a string for safe use inside a double-quoted HTML attribute. */ +function escAttr(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"'); +} + +describe('collectAllFieldState', () => { + it('collects text input values', async () => { + const { collectAllFieldState } = await import('./webview'); + const doc = makeDoc(''); + assert.deepStrictEqual(collectAllFieldState(doc), { name: 'Alice' }); + }); + + it('collects checkbox values as booleans', async () => { + const { collectAllFieldState } = await import('./webview'); + const doc = makeDoc(''); + assert.deepStrictEqual(collectAllFieldState(doc), { accepted: true }); + }); + + it('collects unchecked checkbox as false', async () => { + const { collectAllFieldState } = await import('./webview'); + const doc = makeDoc(''); + assert.deepStrictEqual(collectAllFieldState(doc), { accepted: false }); + }); + + it('collects select values', async () => { + const { collectAllFieldState } = await import('./webview'); + const doc = makeDoc(''); + assert.deepStrictEqual(collectAllFieldState(doc), { role: 'admin' }); + }); + + it('includes hidden fields (for reactivity evaluation)', async () => { + const { collectAllFieldState } = await import('./webview'); + const doc = makeDoc('
'); + const state = collectAllFieldState(doc); + assert.strictEqual(state['secret'], 'hidden-val'); + }); + + it('includes disabled fields (for reactivity evaluation)', async () => { + const { collectAllFieldState } = await import('./webview'); + const doc = makeDoc(''); + const state = collectAllFieldState(doc); + assert.strictEqual(state['disabledField'], 'still-here'); + }); +}); + +describe('collectSubmittableFormData', () => { + it('collects normal fields', async () => { + const { collectSubmittableFormData } = await import('./webview'); + const doc = makeDoc(''); + assert.deepStrictEqual(collectSubmittableFormData(doc), { name: 'Bob' }); + }); + + it('excludes fields inside a hidden component root', async () => { + const { collectSubmittableFormData } = await import('./webview'); + const doc = makeDoc('
'); + const data = collectSubmittableFormData(doc); + assert.strictEqual('hidden' in data, false); + assert.strictEqual(data['visible'], 'y'); + }); + + it('excludes disabled fields', async () => { + const { collectSubmittableFormData } = await import('./webview'); + const doc = makeDoc(''); + const data = collectSubmittableFormData(doc); + assert.strictEqual(data['active'], 'yes'); + assert.strictEqual('locked' in data, false); + }); + + it('includes fields that are inside a non-hidden component root', async () => { + const { collectSubmittableFormData } = await import('./webview'); + const doc = makeDoc('
'); + assert.deepStrictEqual(collectSubmittableFormData(doc), { present: 'here' }); + }); +}); + +describe('validateRequiredFields', () => { + it('returns true when all visible required fields are filled', async () => { + const { validateRequiredFields } = await import('./webview'); + const doc = makeDoc(''); + assert.strictEqual(validateRequiredFields(doc), true); + }); + + it('returns false when a visible required field is empty', async () => { + const { validateRequiredFields } = await import('./webview'); + const doc = makeDoc(''); + assert.strictEqual(validateRequiredFields(doc), false); + }); + + it('skips hidden required fields (does not block submission)', async () => { + const { validateRequiredFields } = await import('./webview'); + const doc = makeDoc( + '
', + ); + assert.strictEqual(validateRequiredFields(doc), true); + }); + + it('skips disabled required fields', async () => { + const { validateRequiredFields } = await import('./webview'); + const doc = makeDoc(''); + assert.strictEqual(validateRequiredFields(doc), true); + }); + + it('returns false when a required checkbox is unchecked', async () => { + const { validateRequiredFields } = await import('./webview'); + const doc = makeDoc(''); + assert.strictEqual(validateRequiredFields(doc), false); + }); + + it('skips hidden required checkbox', async () => { + const { validateRequiredFields } = await import('./webview'); + const doc = makeDoc( + '
', + ); + assert.strictEqual(validateRequiredFields(doc), true); + }); +}); + +describe('applyReactivity', () => { + it('hides a component when visibleIf evaluates to false', async () => { + const { applyReactivity } = await import('./webview'); + const predicate = escAttr(JSON.stringify({ field: 'show', isTruthy: true })); + const doc = makeDoc( + `
` + + ``, + ); + applyReactivity(doc); + const comp = doc.getElementById('comp1') as HTMLElement; + assert.strictEqual(comp.hidden, true); + assert.ok(comp.hasAttribute('data-reactive-hidden')); + }); + + it('shows a component when visibleIf evaluates to true', async () => { + const { applyReactivity } = await import('./webview'); + const predicate = escAttr(JSON.stringify({ field: 'show', isTruthy: true })); + const doc = makeDoc( + `
` + + ``, + ); + applyReactivity(doc); + const comp = doc.getElementById('comp1') as HTMLElement; + assert.strictEqual(comp.hidden, false); + assert.ok(!comp.hasAttribute('data-reactive-hidden')); + }); + + it('disables an interactive element when enabledIf evaluates to false', async () => { + const { applyReactivity } = await import('./webview'); + const predicate = escAttr(JSON.stringify({ field: 'canSubmit', equals: 'yes' })); + const doc = makeDoc( + `` + + ``, + ); + applyReactivity(doc); + const btn = doc.getElementById('btn1') as HTMLButtonElement; + assert.strictEqual(btn.disabled, true); + }); + + it('enables an interactive element when enabledIf evaluates to true', async () => { + const { applyReactivity } = await import('./webview'); + const predicate = escAttr(JSON.stringify({ field: 'canSubmit', equals: 'yes' })); + const doc = makeDoc( + `` + + ``, + ); + applyReactivity(doc); + const btn = doc.getElementById('btn1') as HTMLButtonElement; + assert.strictEqual(btn.disabled, false); + }); + + it('disables input inside a label when enabledIf evaluates to false', async () => { + const { applyReactivity } = await import('./webview'); + const predicate = escAttr(JSON.stringify({ field: 'toggle', isTruthy: true })); + const doc = makeDoc( + `` + + ``, + ); + applyReactivity(doc); + const input = doc.querySelector('[data-field="tf1"]') as HTMLInputElement; + assert.strictEqual(input.disabled, true); + }); + + it('reports malformed data-visible-if attributes as webview issues', async () => { + const { applyReactivity } = await import('./webview'); + const doc = makeDoc('
'); + const originalError = console.error; + const errors: unknown[][] = []; + console.error = (...args: unknown[]) => { + errors.push(args); + }; + + try { + const issues = applyReactivity(doc); + assert.strictEqual(issues.length, 1); + assert.strictEqual(issues[0]?.source, 'webview'); + assert.strictEqual(issues[0]?.componentId, 'bad'); + assert.match(issues[0]?.message ?? '', /Invalid data-visible-if predicate/); + assert.strictEqual(doc.getElementById('bad')?.getAttribute('data-visible-if-error'), issues[0]?.message); + assert.strictEqual(errors.length, 1); + } finally { + console.error = originalError; + } + }); + + it('reports malformed data-enabled-if attributes as webview issues', async () => { + const { applyReactivity } = await import('./webview'); + const doc = makeDoc(''); + const originalError = console.error; + const errors: unknown[][] = []; + console.error = (...args: unknown[]) => { + errors.push(args); + }; + + try { + const issues = applyReactivity(doc); + assert.strictEqual(issues.length, 1); + assert.strictEqual(issues[0]?.source, 'webview'); + assert.strictEqual(issues[0]?.componentId, 'bad'); + assert.match(issues[0]?.message ?? '', /Invalid data-enabled-if predicate/); + assert.strictEqual(doc.getElementById('bad')?.getAttribute('data-enabled-if-error'), issues[0]?.message); + assert.strictEqual(errors.length, 1); + } finally { + console.error = originalError; + } + }); +}); + +describe('attachActionHandlers', () => { + it('re-runs reactivity on input events from data-field elements', async () => { + const { applyReactivity, attachActionHandlers } = await import('./webview'); + const predicate = escAttr(JSON.stringify({ field: 'canSubmit', equals: 'yes' })); + const doc = makeDoc( + `` + + ``, + ); + const button = doc.getElementById('btn1') as HTMLButtonElement; + const field = doc.querySelector('[data-field="canSubmit"]') as HTMLInputElement; + const view = doc.defaultView; + assert.ok(view); + + applyReactivity(doc); + assert.strictEqual(button.disabled, true); + + attachActionHandlers(doc, { postMessage: () => undefined }); + field.value = 'yes'; + field.dispatchEvent(new view.Event('input', { bubbles: true })); + + assert.strictEqual(button.disabled, false); + }); + + it('posts only submittable field data on button click', async () => { + const { applyReactivity, attachActionHandlers } = await import('./webview'); + const visibleIf = escAttr(JSON.stringify({ field: 'showHidden', isTruthy: true })); + const doc = makeDoc( + `` + + `` + + `` + + `
` + + ``, + ); + const messages: unknown[] = []; + const view = doc.defaultView; + assert.ok(view); + + applyReactivity(doc); + attachActionHandlers(doc, { + postMessage: (message) => { + messages.push(message); + }, + }); + + const button = doc.getElementById('submit') as HTMLButtonElement; + button.dispatchEvent(new view.MouseEvent('click', { bubbles: true })); + + assert.deepStrictEqual(messages, [{ + type: 'userAction', + name: 'submit', + data: { + name: 'Alice', + showHidden: '', + }, + }]); + }); +}); diff --git a/src/a2ui/webview.ts b/src/a2ui/webview.ts new file mode 100644 index 0000000..96e891a --- /dev/null +++ b/src/a2ui/webview.ts @@ -0,0 +1,321 @@ +import mermaid from 'mermaid'; +import { parsePredicate, evaluatePredicate } from './reactivity'; +import type { A2UIPredicate } from './reactivity'; +import type { A2UIRenderIssue } from './types'; + +declare function acquireVsCodeApi(): { + postMessage(message: unknown): void; +}; + +type FormFieldElement = HTMLInputElement | HTMLSelectElement; +type PredicateAttributeName = 'data-visible-if' | 'data-enabled-if'; +type VsCodeApi = { postMessage(message: unknown): void }; + +// --------------------------------------------------------------------------- +// Pure DOM helpers (accept `doc` for testability) +// --------------------------------------------------------------------------- + +function isCheckboxElement(doc: Document, element: Element): element is HTMLInputElement { + const view = doc.defaultView; + return Boolean(view && element instanceof view.HTMLInputElement && element.type === 'checkbox'); +} + +function isDetailsElement(doc: Document, element: Element | null): element is HTMLDetailsElement { + const view = doc.defaultView; + return Boolean(view && element instanceof view.HTMLDetailsElement); +} + +function isDomElement(doc: Document, value: unknown): value is Element { + const view = doc.defaultView; + return Boolean(view && value instanceof view.Element); +} + +function getPredicateErrorAttribute(attributeName: PredicateAttributeName): string { + return attributeName === 'data-visible-if' ? 'data-visible-if-error' : 'data-enabled-if-error'; +} + +function createPredicateIssue(rootEl: HTMLElement, attributeName: PredicateAttributeName, error: unknown): A2UIRenderIssue { + const componentId = rootEl.id || undefined; + const suffix = componentId ? ` on component "${componentId}"` : ''; + const detail = error instanceof Error ? error.message : String(error); + return { + source: 'webview', + componentId, + message: `Invalid ${attributeName} predicate${suffix}: ${detail}`, + }; +} + +function parsePredicateAttribute( + rootEl: HTMLElement, + attributeName: PredicateAttributeName, + issues: A2UIRenderIssue[], +): A2UIPredicate | undefined { + const raw = rootEl.getAttribute(attributeName); + if (!raw) { + rootEl.removeAttribute(getPredicateErrorAttribute(attributeName)); + return undefined; + } + + try { + const predicate = parsePredicate(JSON.parse(raw)); + rootEl.removeAttribute(getPredicateErrorAttribute(attributeName)); + return predicate; + } catch (error) { + const issue = createPredicateIssue(rootEl, attributeName, error); + rootEl.setAttribute(getPredicateErrorAttribute(attributeName), issue.message); + issues.push(issue); + console.error(`[A2UI] ${issue.message}`, error); + return undefined; + } +} + +/** + * Collect ALL field values (including hidden/disabled) for reactivity evaluation. + * This is deliberately inclusive so predicate logic has full field state. + */ +export function collectAllFieldState(doc: Document): Record { + const data: Record = {}; + doc.querySelectorAll('[data-field]').forEach((element) => { + const field = element.dataset['field']; + if (!field) return; + + if (isCheckboxElement(doc, element)) { + data[field] = element.checked; + return; + } + + data[field] = element.value; + }); + return data; +} + +/** + * Returns true when the element is inside a reactivity-hidden component root. + * Uses the `data-reactive-hidden` marker attribute set by `applyReactivity`. + */ +export function isFieldHidden(element: Element): boolean { + return element.closest('[data-reactive-hidden]') !== null; +} + +/** + * Collect field values for submission, excluding fields that are hidden or disabled. + */ +export function collectSubmittableFormData(doc: Document): Record { + const data: Record = {}; + doc.querySelectorAll('[data-field]').forEach((element) => { + const field = element.dataset['field']; + if (!field) return; + if (isFieldHidden(element) || element.disabled) return; + + if (isCheckboxElement(doc, element)) { + data[field] = element.checked; + return; + } + + data[field] = element.value; + }); + return data; +} + +/** + * Clear any previously appended validation error markers. + */ +function clearValidationErrors(doc: Document): void { + doc.querySelectorAll('.a2ui-field, .a2ui-checkbox-label').forEach((container) => { + container.classList.remove('a2ui-invalid'); + }); + doc.querySelectorAll('.a2ui-field-error').forEach((el) => el.remove()); +} + +function appendValidationError(container: Element, message: string): void { + container.classList.add('a2ui-invalid'); + const error = container.ownerDocument.createElement('span'); + error.className = 'a2ui-field-error'; + error.textContent = message; + container.appendChild(error); +} + +/** + * Validate required fields, skipping those that are hidden or disabled. + * Returns true when all visible/enabled required fields pass validation. + */ +export function validateRequiredFields(doc: Document): boolean { + clearValidationErrors(doc); + + let isValid = true; + doc.querySelectorAll('[data-field][required]').forEach((element) => { + if (isFieldHidden(element) || element.disabled) return; + + const container = element.closest('.a2ui-field, .a2ui-checkbox-label'); + if (!container) return; + + if (isCheckboxElement(doc, element)) { + if (!element.checked) { + appendValidationError(container, 'This field is required.'); + isValid = false; + } + return; + } + + if (!element.value.trim()) { + appendValidationError(container, 'This field is required.'); + isValid = false; + } + }); + + return isValid; +} + +/** + * Evaluate all `data-visible-if` and `data-enabled-if` predicates and update + * component visibility / interactive-element enabled state accordingly. + * + * Visibility is controlled by the `hidden` attribute + `data-reactive-hidden` + * marker on the component root element (the one carrying `data-visible-if`). + * + * Enabled state is controlled by the `disabled` property on the interactive + * element: for Button that is the root itself; for TextField/Checkbox/Select + * it is the child element carrying `data-field`. + */ +export function applyReactivity(doc: Document): A2UIRenderIssue[] { + const fieldState = collectAllFieldState(doc); + const issues: A2UIRenderIssue[] = []; + + // visibleIf + doc.querySelectorAll('[data-visible-if]').forEach((rootEl) => { + const predicate = parsePredicateAttribute(rootEl, 'data-visible-if', issues); + if (!predicate) return; + + const visible = evaluatePredicate(predicate, fieldState); + rootEl.hidden = !visible; + if (!visible) { + rootEl.setAttribute('data-reactive-hidden', ''); + } else { + rootEl.removeAttribute('data-reactive-hidden'); + } + }); + + // enabledIf + doc.querySelectorAll('[data-enabled-if]').forEach((rootEl) => { + const predicate = parsePredicateAttribute(rootEl, 'data-enabled-if', issues); + if (!predicate) return; + + const enabled = evaluatePredicate(predicate, fieldState); + // Interactive element is either the root itself (button) or a child with data-field. + const interactive = (rootEl.querySelector('[data-field]') ?? rootEl) as HTMLButtonElement | HTMLInputElement | HTMLSelectElement; + interactive.disabled = !enabled; + }); + + return issues; +} + +// --------------------------------------------------------------------------- +// Mermaid rendering (browser-only) +// --------------------------------------------------------------------------- + +async function renderMermaidDiagrams(doc: Document): Promise { + // Check if mermaid is available + if (typeof mermaid === 'undefined') { + console.error('[A2UI] Mermaid library not loaded'); + return; + } + + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'neutral', + logLevel: 'fatal', + }); + + const diagrams = Array.from(doc.querySelectorAll('.a2ui-mermaid')); + console.log('[A2UI] Found Mermaid diagrams:', diagrams.length); + + await Promise.all(diagrams.map(async (diagram, index) => { + const target = diagram.querySelector('.a2ui-mermaid-target'); + const source = diagram.querySelector('.a2ui-mermaid-source'); + const details = diagram.querySelector('.a2ui-mermaid-details'); + if (!target || !source) { + console.warn('[A2UI] Mermaid diagram missing target or source element'); + return; + } + + const definition = source.textContent ?? ''; + if (!definition.trim()) { + console.warn('[A2UI] Mermaid diagram has empty definition'); + return; + } + + console.log('[A2UI] Rendering Mermaid diagram:', index, definition.substring(0, 50)); + + try { + const { svg } = await mermaid.render(`a2ui_mermaid_${index}`, definition); + target.innerHTML = svg; + diagram.dataset['rendered'] = 'true'; + console.log('[A2UI] Mermaid diagram rendered successfully:', index); + } catch (error) { + console.error('[A2UI] Failed to render Mermaid diagram:', error); + target.innerHTML = `
Failed to render Mermaid diagram: ${error instanceof Error ? error.message : String(error)}
`; + if (isDetailsElement(doc, details)) { + (details as HTMLDetailsElement).open = true; + } + diagram.dataset['rendered'] = 'error'; + } + })); +} + +// --------------------------------------------------------------------------- +// Action handlers (browser-only) +// --------------------------------------------------------------------------- + +export function attachActionHandlers(doc: Document, vsCodeApi: VsCodeApi): void { + doc.addEventListener('click', (event) => { + const button = isDomElement(doc, event.target) + ? event.target.closest('button.a2ui-button') + : null; + if (!button || button.disabled) return; + + const action = button.dataset['action']; + if (!action) return; + + if (!validateRequiredFields(doc)) return; + + vsCodeApi.postMessage({ + type: 'userAction', + name: action, + data: collectSubmittableFormData(doc), + }); + }); + + doc.addEventListener('input', (event) => { + if (isDomElement(doc, event.target) && event.target.closest('[data-field]')) { + applyReactivity(doc); + } + }); + + doc.addEventListener('change', (event) => { + if (isDomElement(doc, event.target) && event.target.closest('[data-field]')) { + applyReactivity(doc); + } + }); +} + +// --------------------------------------------------------------------------- +// Browser bootstrap – guarded so module can be imported in Node tests +// --------------------------------------------------------------------------- + +if (typeof document !== 'undefined') { + const vscode = acquireVsCodeApi(); + // Wait for DOM to be ready before rendering + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + applyReactivity(document); + attachActionHandlers(document, vscode); + void renderMermaidDiagrams(document); + }); + } else { + // DOM is already ready + applyReactivity(document); + attachActionHandlers(document, vscode); + void renderMermaidDiagrams(document); + } +} diff --git a/src/extension.antigravity.ts b/src/extension.antigravity.ts index 7645916..1878be4 100644 --- a/src/extension.antigravity.ts +++ b/src/extension.antigravity.ts @@ -136,6 +136,12 @@ export async function activate(context: vscode.ExtensionContext) { } }); context.subscriptions.push(clearHistoryCommand); + + // Register command to show extension logs + const showLogsCommand = vscode.commands.registerCommand('seamless-agent.showLogs', () => { + Logger.show(); + }); + context.subscriptions.push(showLogsCommand); } // This method is called when your extension is deactivated diff --git a/src/extension.ts b/src/extension.ts index b7d83e4..3a1f0eb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -98,6 +98,12 @@ export function activate(context: vscode.ExtensionContext) { }); (context.subscriptions as unknown as Array).push(clearHistoryCommand); + // Register command to show extension logs + const showLogsCommand = vscode.commands.registerCommand('seamless-agent.showLogs', () => { + Logger.show(); + }); + (context.subscriptions as unknown as Array).push(showLogsCommand); + // Create a Chat Participant that uses our tool const handler: vscode.ChatRequestHandler = async ( request: vscode.ChatRequest, diff --git a/src/localization.ts b/src/localization.ts index 59a3ee6..1fa3dcd 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -139,11 +139,25 @@ export const strings = { get response() { return localize('detail.response'); }, get noResponse() { return localize('detail.noResponse'); }, get options() { return localize('detail.options'); }, + get detailWhiteboard() { return localize('detail.whiteboard'); }, + get detailWhiteboardContext() { return localize('detail.whiteboardContext'); }, + get detailWhiteboardCanvases() { return localize('detail.whiteboardCanvases'); }, + get detailWhiteboardSubmittedCanvases() { return localize('detail.whiteboardSubmittedCanvases'); }, + get detailWhiteboardNoCanvases() { return localize('detail.whiteboardNoCanvases'); }, + get detailWhiteboardSession() { return localize('detail.whiteboardSession'); }, + get detailWhiteboardStatus() { return localize('detail.whiteboardStatus'); }, + get detailRenderUI() { return localize('detail.renderUI'); }, + get detailRenderUISurfaceId() { return localize('detail.renderUISurfaceId'); }, + get detailRenderUIComponents() { return localize('detail.renderUIComponents'); }, + get detailRenderUIUserAction() { return localize('detail.renderUIUserAction'); }, + get detailRenderUIDismissed() { return localize('detail.renderUIDismissed'); }, // History filters get historyFilterAll() { return localize('history.filter.all'); }, get historyFilterAskUser() { return localize('history.filter.askUser'); }, get historyFilterPlanReview() { return localize('history.filter.planReview'); }, + get historyFilterWhiteboard() { return localize('history.filter.whiteboard'); }, + get historyFilterRenderUI() { return localize('history.filter.renderUI'); }, // Attachments / images get attachmentNoFilesFound() { return localize('attachment.noFilesFound'); }, @@ -178,6 +192,7 @@ export const strings = { get debugSectionAskUser() { return localize('debug.sectionAskUser'); }, get debugSectionPlanReview() { return localize('debug.sectionPlanReview'); }, get debugSectionWalkthroughReview() { return localize('debug.sectionWalkthroughReview'); }, + get debugSectionWhiteboard() { return localize('debug.sectionWhiteboard'); }, get debugMockAskUser() { return localize('debug.mockAskUser'); }, get debugMockAskUserOptions() { return localize('debug.mockAskUserOptions'); }, get debugMockAskUserMultiStep() { return localize('debug.mockAskUserMultiStep'); }, @@ -185,6 +200,31 @@ export const strings = { get debugMockAskUserDedupTest() { return localize('debug.mockAskUserDedupTest'); }, get debugMockPlanReview() { return localize('debug.mockPlanReview'); }, get debugMockWalkthroughReview() { return localize('debug.mockWalkthroughReview'); }, + get debugMockWhiteboard() { return localize('debug.mockWhiteboard'); }, + get submitted() { return localize('status.submitted'); }, + + // Whiteboard + get whiteboard() { return localize('whiteboard'); }, + get openWhiteboard() { return localize('openWhiteboard'); }, + get whiteboardContext() { return localize('whiteboardContext'); }, + get whiteboardTitle() { return localize('whiteboardTitle'); }, + get whiteboardSubmitted() { return localize('whiteboardSubmitted'); }, + get whiteboardCancelled() { return localize('whiteboardCancelled'); }, + get whiteboardNewCanvas() { return localize('whiteboardNewCanvas'); }, + get whiteboardDeleteCanvas() { return localize('whiteboardDeleteCanvas'); }, + get whiteboardSubmit() { return localize('whiteboardSubmit'); }, + get whiteboardCancel() { return localize('whiteboardCancel'); }, + get whiteboardUndo() { return localize('whiteboardUndo'); }, + get whiteboardRedo() { return localize('whiteboardRedo'); }, + get whiteboardClear() { return localize('whiteboardClear'); }, + get whiteboardToolPen() { return localize('whiteboardToolPen'); }, + get whiteboardToolHighlighter() { return localize('whiteboardToolHighlighter'); }, + get whiteboardToolRectangle() { return localize('whiteboardToolRectangle'); }, + get whiteboardToolCircle() { return localize('whiteboardToolCircle'); }, + get whiteboardToolLine() { return localize('whiteboardToolLine'); }, + get whiteboardToolArrow() { return localize('whiteboardToolArrow'); }, + get whiteboardToolText() { return localize('whiteboardToolText'); }, + get whiteboardToolEraser() { return localize('whiteboardToolEraser'); }, // Errors get noSuchInteraction() { return localize('error.noSuchInteraction'); }, diff --git a/src/logging.ts b/src/logging.ts index 32de4e0..0888b97 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,15 +1,53 @@ -import * as vscode from 'vscode'; import * as util from 'util'; +let vscode: any = null; +let outputChannel: any = null; -const outputChannel = vscode.window.createOutputChannel("Seamless Agent"); +// Lazy load vscode only when needed +const getVscode = () => { + if (!vscode) { + try { + vscode = require('vscode'); + } catch (err) { + // vscode not available (e.g., in tests) - use a no-op mock + vscode = { + window: { + createOutputChannel: () => ({ + append() { }, + appendLine() { }, + clear() { }, + show() { }, + }) + }, + LogLevel: { + Debug: 0, + Info: 1, + Warning: 2, + Error: 3 + } + }; + } + } + return vscode; +}; -const log = (level: vscode.LogLevel, ...args: any[]) => { +const getOutputChannel = (): any => { + if (!outputChannel) { + const vs = getVscode(); + outputChannel = vs.window.createOutputChannel("Seamless Agent"); + } + return outputChannel; +}; + +const log = (level: number, ...args: any[]) => { + const channel = getOutputChannel(); + const vs = getVscode(); const timestamp = new Date().toISOString(); - const logEntry = `[${timestamp}] [${vscode.LogLevel[level]}] `; - outputChannel.append(logEntry); - outputChannel.append(util.format(...args)); - outputChannel.appendLine(''); + const levelName = Object.keys(vs.LogLevel).find((key) => vs.LogLevel[key] === level) || 'INFO'; + const logEntry = `[${timestamp}] [${levelName}] `; + channel.append(logEntry); + channel.append(util.format(...args)); + channel.appendLine(''); } @@ -18,49 +56,71 @@ type LogLevelStr = 'info' | 'warn' | 'error' | 'debug'; export class Logger { static logWithLevel(level: LogLevelStr, ...args: any[]) { - let logLevel: vscode.LogLevel; + const vs = getVscode(); + let logLevel: number; switch (level) { case 'debug': - logLevel = vscode.LogLevel.Debug; + logLevel = vs.LogLevel.Debug; break; case 'warn': - logLevel = vscode.LogLevel.Warning; + logLevel = vs.LogLevel.Warning; break; case 'error': - logLevel = vscode.LogLevel.Error; + logLevel = vs.LogLevel.Error; break; default: - logLevel = vscode.LogLevel.Info; + logLevel = vs.LogLevel.Info; break } log(logLevel, ...args); } static log(...args: any[]) { - log(vscode.LogLevel.Info, ...args); + const vs = getVscode(); + log(vs.LogLevel.Info, ...args); } static debug(...args: any[]) { - log(vscode.LogLevel.Debug, ...args); + const vs = getVscode(); + log(vs.LogLevel.Debug, ...args); } static warn(...args: any[]) { - log(vscode.LogLevel.Warning, ...args); + const vs = getVscode(); + log(vs.LogLevel.Warning, ...args); } static info(...args: any[]) { - log(vscode.LogLevel.Info, ...args); + const vs = getVscode(); + log(vs.LogLevel.Info, ...args); } static error(...args: any[]) { - log(vscode.LogLevel.Error, ...args); + const vs = getVscode(); + log(vs.LogLevel.Error, ...args); } static clear() { - outputChannel.clear(); + const channel = getOutputChannel(); + channel.clear(); } static show() { - outputChannel.show(); + const channel = getOutputChannel(); + channel.show(); + } + + // Badge-specific logging with structured output + static badge(...args: any[]) { + const timestamp = new Date().toISOString(); + const vs = getVscode(); + log(vs.LogLevel.Info, `[BADGE] ${timestamp}`, ...args); + } + + static badgeDebug(...args: any[]) { + const timestamp = new Date().toISOString(); + const vs = getVscode(); + log(vs.LogLevel.Debug, `[BADGE-DEBUG] ${timestamp}`, ...args); } } + diff --git a/src/mcp/apiService.ts b/src/mcp/apiService.ts index cc4bb71..9163e95 100644 --- a/src/mcp/apiService.ts +++ b/src/mcp/apiService.ts @@ -2,8 +2,23 @@ import * as vscode from 'vscode'; import * as http from 'http'; import * as crypto from 'crypto'; import { AgentInteractionProvider } from '../webview/webviewProvider'; -import { askUser, planReview } from '../tools'; -import { PlanReviewInput, parsePlanReviewInput } from '../tools/schemas'; +import { askUser, planReview, openWhiteboard, renderUI, updateUI, appendUI, closeUI, listSurfaces } from '../tools'; +import { + PlanReviewInput, + parsePlanReviewInput, + WhiteboardInput, + parseWhiteboardInput, + RenderUIInput, + parseRenderUIInput, + UpdateUIInput, + parseUpdateUIInput, + AppendUIInput, + parseAppendUIInput, + CloseUIInput, + parseCloseUIInput, + ListSurfacesInput, + parseListSurfacesInput +} from '../tools/schemas'; import { Logger } from '../logging'; export { planReviewApproval, walkthroughReview } from '../tools/planReview'; @@ -21,11 +36,14 @@ export class ApiServiceManager { private server: http.Server | undefined; private port: number | undefined; private authToken: string | undefined; + private readonly instanceId: string; constructor( private context: vscode.ExtensionContext, private provider: AgentInteractionProvider - ) { } + ) { + this.instanceId = crypto.randomUUID(); + } async start() { try { @@ -66,6 +84,42 @@ export class ApiServiceManager { return; } + // Open whiteboard endpoint + if (url === '/open_whiteboard' && req.method === 'POST') { + await this.handleOpenWhiteboard(req, res); + return; + } + + // Render UI endpoint + if (url === '/render_ui' && req.method === 'POST') { + await this.handleRenderUI(req, res); + return; + } + + // Update UI endpoint + if (url === '/update_ui' && req.method === 'POST') { + await this.handleUpdateUI(req, res); + return; + } + + // Append UI endpoint + if (url === '/append_ui' && req.method === 'POST') { + await this.handleAppendUI(req, res); + return; + } + + // Close UI endpoint + if (url === '/close_ui' && req.method === 'POST') { + await this.handleCloseUI(req, res); + return; + } + + // List surfaces endpoint + if (url === '/list_surfaces' && req.method === 'POST') { + await this.handleListSurfaces(req, res); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); } catch (error) { @@ -87,6 +141,16 @@ export class ApiServiceManager { // Register with Antigravity using command format await this.registerWithAntigravity(); + // Write state file so a running CLI can recover the current port/token + await this.writeStateFile(); + + // Track window focus to update lastActive in the registry + vscode.window.onDidChangeWindowState((state) => { + if (state.focused) { + this.updateLastActive(); + } + }); + vscode.window.showInformationMessage( `Seamless Agent API service started on port ${this.port}` ); @@ -247,6 +311,390 @@ export class ApiServiceManager { } } + /** + * Handle POST /open_whiteboard requests + */ + private async handleOpenWhiteboard( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (!this.isAuthorized(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer' + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const contentType = req.headers['content-type']; + const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType; + if (!contentTypeValue || !contentTypeValue.toLowerCase().startsWith('application/json')) { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unsupported Media Type. Use application/json' })); + return; + } + + let body: string; + try { + body = await this.readRequestBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request body too large' })); + return; + } + + let params: WhiteboardInput; + try { + const parsed = JSON.parse(body); + params = parseWhiteboardInput(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Validation error: ${errorMessage}` })); + return; + } + + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const result = await openWhiteboard( + params, + this.context, + this.provider, + tokenSource.token + ); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + submitted: false, + canvases: [], + interactionId: '', + error: `Error: ${error}`, + })); + } finally { + tokenSource.dispose(); + } + } + + /** + * Handle POST /render_ui requests + */ + private async handleRenderUI( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (!this.isAuthorized(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer' + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const contentType = req.headers['content-type']; + const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType; + if (!contentTypeValue || !contentTypeValue.toLowerCase().startsWith('application/json')) { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unsupported Media Type. Use application/json' })); + return; + } + + let body: string; + try { + body = await this.readRequestBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request body too large' })); + return; + } + + let params: RenderUIInput; + try { + const parsed = JSON.parse(body); + params = parseRenderUIInput(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Validation error: ${errorMessage}` })); + return; + } + + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const result = await renderUI( + params, + this.context, + this.provider, + tokenSource.token + ); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + surfaceId: params.surfaceId ?? '', + rendered: false, + error: `Error: ${error}`, + })); + } finally { + tokenSource.dispose(); + } + } + + /** + * Handle POST /update_ui requests + */ + private async handleUpdateUI( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (!this.isAuthorized(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer' + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const contentType = req.headers['content-type']; + const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType; + if (!contentTypeValue || !contentTypeValue.toLowerCase().startsWith('application/json')) { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unsupported Media Type. Use application/json' })); + return; + } + + let body: string; + try { + body = await this.readRequestBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request body too large' })); + return; + } + + let params: UpdateUIInput; + try { + const parsed = JSON.parse(body); + params = parseUpdateUIInput(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Validation error: ${errorMessage}` })); + return; + } + + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const result = await updateUI(params, undefined, tokenSource.token); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + surfaceId: params.surfaceId ?? '', + applied: false, + error: `Error: ${error}`, + })); + } finally { + tokenSource.dispose(); + } + } + + /** + * Handle POST /append_ui requests + */ + private async handleAppendUI( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (!this.isAuthorized(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer' + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const contentType = req.headers['content-type']; + const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType; + if (!contentTypeValue || !contentTypeValue.toLowerCase().startsWith('application/json')) { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unsupported Media Type. Use application/json' })); + return; + } + + let body: string; + try { + body = await this.readRequestBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request body too large' })); + return; + } + + let params: AppendUIInput; + try { + const parsed = JSON.parse(body); + params = parseAppendUIInput(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Validation error: ${errorMessage}` })); + return; + } + + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const result = await appendUI(params, undefined, tokenSource.token); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + surfaceId: params.surfaceId ?? '', + applied: false, + error: `Error: ${error}`, + })); + } finally { + tokenSource.dispose(); + } + } + + /** + * Handle POST /close_ui requests + */ + private async handleCloseUI( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (!this.isAuthorized(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer' + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const contentType = req.headers['content-type']; + const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType; + if (!contentTypeValue || !contentTypeValue.toLowerCase().startsWith('application/json')) { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unsupported Media Type. Use application/json' })); + return; + } + + let body: string; + try { + body = await this.readRequestBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request body too large' })); + return; + } + + let params: CloseUIInput; + try { + const parsed = JSON.parse(body); + params = parseCloseUIInput(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Validation error: ${errorMessage}` })); + return; + } + + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const result = await closeUI(params, undefined, tokenSource.token); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + surfaceId: params.surfaceId ?? '', + closed: false, + error: `Error: ${error}`, + })); + } finally { + tokenSource.dispose(); + } + } + + /** + * Handle POST /list_surfaces requests + */ + private async handleListSurfaces( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (!this.isAuthorized(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer' + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const contentType = req.headers['content-type']; + const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType; + if (!contentTypeValue || !contentTypeValue.toLowerCase().startsWith('application/json')) { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unsupported Media Type. Use application/json' })); + return; + } + + let body: string; + try { + body = await this.readRequestBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request body too large' })); + return; + } + + let params: ListSurfacesInput; + try { + const parsed = JSON.parse(body); + params = parseListSurfacesInput(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Validation error: ${errorMessage}` })); + return; + } + + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const result = await listSurfaces(params, undefined, tokenSource.token); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + surfaces: [], + error: `Error: ${error}`, + })); + } finally { + tokenSource.dispose(); + } + } + /** * Read request body as string */ @@ -317,6 +765,7 @@ export class ApiServiceManager { async dispose() { await this.unregisterFromAntigravity(); + await this.unregisterFromStateFile(); if (this.server) { return new Promise((resolve) => { @@ -348,13 +797,109 @@ export class ApiServiceManager { }); } - private async registerWithAntigravity() { + /** + * Writes the current port and token to a well-known state file. + * The file is a registry keyed by instanceId so multiple IDE windows + * can each maintain their own entry without overwriting one another. + */ + private async writeStateFile() { if (!this.port || !this.authToken) return; const fs = await import('fs'); const path = await import('path'); const os = await import('os'); + const stateFilePath = path.join(os.homedir(), '.antigravity', 'seamless-agent-state.json'); + const stateDir = path.dirname(stateFilePath); + + try { + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + let registry: Record = {}; + if (fs.existsSync(stateFilePath)) { + try { + const content = fs.readFileSync(stateFilePath, 'utf8'); + const parsed = JSON.parse(content); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + // Detect and discard old flat format {port, token} to avoid + // backward-compat clash where readBestInstance returns stale data + if (typeof parsed.port !== 'number') { + registry = parsed; + } + } + } catch { + // Start fresh if corrupt + } + } + const now = Date.now(); + registry[this.instanceId] = { + port: this.port, + token: this.authToken, + lastActive: now, + startedAt: now + }; + fs.writeFileSync(stateFilePath, JSON.stringify(registry, null, 2), { mode: 0o600 }); + Logger.log(`State file written: ${stateFilePath} (instanceId: ${this.instanceId})`); + } catch (error) { + Logger.warn('Failed to write state file:', error); + } + } + + /** + * Removes this instance's entry from the state file registry on shutdown. + */ + private async unregisterFromStateFile() { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const stateFilePath = path.join(os.homedir(), '.antigravity', 'seamless-agent-state.json'); + + try { + if (!fs.existsSync(stateFilePath)) return; + const content = fs.readFileSync(stateFilePath, 'utf8'); + const registry = JSON.parse(content); + if (registry && typeof registry === 'object' && !Array.isArray(registry)) { + delete registry[this.instanceId]; + fs.writeFileSync(stateFilePath, JSON.stringify(registry, null, 2), { mode: 0o600 }); + Logger.log(`Removed instance ${this.instanceId} from state file registry`); + } + } catch (error) { + Logger.warn('Failed to unregister from state file:', error); + } + } + + /** + * Updates lastActive timestamp for this instance in the registry. + * Called when the IDE window receives focus. + */ + private async updateLastActive() { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const stateFilePath = path.join(os.homedir(), '.antigravity', 'seamless-agent-state.json'); + + try { + if (!fs.existsSync(stateFilePath)) return; + const content = fs.readFileSync(stateFilePath, 'utf8'); + const registry = JSON.parse(content); + if (registry && typeof registry === 'object' && !Array.isArray(registry) && registry[this.instanceId]) { + registry[this.instanceId].lastActive = Date.now(); + fs.writeFileSync(stateFilePath, JSON.stringify(registry, null, 2), { mode: 0o600 }); + } + } catch { + // Best-effort; don't log noise on every focus event + } + } + + private async registerWithAntigravity() { + + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + const mcpConfigPath = path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'); // Get the path to the bundled CLI script in dist/ @@ -386,11 +931,11 @@ export class ApiServiceManager { // This matches the standard MCP server configuration pattern config.mcpServers['seamless-agent'] = { command: 'node', - args: [cliScriptPath, '--port', String(this.port), '--token', this.authToken] + args: [cliScriptPath] }; fs.writeFileSync(mcpConfigPath, JSON.stringify(config, null, 2)); - Logger.log(`Registered with Antigravity: command=node, args=[${cliScriptPath}, --port, ${this.port}]`); + Logger.log(`Registered with Antigravity: command=node, args=[${cliScriptPath}] (port/token resolved from state registry at runtime)`); } catch (error) { Logger.error('Failed to register MCP server in mcp_config.json:', error); diff --git a/src/mcp/mcpServer.test.ts b/src/mcp/mcpServer.test.ts new file mode 100644 index 0000000..1e1cf85 --- /dev/null +++ b/src/mcp/mcpServer.test.ts @@ -0,0 +1,767 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { z } from 'zod'; +import { createRequire } from 'node:module'; + +import { RenderUIInputSchema, WhiteboardInputSchema, UpdateUIInputSchema, AppendUIInputSchema, CloseUIInputSchema } from '../tools/schemas'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; +const modulePath = require.resolve('./mcpServer.ts'); +let originalLoad: typeof Module._load; + +type RegisteredTool = { + name: string; + config: { + inputSchema: z.ZodTypeAny; + }; + handler: (args: unknown, context: { signal?: AbortSignal }) => Promise; +}; + +function summarizeSchemaResult(schema: z.ZodTypeAny, input: unknown) { + const result = schema.safeParse(input); + if (result.success) { + return { + success: true as const, + data: result.data, + }; + } + + return { + success: false as const, + error: result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '), + }; +} + +function parseTextResult(result: unknown): unknown { + assert.ok(result && typeof result === 'object'); + const content = (result as { content?: Array<{ type?: string; text?: string }> }).content; + assert.ok(Array.isArray(content)); + assert.strictEqual(content.length, 1); + assert.strictEqual(content[0]?.type, 'text'); + assert.ok(typeof content[0]?.text === 'string'); + return JSON.parse(content[0]!.text!); +} + +beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; +}); + +afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; +}); + +async function loadHarness(options: { + openWhiteboard?: (params: unknown) => Promise; + renderUI?: (params: unknown) => Promise; + updateUI?: (params: unknown) => Promise; + appendUI?: (params: unknown) => Promise; + closeUI?: (params: unknown) => Promise; +} = {}) { + const registeredTools: RegisteredTool[] = []; + let cancellationTokenSourceConstructCount = 0; + let cancellationTokenSourceDisposeCount = 0; + + class MockMcpServer { + registerTool(name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']) { + registeredTools.push({ name, config, handler }); + } + + async connect() { + return undefined; + } + + async close() { + return undefined; + } + } + + class MockStreamableHTTPServerTransport { + constructor(_options: unknown) { } + + async handleRequest() { + return undefined; + } + } + + const httpMock = { + createServer() { + const server = { + listen(_port: number, _host: string, callback?: () => void) { + callback?.(); + }, + address() { + return { port: 43123 }; + }, + close(callback?: () => void) { + callback?.(); + }, + on() { + return server; + }, + }; + + return server; + }, + }; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + CancellationTokenSource: class { + constructor() { + cancellationTokenSourceConstructCount += 1; + } + token = { isCancellationRequested: false }; + cancel() { + this.token.isCancellationRequested = true; + } + dispose() { + cancellationTokenSourceDisposeCount += 1; + } + }, + window: { + showErrorMessage() { }, + showInformationMessage() { }, + }, + }; + } + + if (request === 'http') { + return httpMock; + } + + if (request === 'fs') { + return { + existsSync() { + return false; + }, + mkdirSync() { }, + readFileSync() { + throw new Error('not implemented'); + }, + writeFileSync() { }, + }; + } + + if (request === 'os') { + return { + homedir() { + return '/tmp'; + }, + }; + } + + if (request === 'crypto') { + return { + randomUUID() { + return 'uuid'; + }, + }; + } + + if (request === '@modelcontextprotocol/sdk/server/mcp.js') { + return { + McpServer: MockMcpServer, + }; + } + + if (request === '@modelcontextprotocol/sdk/server/streamableHttp.js') { + return { + StreamableHTTPServerTransport: MockStreamableHTTPServerTransport, + }; + } + + if (request === '../tools') { + return { + askUser: async () => ({ responded: true, response: 'ok', attachments: [] }), + openWhiteboard: options.openWhiteboard ?? (async () => ({ + submitted: false, + images: [], + interactionId: 'wb_test', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + })), + renderUI: options.renderUI ?? (async () => ({ + surfaceId: 'surface_test', + rendered: true, + })), + updateUI: options.updateUI ?? (async () => ({ + surfaceId: 'surface_test', + applied: true, + })), + appendUI: options.appendUI ?? (async () => ({ + surfaceId: 'surface_test', + applied: true, + })), + closeUI: options.closeUI ?? (async () => ({ + surfaceId: 'surface_test', + closed: true, + })), + planReviewApproval: async () => ({ status: 'approved', requiredRevisions: [], reviewId: 'review_1' }), + walkthroughReview: async () => ({ status: 'acknowledged', requiredRevisions: [], reviewId: 'review_2' }), + }; + } + + if (request === '../logging') { + return { + Logger: { + log() { }, + warn() { }, + error() { }, + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { McpServerManager } = require('./mcpServer.ts') as typeof import('./mcpServer'); + const manager = new McpServerManager({} as any, {} as any); + await manager.start(); + + const openWhiteboardTool = registeredTools.find((tool) => tool.name === 'open_whiteboard'); + assert.ok(openWhiteboardTool, 'Expected open_whiteboard MCP tool to be registered'); + const renderUITool = registeredTools.find((tool) => tool.name === 'render_ui'); + assert.ok(renderUITool, 'Expected render_ui MCP tool to be registered'); + const updateUITool = registeredTools.find((tool) => tool.name === 'update_ui'); + assert.ok(updateUITool, 'Expected update_ui MCP tool to be registered'); + const appendUITool = registeredTools.find((tool) => tool.name === 'append_ui'); + assert.ok(appendUITool, 'Expected append_ui MCP tool to be registered'); + const closeUITool = registeredTools.find((tool) => tool.name === 'close_ui'); + assert.ok(closeUITool, 'Expected close_ui MCP tool to be registered'); + + return { + openWhiteboardTool, + renderUITool, + updateUITool, + appendUITool, + closeUITool, + getCancellationTokenSourceConstructCount: () => cancellationTokenSourceConstructCount, + getCancellationTokenSourceDisposeCount: () => cancellationTokenSourceDisposeCount, + resetCancellationTokenSourceConstructCount: () => { cancellationTokenSourceConstructCount = 0; }, + resetCancellationTokenSourceDisposeCount: () => { cancellationTokenSourceDisposeCount = 0; }, + }; +} + +describe('McpServerManager open_whiteboard registration', () => { + it('accepts importImages in the MCP schema and forwards parsed image-first inputs', async () => { + const receivedCalls: unknown[] = []; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard(params) { + receivedCalls.push(params); + return { + submitted: false, + images: [], + interactionId: 'wb_imports', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + }; + }, + }); + + const importInput = { + title: 'Annotate screenshot', + context: 'Mark the risky areas.', + importImages: [ + { + uri: 'file:///tmp/mockup.png', + label: 'Mockup', + }, + ], + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, importInput), + summarizeSchemaResult(WhiteboardInputSchema, importInput), + ); + + await openWhiteboardTool.handler(importInput, {}); + + assert.deepStrictEqual(receivedCalls, [{ + ...importInput, + blankCanvas: true, + }]); + }); + + it('defaults blankCanvas to true for blank whiteboard MCP requests', async () => { + const receivedCalls: unknown[] = []; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard(params) { + receivedCalls.push(params); + return { + submitted: false, + images: [], + interactionId: 'wb_blank', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + }; + }, + }); + + const blankInput = { + title: 'Blank whiteboard', + context: 'Start from scratch.', + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, blankInput), + summarizeSchemaResult(WhiteboardInputSchema, blankInput), + ); + + await openWhiteboardTool.handler(blankInput, {}); + + assert.deepStrictEqual(receivedCalls, [{ + ...blankInput, + blankCanvas: true, + }]); + }); + + it('accepts initialCanvases in the MCP schema and forwards seeded inputs', async () => { + const receivedCalls: unknown[] = []; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard(params) { + receivedCalls.push(params); + return { + submitted: false, + images: [], + interactionId: 'wb_seeded_mcp', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + }; + }, + }); + + const seededInput = { + title: 'Seeded starter content', + initialCanvases: [ + { + name: 'Sketch', + seedElements: [ + { + type: 'text', + x: 120, + y: 80, + text: 'Hello', + }, + ], + }, + ], + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, seededInput), + summarizeSchemaResult(WhiteboardInputSchema, seededInput), + ); + + await openWhiteboardTool.handler(seededInput, {}); + + assert.deepStrictEqual(receivedCalls, [{ + ...seededInput, + blankCanvas: true, + }]); + }); + + it('rejects invalid imported-image input before calling openWhiteboard', async () => { + let openWhiteboardCalls = 0; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard() { + openWhiteboardCalls += 1; + return { + submitted: false, + images: [], + interactionId: 'wb_invalid_import', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + }; + }, + }); + + const invalidImportInput = { + title: 'Broken import', + importImages: [ + { + uri: '', + }, + ], + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, invalidImportInput), + summarizeSchemaResult(WhiteboardInputSchema, invalidImportInput), + ); + + // FIX #5: handler now returns structured error instead of throwing + const result = await openWhiteboardTool.handler(invalidImportInput, {}); + const resultText = (result as { content: Array<{ text: string }> }).content[0].text; + assert.match(resultText, /Import image uri cannot be empty/); + assert.strictEqual(openWhiteboardCalls, 0); + }); +}); + +describe('McpServerManager render_ui registration', () => { + it('accepts the flat render_ui schema and forwards parsed inputs', async () => { + const receivedCalls: unknown[] = []; + const { renderUITool } = await loadHarness({ + async renderUI(params) { + receivedCalls.push(params); + return { + surfaceId: 'surface_architecture', + rendered: true, + }; + }, + }); + + const renderInput = { + surfaceId: 'surface_architecture', + title: 'Architecture', + components: [ + { + id: 'card_1', + component: { + type: 'Card', + }, + }, + { + id: 'text_1', + parentId: 'card_1', + component: { + type: 'Text', + props: { + content: '$data.summary', + }, + }, + }, + ], + dataModel: { + summary: 'Rendered from data', + }, + }; + + assert.deepStrictEqual( + summarizeSchemaResult(renderUITool.config.inputSchema, renderInput), + summarizeSchemaResult(RenderUIInputSchema, renderInput), + ); + + await renderUITool.handler(renderInput, {}); + + assert.deepStrictEqual(receivedCalls, [{ + ...renderInput, + waitForAction: false, + enableA2UI: true, + streaming: false, + a2uiLevel: 'basic', + deleteSurface: false, + }]); + }); + + it('rejects render_ui input missing components before calling renderUI', async () => { + let renderUICalls = 0; + const { renderUITool } = await loadHarness({ + async renderUI() { + renderUICalls += 1; + return { + surfaceId: 'surface_invalid', + rendered: true, + }; + }, + }); + + const invalidInput = { + title: 'Missing components', + }; + + assert.deepStrictEqual( + summarizeSchemaResult(renderUITool.config.inputSchema, invalidInput), + summarizeSchemaResult(RenderUIInputSchema, invalidInput), + ); + + const result = await renderUITool.handler(invalidInput, {}); + const payload = parseTextResult(result) as { surfaceId: string; rendered: boolean; error?: string }; + assert.strictEqual(payload.surfaceId, ''); + assert.strictEqual(payload.rendered, false); + assert.match(payload.error ?? '', /Validation error:/); + assert.strictEqual(renderUICalls, 0); + }); +}); + +describe('McpServerManager update_ui registration', () => { + it('accepts valid update_ui input and forwards parsed params', async () => { + const receivedCalls: unknown[] = []; + const { updateUITool } = await loadHarness({ + async updateUI(params) { + receivedCalls.push(params); + return { surfaceId: 'surface_1', applied: true }; + }, + }); + + const updateInput = { + surfaceId: 'surface_1', + dataModel: { key: 'value' }, + }; + + assert.deepStrictEqual( + summarizeSchemaResult(updateUITool.config.inputSchema, updateInput), + summarizeSchemaResult(UpdateUIInputSchema, updateInput), + ); + + await updateUITool.handler(updateInput, {}); + assert.deepStrictEqual(receivedCalls, [updateInput]); + }); + + it('rejects update_ui input missing both title and dataModel', async () => { + let updateUICalls = 0; + const { updateUITool } = await loadHarness({ + async updateUI() { + updateUICalls += 1; + return { surfaceId: 'surface_invalid', applied: false }; + }, + }); + + const invalidInput = { surfaceId: 'surface_1' }; + + assert.deepStrictEqual( + summarizeSchemaResult(updateUITool.config.inputSchema, invalidInput), + summarizeSchemaResult(UpdateUIInputSchema, invalidInput), + ); + + const result = await updateUITool.handler(invalidInput, {}); + const payload = parseTextResult(result) as { surfaceId: string; applied: boolean; error?: string }; + assert.strictEqual(payload.surfaceId, 'surface_1'); + assert.strictEqual(payload.applied, false); + assert.match(payload.error ?? '', /Validation error:/); + assert.strictEqual(updateUICalls, 0); + }); +}); + +describe('McpServerManager append_ui registration', () => { + it('accepts valid append_ui input and forwards parsed params', async () => { + const receivedCalls: unknown[] = []; + const { appendUITool } = await loadHarness({ + async appendUI(params) { + receivedCalls.push(params); + return { surfaceId: 'surface_2', applied: true }; + }, + }); + + const appendInput = { + surfaceId: 'surface_2', + components: [ + { id: 'text_1', component: { type: 'Text', props: { content: 'Hello' } } }, + ], + }; + + assert.deepStrictEqual( + summarizeSchemaResult(appendUITool.config.inputSchema, appendInput), + summarizeSchemaResult(AppendUIInputSchema, appendInput), + ); + + await appendUITool.handler(appendInput, {}); + assert.deepStrictEqual(receivedCalls, [{ ...appendInput, finalize: false }]); + }); + + it('rejects append_ui input missing components before calling appendUI', async () => { + let appendUICalls = 0; + const { appendUITool } = await loadHarness({ + async appendUI() { + appendUICalls += 1; + return { surfaceId: 'surface_invalid', applied: false }; + }, + }); + + const invalidInput = { surfaceId: 'surface_2' }; + + assert.deepStrictEqual( + summarizeSchemaResult(appendUITool.config.inputSchema, invalidInput), + summarizeSchemaResult(AppendUIInputSchema, invalidInput), + ); + + const result = await appendUITool.handler(invalidInput, {}); + const payload = parseTextResult(result) as { surfaceId: string; applied: boolean; error?: string }; + assert.strictEqual(payload.surfaceId, 'surface_2'); + assert.strictEqual(payload.applied, false); + assert.match(payload.error ?? '', /Validation error:/); + assert.strictEqual(appendUICalls, 0); + }); +}); + +describe('McpServerManager close_ui registration', () => { + it('accepts valid close_ui input and forwards parsed params', async () => { + const receivedCalls: unknown[] = []; + const { closeUITool } = await loadHarness({ + async closeUI(params) { + receivedCalls.push(params); + return { surfaceId: 'surface_3', closed: true }; + }, + }); + + const closeInput = { surfaceId: 'surface_3' }; + + assert.deepStrictEqual( + summarizeSchemaResult(closeUITool.config.inputSchema, closeInput), + summarizeSchemaResult(CloseUIInputSchema, closeInput), + ); + + await closeUITool.handler(closeInput, {}); + assert.deepStrictEqual(receivedCalls, [closeInput]); + }); + + it('rejects close_ui input with empty surfaceId', async () => { + let closeUICalls = 0; + const { closeUITool } = await loadHarness({ + async closeUI() { + closeUICalls += 1; + return { surfaceId: '', closed: false }; + }, + }); + + const invalidInput = { surfaceId: '' }; + + assert.deepStrictEqual( + summarizeSchemaResult(closeUITool.config.inputSchema, invalidInput), + summarizeSchemaResult(CloseUIInputSchema, invalidInput), + ); + + const result = await closeUITool.handler(invalidInput, {}); + const payload = parseTextResult(result) as { surfaceId: string; closed: boolean; error?: string }; + assert.strictEqual(payload.surfaceId, ''); + assert.strictEqual(payload.closed, false); + assert.match(payload.error ?? '', /Validation error:/); + assert.strictEqual(closeUICalls, 0); + }); +}); + +describe('McpServerManager - CancellationTokenSource for delta tools', () => { + it('render_ui handler disposes CancellationTokenSource after success', async () => { + const { + renderUITool, + resetCancellationTokenSourceConstructCount, + resetCancellationTokenSourceDisposeCount, + getCancellationTokenSourceConstructCount, + getCancellationTokenSourceDisposeCount, + } = await loadHarness({ + async renderUI() { + return { surfaceId: 'surface_render', rendered: true }; + }, + }); + + resetCancellationTokenSourceConstructCount(); + resetCancellationTokenSourceDisposeCount(); + await renderUITool.handler({ title: 'Render', components: [{ id: 'text_1', component: { type: 'Text', props: { content: 'Hello' } } }] }, {}); + assert.strictEqual(getCancellationTokenSourceConstructCount(), 1); + assert.strictEqual(getCancellationTokenSourceDisposeCount(), 1); + }); + + it('update_ui handler constructs CancellationTokenSource and passes token', async () => { + const { + updateUITool, + resetCancellationTokenSourceConstructCount, + resetCancellationTokenSourceDisposeCount, + getCancellationTokenSourceConstructCount, + getCancellationTokenSourceDisposeCount, + } = + await loadHarness({ + async updateUI() { + return { surfaceId: 'surface_1', applied: true }; + }, + }); + + resetCancellationTokenSourceConstructCount(); + resetCancellationTokenSourceDisposeCount(); + await updateUITool.handler({ surfaceId: 'surface_1', dataModel: { key: 'value' } }, {}); + assert.strictEqual( + getCancellationTokenSourceConstructCount(), + 1, + 'update_ui must construct CancellationTokenSource to pass the token', + ); + assert.strictEqual( + getCancellationTokenSourceDisposeCount(), + 1, + 'update_ui must dispose the CancellationTokenSource after the request completes', + ); + }); + + it('append_ui handler constructs CancellationTokenSource and passes token', async () => { + const { + appendUITool, + resetCancellationTokenSourceConstructCount, + resetCancellationTokenSourceDisposeCount, + getCancellationTokenSourceConstructCount, + getCancellationTokenSourceDisposeCount, + } = + await loadHarness({ + async appendUI() { + return { surfaceId: 'surface_2', applied: true }; + }, + }); + + resetCancellationTokenSourceConstructCount(); + resetCancellationTokenSourceDisposeCount(); + await appendUITool.handler( + { + surfaceId: 'surface_2', + components: [{ id: 'text_1', component: { type: 'Text', props: { content: 'Hello' } } }], + }, + {}, + ); + assert.strictEqual( + getCancellationTokenSourceConstructCount(), + 1, + 'append_ui must construct CancellationTokenSource to pass the token', + ); + assert.strictEqual( + getCancellationTokenSourceDisposeCount(), + 1, + 'append_ui must dispose the CancellationTokenSource after the request completes', + ); + }); + + it('close_ui handler constructs CancellationTokenSource and passes token', async () => { + const { + closeUITool, + resetCancellationTokenSourceConstructCount, + resetCancellationTokenSourceDisposeCount, + getCancellationTokenSourceConstructCount, + getCancellationTokenSourceDisposeCount, + } = + await loadHarness({ + async closeUI() { + return { surfaceId: 'surface_3', closed: true }; + }, + }); + + resetCancellationTokenSourceConstructCount(); + resetCancellationTokenSourceDisposeCount(); + await closeUITool.handler({ surfaceId: 'surface_3' }, {}); + assert.strictEqual( + getCancellationTokenSourceConstructCount(), + 1, + 'close_ui must construct CancellationTokenSource to pass the token', + ); + assert.strictEqual( + getCancellationTokenSourceDisposeCount(), + 1, + 'close_ui must dispose the CancellationTokenSource after the request completes', + ); + }); + + it('update_ui disposes CancellationTokenSource after validation errors too', async () => { + const { + updateUITool, + resetCancellationTokenSourceConstructCount, + resetCancellationTokenSourceDisposeCount, + getCancellationTokenSourceConstructCount, + getCancellationTokenSourceDisposeCount, + } = await loadHarness(); + + resetCancellationTokenSourceConstructCount(); + resetCancellationTokenSourceDisposeCount(); + await updateUITool.handler({ surfaceId: 'surface_1' }, {}); + assert.strictEqual(getCancellationTokenSourceConstructCount(), 1); + assert.strictEqual(getCancellationTokenSourceDisposeCount(), 1); + }); +}); diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index 9e6ce0c..0596324 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -8,9 +8,46 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { AgentInteractionProvider } from '../webview/webviewProvider'; -import { askUser, planReviewApproval, walkthroughReview } from '../tools'; +import { askUser, openWhiteboard, planReviewApproval, walkthroughReview, renderUI, updateUI, appendUI, closeUI, listSurfaces } from '../tools'; +import { parseWhiteboardInput, parseRenderUIInput, parseUpdateUIInput, parseAppendUIInput, parseCloseUIInput, parseListSurfacesInput, WhiteboardInputSchema, RenderUIInputSchema, UpdateUIInputSchema, AppendUIInputSchema, CloseUIInputSchema, ListSurfacesInputSchema } from '../tools/schemas'; import { Logger } from '../logging'; +function createMcpTextResult(payload: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(payload) + } + ] + }; +} + +function getOptionalStringArg(args: unknown, key: string): string | undefined { + if (!args || typeof args !== 'object') { + return undefined; + } + + const value = (args as Record)[key]; + return typeof value === 'string' ? value : undefined; +} + +async function withCancellationToken( + signal: AbortSignal | undefined, + callback: (token: vscode.CancellationToken) => Promise, +): Promise { + const tokenSource = new vscode.CancellationTokenSource(); + const abortListener = () => tokenSource.cancel(); + signal?.addEventListener('abort', abortListener); + + try { + return await callback(tokenSource.token); + } finally { + signal?.removeEventListener('abort', abortListener); + tokenSource.dispose(); + } +} + export class McpServerManager { private server: http.Server | undefined; private mcpServer: McpServer | undefined; @@ -43,6 +80,10 @@ export class McpServerManager { this.mcpServer.registerTool( "ask_user", { + description: "Present a question or prompt to the user and wait for their response. Use for confirmations, decisions, and collecting user input. " + + "Provide options[] for clickable answer buttons (flat strings or {label,description} objects); use multiSelect:true when multiple selections are allowed. " + + "Returns responded:true with the user's response string, or responded:false if the dialog was dismissed. " + + "MANDATORY in this workspace: call ask_user for every reply, response, or summary.", inputSchema: z.object({ question: z.string().describe("The question or prompt to display to the user for confirmation"), title: z.string().optional().describe("Optional custom title for the confirmation dialog"), @@ -70,44 +111,48 @@ export class McpServerManager { }) }, async (args: any, { signal }: { signal?: AbortSignal }) => { - // Convert MCP cancellation token to VS Code cancellation token const tokenSource = new vscode.CancellationTokenSource(); if (signal) { signal.onabort = () => tokenSource.cancel(); } - // Validate args if (!args || typeof args !== 'object' || !('question' in args)) { throw new Error('Invalid arguments: question is required'); } - const result = await askUser( - { - question: String(args.question), - title: args.title ? String(args.title) : undefined, - agentName: args.agentName ? String(args.agentName) : undefined, - options: args.options ?? undefined, - multiSelect: args.multiSelect ? Boolean(args.multiSelect) : false - }, - this.provider, - tokenSource.token - ); - - const appendText = vscode.workspace.getConfiguration('seamless-agent').get('askUserAppendText', ''); - - return { - content: [ + try { + const result = await askUser( { - type: "text" as const, - text: JSON.stringify(result) + question: String(args.question), + title: args.title ? String(args.title) : undefined, + agentName: args.agentName ? String(args.agentName) : undefined, + options: args.options ?? undefined, + multiSelect: args.multiSelect ? Boolean(args.multiSelect) : false }, - // Append user-configured text as a separate content part - ...(appendText ? [{ - type: "text" as const, - text: appendText - }] : []) - ] - }; + this.provider, + tokenSource.token + ); + + const appendText = vscode.workspace.getConfiguration('seamless-agent').get('askUserAppendText', ''); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result) + }, + ...(appendText ? [{ + type: "text" as const, + text: appendText + }] : []) + ] + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createMcpTextResult({ error: `Validation failed: ${message}` }); + } finally { + tokenSource.dispose(); + } } ); @@ -115,6 +160,10 @@ export class McpServerManager { this.mcpServer.registerTool( "plan_review", { + description: "Present a Markdown implementation plan to the user for approval and wait for their decision. " + + "Returns status:'approved' (proceed), 'recreateWithChanges' (revise and resubmit via plan_review again with requiredRevisions applied), " + + "or 'cancelled'. Use for multi-step or non-trivial tasks before executing. " + + "For step-by-step guides use walkthrough_review instead.", inputSchema: z.object({ plan: z.string().describe("The detailed plan in Markdown format to present to the user for review"), title: z.string().optional().describe("Optional title for the review panel"), @@ -122,36 +171,86 @@ export class McpServerManager { }) }, async (args: any, { signal }: { signal?: AbortSignal }) => { - // Convert MCP cancellation token to VS Code cancellation token const tokenSource = new vscode.CancellationTokenSource(); if (signal) { signal.onabort = () => tokenSource.cancel(); } + try { + // Validate args + if (!args || typeof args !== 'object' || !('plan' in args)) { + throw new Error('Invalid arguments: plan is required'); + } - // Validate args - if (!args || typeof args !== 'object' || !('plan' in args)) { - throw new Error('Invalid arguments: plan is required'); + const result = await planReviewApproval( + { + plan: String(args.plan), + title: args.title ? String(args.title) : undefined, + chatId: args.chatId ? String(args.chatId) : undefined + }, + this.context, + this.provider, + tokenSource.token + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result) + } + ] + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createMcpTextResult({ error: `Validation failed: ${message}` }); + } finally { + tokenSource.dispose(); } + } + ); - const result = await planReviewApproval( - { - plan: String(args.plan), - title: args.title ? String(args.title) : undefined, - chatId: args.chatId ? String(args.chatId) : undefined - }, - this.context, - this.provider, - tokenSource.token - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify(result) - } - ] - }; + // Register open_whiteboard tool + this.mcpServer.registerTool( + "open_whiteboard", + { + description: "Open an interactive whiteboard panel for the user to sketch, draw, or annotate visuals. Blocks until the user submits. " + + "Returns action ('approved' | 'recreateWithChanges' | 'cancelled'), exported images as data URIs, and an instruction string. " + + "When action==='approved': use images as confirmed visual input. " + + "When action==='recreateWithChanges': address the user's annotated feedback and call open_whiteboard again before concluding. " + + "When action==='cancelled': discard the submission. " + + "Use importImages to pre-load screenshots for annotation. " + + "Use initialCanvases[].seedElements (preferred) for agent-authored starter sketches; use fabricState only to reopen a saved session.", + inputSchema: WhiteboardInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + const tokenSource = new vscode.CancellationTokenSource(); + if (signal) { + signal.onabort = () => tokenSource.cancel(); + } + try { + const params = parseWhiteboardInput(args); + + const result = await openWhiteboard( + params, + this.context, + this.provider, + tokenSource.token + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result) + } + ] + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createMcpTextResult({ error: `Validation failed: ${message}` }); + } finally { + tokenSource.dispose(); + } } ); @@ -159,6 +258,10 @@ export class McpServerManager { this.mcpServer.registerTool( "walkthrough_review", { + description: "Present a step-by-step Markdown guide to the user in a dedicated walkthrough panel. " + + "Use for tutorials, setup instructions, and sequential how-to guides. " + + "The user can comment; address feedback by calling walkthrough_review again with the revised steps. " + + "For implementation plan approval (approve/reject workflow) use plan_review instead.", inputSchema: z.object({ plan: z.string().describe("The walkthrough content in Markdown format to present to the user"), title: z.string().optional().describe("Optional title for the walkthrough panel"), @@ -170,30 +273,200 @@ export class McpServerManager { if (signal) { signal.onabort = () => tokenSource.cancel(); } + try { + if (!args || typeof args !== 'object' || !('plan' in args)) { + throw new Error('Invalid arguments: plan is required'); + } - if (!args || typeof args !== 'object' || !('plan' in args)) { - throw new Error('Invalid arguments: plan is required'); + const result = await walkthroughReview( + { + plan: String(args.plan), + title: args.title ? String(args.title) : undefined, + chatId: args.chatId ? String(args.chatId) : undefined + }, + this.context, + this.provider, + tokenSource.token + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result) + } + ] + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createMcpTextResult({ error: `Validation failed: ${message}` }); + } finally { + tokenSource.dispose(); } + } + ); - const result = await walkthroughReview( - { - plan: String(args.plan), - title: args.title ? String(args.title) : undefined, - chatId: args.chatId ? String(args.chatId) : undefined - }, - this.context, - this.provider, - tokenSource.token - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify(result) - } - ] - }; + // Register render_ui tool (Phase 2 A2UI surface rendering) + this.mcpServer.registerTool( + "render_ui", + { + description: "Render a structured UI panel in a dedicated VS Code webview using a flat component list. " + + "Use for dashboards, forms, data displays, reports, or any rich structured UI. " + + "This tool creates the surface — call it FIRST before using append_ui, update_ui, or close_ui on the same surfaceId. " + + "Do NOT use to change only the dataModel or title of an existing surface — use update_ui instead (cheaper). " + + "STREAMING WORKFLOW: pass streaming:true to show a loading indicator, then call append_ui() one or more times to add content incrementally, ending with append_ui(finalize:true) to dismiss the indicator. " + + "FORM/BUTTON WORKFLOW: pass waitForAction:true to block until the user clicks a Button; the result will include userAction.name (the Button's action prop) and userAction.data (form field values keyed by component name props). " + + "To close a surface, call close_ui or pass deleteSurface:true with the surfaceId.", + inputSchema: RenderUIInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + return withCancellationToken(signal, async (token) => { + let params; + try { + params = parseRenderUIInput(args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return createMcpTextResult({ + surfaceId: getOptionalStringArg(args, 'surfaceId') ?? '', + rendered: false, + error: `Validation error: ${errorMessage}`, + }); + } + + const result = await renderUI( + params, + this.context, + this.provider, + token, + ); + + return createMcpTextResult(result); + }); + } + ); + + // Register update_ui tool (delta: mutate dataModel/title of an existing surface) + this.mcpServer.registerTool( + "update_ui", + { + description: "Update the dataModel and/or title of an existing surface without resending the full component tree. " + + "Use this for efficient data refresh — e.g. updating values displayed via $data.path bindings after a background fetch. " + + "dataModel is a FULL REPLACEMENT (not a patch/merge); all existing bindings are re-resolved from the new model. " + + "At least one of title or dataModel must be provided. " + + "Requires the surface to already exist (created by render_ui). " + + "If notFound:true is returned, the surface no longer exists — call list_surfaces to check active panels or call render_ui to create a new one.", + inputSchema: UpdateUIInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + return withCancellationToken(signal, async (token) => { + let params; + try { + params = parseUpdateUIInput(args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return createMcpTextResult({ + surfaceId: getOptionalStringArg(args, 'surfaceId') ?? '', + applied: false, + error: `Validation error: ${errorMessage}`, + }); + } + + const result = await updateUI(params, undefined, token); + return createMcpTextResult(result); + }); + } + ); + + // Register append_ui tool (delta: append components to an existing surface) + this.mcpServer.registerTool( + "append_ui", + { + description: "Append one or more components onto an existing surface without replacing the current component tree. " + + "Requires render_ui to have been called first with the same surfaceId. " + + "PRIMARY USE CASE — streaming/progressive UI: call render_ui(streaming:true) to show initial structure and a loading indicator, " + + "then call append_ui() one or more times to add content incrementally, " + + "and end with append_ui(finalize:true) to dismiss the indicator. " + + "parentId in appended components can reference IDs from the original render_ui components OR from previously appended components. " + + "If notFound:true is returned, the surface no longer exists — call list_surfaces to check active panels.", + inputSchema: AppendUIInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + return withCancellationToken(signal, async (token) => { + let params; + try { + params = parseAppendUIInput(args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return createMcpTextResult({ + surfaceId: getOptionalStringArg(args, 'surfaceId') ?? '', + applied: false, + error: `Validation error: ${errorMessage}`, + }); + } + + const result = await appendUI(params, undefined, token); + return createMcpTextResult(result); + }); + } + ); + + // Register close_ui tool (delta: close an existing surface panel) + this.mcpServer.registerTool( + "close_ui", + { + description: "Close an active surface panel by surfaceId. " + + "Use for cleanup when a task completes or to dismiss a stale UI panel. " + + "Equivalent to calling render_ui(deleteSurface:true, surfaceId:...) but lighter weight (no component resend). " + + "closed:true means the panel was found and closed. " + + "closed:false means the surface was not found (may already be closed or never created) — call list_surfaces to enumerate active panels.", + inputSchema: CloseUIInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + return withCancellationToken(signal, async (token) => { + let params; + try { + params = parseCloseUIInput(args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return createMcpTextResult({ + surfaceId: getOptionalStringArg(args, 'surfaceId') ?? '', + closed: false, + error: `Validation error: ${errorMessage}`, + }); + } + + const result = await closeUI(params, undefined, token); + return createMcpTextResult(result); + }); + } + ); + + // Register list_surfaces tool (delta: list all active surface panels) + this.mcpServer.registerTool( + "list_surfaces", + { + description: "List all currently active surface panels with their IDs, titles, and creation timestamps. " + + "Use as a recovery/discovery tool when you have lost track of a surfaceId. " + + "The returned surfaceId values can be passed directly to update_ui, append_ui, or close_ui. " + + "An empty surfaces array means no panels are currently open.", + inputSchema: ListSurfacesInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + return withCancellationToken(signal, async (token) => { + let params; + try { + params = parseListSurfacesInput(args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return createMcpTextResult({ + surfaces: [], + error: `Validation error: ${errorMessage}`, + }); + } + + const result = await listSurfaces(params, undefined, token); + return createMcpTextResult(result); + }); } ); diff --git a/src/mcp/zodV3Compat.test.ts b/src/mcp/zodV3Compat.test.ts new file mode 100644 index 0000000..42ed6df --- /dev/null +++ b/src/mcp/zodV3Compat.test.ts @@ -0,0 +1,104 @@ +/** + * Regression tests: zod/v3 subpath compatibility with MCP SDK schema conversion. + * + * History: The MCP CLI bundle (dist/seamless-agent-mcp.js) was failing at runtime + * with "Cannot find module 'zod/v3'" because zod was marked as external in esbuild, + * but the standalone CLI has no node_modules alongside it. + * + * Fix: esbuild CLI bundle now bundles zod (only 'vscode' is external). + * The CLI also uses `require('zod/v3')` (not `require('zod')`) so the MCP SDK + * routes schema conversion through zod-to-json-schema instead of z4mini.toJSONSchema, + * preventing a bundled-duplicate-core conflict. + * + * These tests prevent future regressions in either the subpath resolution or + * the MCP SDK schema detection logic. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const nodeRequire = createRequire(__filename); + +describe('zod/v3 subpath resolution', () => { + it('zod/v3 subpath resolves without MODULE_NOT_FOUND error', () => { + // This was failing before the esbuild external-list fix. + // Regression guard: zod package must export the ./v3 subpath with a CJS target. + let z: typeof import('zod/v3').z; + assert.doesNotThrow(() => { + ({ z } = nodeRequire('zod/v3')); + }, 'require("zod/v3") must not throw MODULE_NOT_FOUND'); + assert.ok(z!, 'z must be defined after require("zod/v3")'); + }); + + it('zod/v3 provides the core schema-builder API', () => { + const { z } = nodeRequire('zod/v3') as typeof import('zod/v3'); + assert.strictEqual(typeof z.object, 'function', 'z.object must be a function'); + assert.strictEqual(typeof z.string, 'function', 'z.string must be a function'); + assert.strictEqual(typeof z.boolean, 'function', 'z.boolean must be a function'); + assert.strictEqual(typeof z.array, 'function', 'z.array must be a function'); + assert.strictEqual(typeof z.union, 'function', 'z.union must be a function'); + assert.strictEqual(typeof z.enum, 'function', 'z.enum must be a function'); + }); +}); + +describe('zod/v3 MCP SDK schema detection', () => { + it('zod/v3 schemas do NOT have _zod marker (isZ4Schema returns false)', () => { + // The MCP SDK uses `isZ4Schema(s) = !!s._zod` to branch between: + // - zod v4 path: calls z4mini.toJSONSchema() — requires duplicate-free zod core + // - zod v3 path: calls zod-to-json-schema — safe in a bundled standalone binary + // + // If this test fails, it means zod/v3 was swapped to return a v4 schema object, + // which would break schema conversion in the bundled MCP CLI. + const { z } = nodeRequire('zod/v3') as typeof import('zod/v3'); + const schema = z.object({ question: z.string() }); + assert.ok( + !('_zod' in schema), + 'zod/v3 schema must NOT have _zod property — MCP SDK must route through zod-to-json-schema' + ); + }); + + it('zod/v4 schemas DO have _zod marker (baseline sanity check)', () => { + // Confirm the main zod export IS v4, so the subpath distinction is real. + const { z: zV4 } = nodeRequire('zod') as typeof import('zod'); + const schema = zV4.object({ x: zV4.string() }); + assert.ok('_zod' in schema, 'zod v4 schema must have _zod property'); + }); +}); + +describe('zod/v3 schema validation', () => { + it('validates a valid input correctly', () => { + const { z } = nodeRequire('zod/v3') as typeof import('zod/v3'); + const schema = z.object({ + question: z.string(), + title: z.string().optional(), + agentName: z.string().optional(), + }); + const result = schema.safeParse({ question: 'Hello?', agentName: 'TestAgent' }); + assert.ok(result.success, 'safeParse must succeed for valid input'); + assert.strictEqual(result.data?.question, 'Hello?'); + assert.strictEqual(result.data?.agentName, 'TestAgent'); + assert.strictEqual(result.data?.title, undefined); + }); + + it('rejects invalid input', () => { + const { z } = nodeRequire('zod/v3') as typeof import('zod/v3'); + const schema = z.object({ question: z.string() }); + const result = schema.safeParse({ question: 42 }); + assert.ok(!result.success, 'safeParse must fail for wrong type'); + }); + + it('handles union schemas (used in ask_user options)', () => { + const { z } = nodeRequire('zod/v3') as typeof import('zod/v3'); + const optionSchema = z.union([ + z.string(), + z.object({ label: z.string(), description: z.string().optional() }), + ]); + const schema = z.object({ options: z.array(optionSchema).optional() }); + + const result = schema.safeParse({ + options: ['Yes', { label: 'No', description: 'Decline' }], + }); + assert.ok(result.success, 'union schema with array must validate correctly'); + }); +}); diff --git a/src/storage/chatHistoryStorage.test.ts b/src/storage/chatHistoryStorage.test.ts new file mode 100644 index 0000000..676ed52 --- /dev/null +++ b/src/storage/chatHistoryStorage.test.ts @@ -0,0 +1,394 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +type MementoStore = { + get(key: string, defaultValue?: T): T; + update(key: string, value: unknown): void; +}; + +function createMemento(initialState: Record = {}): MementoStore { + const state = new Map(Object.entries(initialState)); + return { + get(key: string, defaultValue?: T): T { + return (state.has(key) ? state.get(key) : defaultValue) as T; + }, + update(key: string, value: unknown): void { + state.set(key, value); + }, + }; +} + +function createExtensionContext(initialState: Record = {}) { + return { + workspaceState: createMemento(initialState), + globalState: createMemento(), + }; +} + +describe('ChatHistoryStorage whiteboard helpers', () => { + const modulePath = require.resolve('./chatHistoryStorage.ts'); + let originalLoad: typeof Module._load; + let storageContext: 'workspace' | 'global' = 'workspace'; + let warnLog: string[]; + let errorLog: string[]; + + beforeEach(() => { + originalLoad = Module._load; + storageContext = 'workspace'; + warnLog = []; + errorLog = []; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + function loadChatHistoryStorage() { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + workspace: { + getConfiguration() { + return { + get(_key: string, defaultValue?: T) { + return (storageContext as T) ?? defaultValue; + }, + }; + }, + onDidChangeConfiguration() { + return { + dispose() { }, + }; + }, + fs: { + async writeFile() { }, + }, + workspaceFolders: undefined, + }, + window: { + showErrorMessage() { }, + showInformationMessage() { }, + }, + Uri: { + file(fsPath: string) { + return { fsPath }; + }, + joinPath(base: { fsPath: string }, ...parts: string[]) { + return { fsPath: [base.fsPath, ...parts].join('/') }; + }, + }, + }; + } + + if (request === '../config/storage') { + return { + getStorageContext() { + return storageContext; + }, + }; + } + + if (request === '../logging') { + return { + Logger: { + warn(message: string) { + warnLog.push(message); + }, + error(message: string) { + errorLog.push(message); + }, + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + return require('./chatHistoryStorage.ts') as typeof import('./chatHistoryStorage'); + } + + function createCanvas(id: string, fabricState = '{"version":"6.0.0","objects":[]}') { + return { + id, + name: `Canvas ${id}`, + fabricState, + createdAt: 100, + updatedAt: 100, + }; + } + + it('saves and reads whiteboard sessions through dedicated helpers', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new ChatHistoryStorage(context as any); + + const interactionId = await storage.saveWhiteboardInteraction({ + title: 'Architecture sketch', + context: 'Map the service boundaries', + canvases: [createCanvas('canvas_1')], + activeCanvasId: 'canvas_1', + status: 'pending', + }); + + const session = storage.getWhiteboardSession(interactionId); + + assert.equal(session?.interactionId, interactionId); + assert.equal(session?.title, 'Architecture sketch'); + assert.equal(session?.context, 'Map the service boundaries'); + assert.equal(session?.activeCanvasId, 'canvas_1'); + assert.deepEqual(session?.canvases, [createCanvas('canvas_1')]); + assert.equal(session?.status, 'pending'); + }); + + it('updates a whiteboard session without clobbering stored canvases', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new ChatHistoryStorage(context as any); + + const interactionId = await storage.saveWhiteboardInteraction({ + title: 'Architecture sketch', + canvases: [createCanvas('canvas_1')], + activeCanvasId: 'canvas_1', + status: 'pending', + }); + + await await storage.updateWhiteboardSession(interactionId, { + status: 'approved', + submittedAt: 1234, + submittedCanvases: [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + ], + }); + + const session = storage.getWhiteboardSession(interactionId); + + assert.equal(session?.status, 'approved'); + assert.equal(session?.submittedAt, 1234); + assert.deepEqual(session?.submittedCanvases, [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + ]); + assert.deepEqual(session?.canvases, [createCanvas('canvas_1')]); + assert.equal(session?.activeCanvasId, 'canvas_1'); + }); + + it('cleans up stale abandoned whiteboard sessions but preserves approved history', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { now: () => now }) as InstanceType; + + const staleInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Abandoned board', + canvases: [createCanvas('stale_canvas')], + activeCanvasId: 'stale_canvas', + status: 'pending', + }); + await storage.updateInteraction(staleInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + const submittedInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Approved board', + canvases: [createCanvas('submitted_canvas')], + activeCanvasId: 'submitted_canvas', + status: 'approved', + submittedAt: now - 1000, + submittedCanvases: [ + { + id: 'submitted_canvas', + name: 'Canvas submitted_canvas', + imageUri: 'file:///tmp/submitted.png', + }, + ], + }); + await storage.updateInteraction(submittedInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + const recentInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Recent board', + canvases: [createCanvas('recent_canvas')], + activeCanvasId: 'recent_canvas', + status: 'pending', + }); + + await storage.cleanupOldWhiteboardSessions(); + + assert.equal(storage.getInteraction(staleInteractionId), undefined); + assert.ok(storage.getInteraction(submittedInteractionId)); + assert.ok(storage.getInteraction(recentInteractionId)); + }); + + it('triggers stale-session cleanup when whiteboard storage nears the quota threshold', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { + now: () => now, + maxStorageBytes: 2400, + quotaCleanupThreshold: 0.5, + }) as InstanceType; + + const oversizedState = JSON.stringify({ + version: '6.0.0', + objects: [ + { + type: 'textbox', + text: 'x'.repeat(900), + }, + ], + }); + + const staleInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Old large board', + canvases: [createCanvas('stale_canvas', oversizedState)], + activeCanvasId: 'stale_canvas', + status: 'pending', + }); + await storage.updateInteraction(staleInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + const freshInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Fresh board', + canvases: [createCanvas('fresh_canvas')], + activeCanvasId: 'fresh_canvas', + status: 'pending', + }); + + assert.equal(storage.getInteraction(staleInteractionId), undefined); + assert.ok(storage.getInteraction(freshInteractionId)); + assert.match(warnLog.join('\n'), /quota/i); + }); + + it('refuses to persist oversized whiteboard payloads when cleanup cannot get below the storage quota', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { + maxStorageBytes: 1200, + quotaCleanupThreshold: 0.5, + }) as InstanceType; + + const baselineInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Baseline board', + canvases: [createCanvas('baseline_canvas')], + activeCanvasId: 'baseline_canvas', + status: 'pending', + }); + + const oversizedState = JSON.stringify({ + version: '6.0.0', + objects: [ + { + type: 'textbox', + text: 'y'.repeat(1500), + }, + ], + }); + + await assert.rejects( + () => storage.saveWhiteboardInteraction({ + title: 'Oversized board', + canvases: [createCanvas('oversized_canvas', oversizedState)], + activeCanvasId: 'oversized_canvas', + status: 'pending', + }), + /quota exceeded/i + ); + + assert.ok(storage.getInteraction(baselineInteractionId)); + assert.equal(storage.getInteractionsByType('whiteboard').length, 1); + assert.match(warnLog.join('\n'), /quota threshold reached/i); + assert.match(errorLog.join('\n'), /quota exceeded/i); + }); + + it('round-trips multi-canvas submissions through storage helper updates', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new ChatHistoryStorage(context as any); + + const interactionId = await storage.saveWhiteboardInteraction({ + title: 'Architecture board', + canvases: [createCanvas('canvas_1'), createCanvas('canvas_2')], + activeCanvasId: 'canvas_2', + status: 'pending', + }); + + await storage.updateWhiteboardInteraction(interactionId, { + title: 'Architecture board v2', + whiteboardSession: { + status: 'approved', + submittedAt: 5678, + submittedCanvases: [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + { + id: 'canvas_2', + name: 'Canvas canvas_2', + imageUri: 'file:///tmp/canvas-2.png', + }, + ], + }, + }); + + const interaction = storage.getInteraction(interactionId); + assert.equal(interaction?.title, 'Architecture board v2'); + assert.equal(interaction?.whiteboardSession?.status, 'approved'); + assert.equal(interaction?.whiteboardSession?.submittedAt, 5678); + assert.deepEqual(interaction?.whiteboardSession?.canvases, [createCanvas('canvas_1'), createCanvas('canvas_2')]); + assert.equal(interaction?.whiteboardSession?.activeCanvasId, 'canvas_2'); + assert.deepEqual(interaction?.whiteboardSession?.submittedCanvases, [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + { + id: 'canvas_2', + name: 'Canvas canvas_2', + imageUri: 'file:///tmp/canvas-2.png', + }, + ]); + assert.deepEqual(storage.getCompletedInteractions().map((entry) => entry.id), [interactionId]); + }); + + it('preserves cancelled whiteboard history during stale-session cleanup', async () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { now: () => now }) as InstanceType; + + const cancelledInteractionId = await storage.saveWhiteboardInteraction({ + title: 'Cancelled board', + canvases: [createCanvas('cancelled_canvas')], + activeCanvasId: 'cancelled_canvas', + status: 'cancelled', + }); + await storage.updateInteraction(cancelledInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + await storage.cleanupOldWhiteboardSessions(); + + assert.ok(storage.getInteraction(cancelledInteractionId)); + }); +}); diff --git a/src/storage/chatHistoryStorage.ts b/src/storage/chatHistoryStorage.ts index 8ea0d89..d8e6269 100644 --- a/src/storage/chatHistoryStorage.ts +++ b/src/storage/chatHistoryStorage.ts @@ -1,6 +1,18 @@ import * as vscode from 'vscode'; -import type { RequiredPlanRevisions, StoredInteraction } from '../webview/types'; +import { + isCompletedStoredInteraction, + isPendingStoredInteraction, + type RequiredPlanRevisions, + type RenderUISession, + type StoredInteraction, + type WhiteboardCanvas, + type WhiteboardSession, + type WhiteboardSessionStatus, + type WhiteboardSubmittedCanvas, +} from '../webview/types'; import { getStorageContext } from '../config/storage'; +import { Logger } from '../logging'; +import { cleanupWhiteboardTempImages } from '../whiteboard/imageCleanup'; /** * Storage keys for global state @@ -9,6 +21,32 @@ const STORAGE_KEYS = { INTERACTIONS: 'seamless-agent.interactions', }; +const DEFAULT_WHITEBOARD_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const DEFAULT_STORAGE_QUOTA_BYTES = 100 * 1024 * 1024; +const DEFAULT_STORAGE_QUOTA_THRESHOLD = 0.9; + +export interface ChatHistoryStorageOptions { + now?: () => number; + whiteboardSessionMaxAgeMs?: number; + maxStorageBytes?: number; + quotaCleanupThreshold?: number; +} + +export class StorageQuotaExceededError extends Error { + constructor(message: string) { + super(message); + this.name = 'StorageQuotaExceededError'; + } +} + + +/** + * Whiteboard sessions should be stored on StoredInteraction.whiteboardSession so + * session-scoped canvases follow the same workspace/global storage lifecycle as + * ask_user and plan_review interactions. Dedicated whiteboard save/update helpers + * will build on this shared interaction record in a later task. + */ + /** * Manages persistence of interactions * Uses VS Code's globalState for cross-session persistence @@ -17,10 +55,17 @@ const STORAGE_KEYS = { export class ChatHistoryStorage { private context: vscode.ExtensionContext; private config: vscode.WorkspaceConfiguration; + private readonly options: Required; - constructor(context: vscode.ExtensionContext) { + constructor(context: vscode.ExtensionContext, options: ChatHistoryStorageOptions = {}) { this.context = context; this.config = vscode.workspace.getConfiguration('seamless-agent'); + this.options = { + now: options.now ?? (() => Date.now()), + whiteboardSessionMaxAgeMs: options.whiteboardSessionMaxAgeMs ?? DEFAULT_WHITEBOARD_SESSION_MAX_AGE_MS, + maxStorageBytes: options.maxStorageBytes ?? DEFAULT_STORAGE_QUOTA_BYTES, + quotaCleanupThreshold: options.quotaCleanupThreshold ?? DEFAULT_STORAGE_QUOTA_THRESHOLD, + }; } // ======================== @@ -39,7 +84,7 @@ export class ChatHistoryStorage { * Get all interactions, sorted by timestamp (most recent first) */ getAllInteractions(): StoredInteraction[] { - const interactions = this.storage.get(STORAGE_KEYS.INTERACTIONS, []); + const interactions = [...this.storage.get(STORAGE_KEYS.INTERACTIONS, [])]; return interactions.sort((a, b) => b.timestamp - a.timestamp); } @@ -57,7 +102,7 @@ export class ChatHistoryStorage { **/ getPendingInteraction(interactionId: string): StoredInteraction | undefined { const interaction = this.getInteraction(interactionId); - if (interaction?.status === 'pending') { + if (interaction && isPendingStoredInteraction(interaction)) { return interaction; } } @@ -65,7 +110,7 @@ export class ChatHistoryStorage { /** * Save a new ask_user interaction */ - saveAskUserInteraction(data: { + async saveAskUserInteraction(data: { question: string; title?: string; agentName?: string; @@ -74,7 +119,7 @@ export class ChatHistoryStorage { options?: import('../webview/types').AskUserOptions; selectedOptionLabels?: Record; isDebug?: boolean; - }): string { + }): Promise { const interactionId = this.generateId('ask'); const interaction: StoredInteraction = { id: interactionId, @@ -90,21 +135,21 @@ export class ChatHistoryStorage { isDebug: data.isDebug, }; - this.saveInteraction(interaction); + await this.saveInteraction(interaction); return interactionId; } /** * Save a new plan_review interaction */ - savePlanReviewInteraction(data: { + async savePlanReviewInteraction(data: { plan: string; title?: string; mode?: 'review' | 'walkthrough'; status?: 'pending' | 'approved' | 'recreateWithChanges' | 'acknowledged' | 'closed' | 'cancelled'; requiredRevisions?: RequiredPlanRevisions[]; isDebug?: boolean; - }): string { + }): Promise { const interactionId = this.generateId('review'); const interaction: StoredInteraction = { id: interactionId, @@ -118,15 +163,89 @@ export class ChatHistoryStorage { isDebug: data.isDebug, }; - this.saveInteraction(interaction); + await this.saveInteraction(interaction); + return interactionId; + } + + /** + * Save a new whiteboard interaction + */ + async saveWhiteboardInteraction(data: { + title?: string; + context?: string; + canvases?: WhiteboardCanvas[]; + activeCanvasId?: string; + status?: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; + isDebug?: boolean; + }): Promise { + const interactionId = this.generateId('wb'); + const interaction: StoredInteraction = { + id: interactionId, + type: 'whiteboard', + timestamp: Date.now(), + title: data.title, + isDebug: data.isDebug, + whiteboardSession: { + id: interactionId, + interactionId, + context: data.context, + title: data.title, + canvases: data.canvases || [], + activeCanvasId: data.activeCanvasId, + status: data.status || 'pending', + submittedAt: data.submittedAt, + submittedCanvases: data.submittedCanvases, + }, + }; + + await this.saveInteraction(interaction); + return interactionId; + } + + /** + * Save a new renderUI interaction + */ + async saveRenderUIInteraction(data: { + title?: string; + surfaceId: string; + components?: unknown[]; + dataModel?: Record; + userAction?: { name: string; data: Record }; + dismissed?: boolean; + renderErrors?: Array<{ source: string; message: string }>; + isDebug?: boolean; + }): Promise { + const interactionId = this.generateId('ui'); + const interaction: StoredInteraction = { + id: interactionId, + type: 'renderUI', + timestamp: Date.now(), + title: data.title, + isDebug: data.isDebug, + renderUISession: { + id: interactionId, + interactionId, + title: data.title, + surfaceId: data.surfaceId, + components: data.components, + dataModel: data.dataModel, + userAction: data.userAction, + dismissed: data.dismissed ?? false, + renderErrors: data.renderErrors, + }, + }; + + await this.saveInteraction(interaction); return interactionId; } /** * Save an interaction to storage */ - private saveInteraction(interaction: StoredInteraction): void { - const interactions = this.storage.get(STORAGE_KEYS.INTERACTIONS, []); + private async saveInteraction(interaction: StoredInteraction): Promise { + const interactions = [...this.storage.get(STORAGE_KEYS.INTERACTIONS, [])]; const existingIndex = interactions.findIndex(i => i.id === interaction.id); if (existingIndex >= 0) { @@ -135,17 +254,87 @@ export class ChatHistoryStorage { interactions.push(interaction); } - this.storage.update(STORAGE_KEYS.INTERACTIONS, interactions); + this.storage.update(STORAGE_KEYS.INTERACTIONS, await this.prepareInteractionsForStorage(interactions)); } /** * Update an existing interaction */ - updateInteraction(interactionId: string, updates: Partial): void { + async updateInteraction(interactionId: string, updates: Partial): Promise { const interaction = this.getInteraction(interactionId); if (interaction) { const updated = { ...interaction, ...updates }; - this.saveInteraction(updated); + await this.saveInteraction(updated); + } + } + + /** + * Update an existing whiteboard interaction by merging whiteboard session fields. + */ + async updateWhiteboardInteraction(interactionId: string, updates: { + title?: string; + whiteboardSession?: Partial>; + }): Promise { + const interaction = this.getInteraction(interactionId); + if (!interaction) { + Logger.warn(`Cannot update missing whiteboard interaction: ${interactionId}`); + return; + } + + if (interaction.type !== 'whiteboard') { + Logger.warn(`Cannot update non-whiteboard interaction as whiteboard: ${interactionId}`); + return; + } + + const updatedSession = updates.whiteboardSession + ? { + ...(interaction.whiteboardSession || { + id: interactionId, + interactionId, + canvases: [], + status: 'pending' as const, + }), + ...updates.whiteboardSession, + } + : interaction.whiteboardSession; + + await this.saveInteraction({ + ...interaction, + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updatedSession ? { whiteboardSession: updatedSession } : {}), + }); + } + + /** + * Get a whiteboard session by interaction ID. + */ + getWhiteboardSession(interactionId: string): WhiteboardSession | undefined { + const interaction = this.getInteraction(interactionId); + if (!interaction || interaction.type !== 'whiteboard') { + return undefined; + } + + return interaction.whiteboardSession; + } + + /** + * Update the stored whiteboard session for a whiteboard interaction. + */ + updateWhiteboardSession(interactionId: string, updates: Partial): void { + this.updateWhiteboardInteraction(interactionId, { + whiteboardSession: updates, + }); + } + + /** + * Remove stale whiteboard sessions that were abandoned and never submitted. + */ + async cleanupOldWhiteboardSessions(): Promise { + const interactions = [...this.storage.get(STORAGE_KEYS.INTERACTIONS, [])]; + const cleanedInteractions = await this.pruneOldWhiteboardSessions(interactions); + + if (cleanedInteractions.length !== interactions.length) { + this.storage.update(STORAGE_KEYS.INTERACTIONS, cleanedInteractions); } } @@ -173,7 +362,7 @@ export class ChatHistoryStorage { clearAll(): void { const allInteractions = this.getAllInteractions(); // Keep only pending interactions - they should only be cancelled via command - const pendingInteractions = allInteractions.filter(i => i.status === 'pending'); + const pendingInteractions = allInteractions.filter(isPendingStoredInteraction); this.storage.update(STORAGE_KEYS.INTERACTIONS, pendingInteractions); } @@ -189,18 +378,26 @@ export class ChatHistoryStorage { .filter(i => i.type === 'plan_review' && i.status === 'pending'); } + /** + * Get all pending whiteboard interactions. + */ + getPendingWhiteboards(): StoredInteraction[] { + return this.getAllInteractions() + .filter(i => i.type === 'whiteboard' && isPendingStoredInteraction(i)); + } + /** * Get all completed interactions (not pending) */ getCompletedInteractions(): StoredInteraction[] { return this.getAllInteractions() - .filter(i => i.type === 'ask_user' || (i.type === 'plan_review' && i.status !== 'pending')); + .filter(isCompletedStoredInteraction); } /** * Get interactions by type */ - getInteractionsByType(type: 'ask_user' | 'plan_review'): StoredInteraction[] { + getInteractionsByType(type: 'ask_user' | 'plan_review' | 'whiteboard' | 'renderUI'): StoredInteraction[] { return this.getAllInteractions().filter(i => i.type === type); } @@ -283,15 +480,111 @@ export class ChatHistoryStorage { pendingReviews: this.getPendingPlanReviews().length, }; } + + private async prepareInteractionsForStorage(interactions: StoredInteraction[]): Promise { + const totalSize = this.getSerializedSize(interactions); + if (totalSize <= this.getQuotaCleanupThresholdBytes()) { + return interactions; + } + + const cleanedInteractions = await this.pruneOldWhiteboardSessions(interactions); + const removedCount = interactions.length - cleanedInteractions.length; + + if (removedCount > 0) { + Logger.warn( + `Whiteboard storage quota threshold reached (${totalSize} bytes); cleaned ${removedCount} stale session(s).` + ); + } else { + Logger.warn( + `Whiteboard storage quota threshold reached (${totalSize} bytes); no stale whiteboard sessions were eligible for cleanup.` + ); + } + + const cleanedSize = this.getSerializedSize(cleanedInteractions); + if (cleanedSize > this.options.maxStorageBytes) { + const message = `Whiteboard storage quota exceeded (${cleanedSize} bytes after cleanup; limit ${this.options.maxStorageBytes}); refusing to persist oversized payload.`; + Logger.error(message); + throw new StorageQuotaExceededError(message); + } + + return cleanedInteractions; + } + + private async pruneOldWhiteboardSessions(interactions: StoredInteraction[]): Promise { + const staleBefore = this.options.now() - this.options.whiteboardSessionMaxAgeMs; + const toRemove = interactions.filter((interaction) => + this.shouldCleanupWhiteboardInteraction(interaction, staleBefore) + ); + + // Clean up temporary images for removed whiteboard interactions + const storageUri = this.context.globalStorageUri; + if (storageUri) { + const storageRootPath = storageUri.fsPath; + const whiteboardInteractions = toRemove.filter( + (interaction): interaction is StoredInteraction & { type: 'whiteboard'; id: string } => + interaction.type === 'whiteboard' && interaction.id !== undefined + ); + + // Process cleanups in parallel for better performance + const cleanupPromises = whiteboardInteractions.map(async (interaction) => { + try { + await cleanupWhiteboardTempImages(interaction.id, storageRootPath); + } catch (error) { + Logger.error( + `Failed to cleanup whiteboard images for interaction ${interaction.id}`, + error + ); + } + }); + + // Wait for all cleanups to complete (or fail gracefully) + await Promise.allSettled(cleanupPromises); + } + + return interactions.filter((interaction) => + !this.shouldCleanupWhiteboardInteraction(interaction, staleBefore) + ); + } + + private shouldCleanupWhiteboardInteraction(interaction: StoredInteraction, staleBefore: number): boolean { + if (interaction.type !== 'whiteboard') { + return false; + } + + if (interaction.timestamp >= staleBefore) { + return false; + } + + const session = interaction.whiteboardSession; + if (!session) { + return true; + } + + if (session.status === 'approved' || session.status === 'recreateWithChanges' || session.status === 'cancelled') { + return false; + } + + return (session.submittedCanvases?.length ?? 0) === 0; + } + + private getQuotaCleanupThresholdBytes(): number { + return Math.floor(this.options.maxStorageBytes * this.options.quotaCleanupThreshold); + } + + private getSerializedSize(interactions: StoredInteraction[]): number { + return JSON.stringify(interactions).length; + } } // Singleton instance let storageInstance: ChatHistoryStorage | undefined; +let extensionContextInstance: vscode.ExtensionContext | undefined; /** * Initialize the storage with extension context */ export function initializeChatHistoryStorage(context: vscode.ExtensionContext): ChatHistoryStorage { + extensionContextInstance = context; storageInstance = new ChatHistoryStorage(context); return storageInstance; } @@ -305,3 +598,10 @@ export function getChatHistoryStorage(): ChatHistoryStorage { } return storageInstance; } + +export function getExtensionContext(): vscode.ExtensionContext { + if (!extensionContextInstance) { + throw new Error('Extension context not initialized. Call initializeChatHistoryStorage first.'); + } + return extensionContextInstance; +} diff --git a/src/tools/appendUI.test.ts b/src/tools/appendUI.test.ts new file mode 100644 index 0000000..bf7a946 --- /dev/null +++ b/src/tools/appendUI.test.ts @@ -0,0 +1,336 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ===================================================== +// append_ui schema tests +// ===================================================== + +describe('append_ui schema', () => { + it('accepts surfaceId + components', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + surfaceId: 'surf1', + components: [{ id: 'c1', component: { type: 'Text', props: { content: 'Hello' } } }], + }); + assert.ok(result.success, 'Expected valid input to parse successfully'); + if (result.success) { + assert.equal(result.data.surfaceId, 'surf1'); + assert.equal(result.data.components.length, 1); + } + }); + + it('accepts surfaceId + components + title', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + surfaceId: 'surf1', + title: 'Extended Surface', + components: [{ id: 'c1', component: { type: 'Text', props: { content: 'Hello' } } }], + }); + assert.ok(result.success, 'Expected all fields to parse successfully'); + }); + + it('rejects missing surfaceId', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + components: [{ id: 'c1', component: { type: 'Text' } }], + }); + assert.strictEqual(result.success, false); + }); + + it('rejects empty surfaceId', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + surfaceId: '', + components: [{ id: 'c1', component: { type: 'Text' } }], + }); + assert.strictEqual(result.success, false); + }); + + it('rejects empty components array', async () => { + const { AppendUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(AppendUIInputSchema, { + surfaceId: 'surf1', + components: [], + }); + assert.strictEqual(result.success, false, 'Should reject empty components'); + if (!result.success) { + assert.ok(result.error.includes('components'), `Expected error to mention components, got: ${result.error}`); + } + }); + + it('rejects missing components', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + surfaceId: 'surf1', + }); + assert.strictEqual(result.success, false); + }); + + it('rejects component with missing id', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + surfaceId: 'surf1', + components: [{ component: { type: 'Text' } }], + }); + assert.strictEqual(result.success, false); + }); + + it('preserves parentId in components', async () => { + const { AppendUIInputSchema } = await import('./schemas'); + const result = AppendUIInputSchema.safeParse({ + surfaceId: 'surf1', + components: [ + { id: 'row1', component: { type: 'Row' } }, + { id: 'text1', parentId: 'row1', component: { type: 'Text', props: { content: 'Child' } } }, + ], + }); + assert.ok(result.success); + if (result.success) { + assert.equal(result.data.components[1]?.parentId, 'row1'); + } + }); +}); + +// ===================================================== +// appendUI function tests +// ===================================================== + +describe('appendUI function', () => { + it('calls appendComponents when surface exists', async () => { + const { appendUI } = await import('./appendUI'); + + const components = [{ id: 'c1', component: { type: 'Text', props: { content: 'Extra' } } }]; + let calledWith: { surfaceId: string; components: unknown[] } | null = null; + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { return { found: true }; }, + appendComponents(surfaceId: string, comps: unknown[]) { + calledWith = { surfaceId, components: comps }; + return { found: true }; + }, + }; + + const result = await appendUI( + { surfaceId: 'surf1', components }, + { panel: mockPanel }, + ); + + assert.ok(calledWith !== null); + const recorded = calledWith as { surfaceId: string; components: unknown[] }; + assert.equal(recorded.surfaceId, 'surf1'); + assert.equal(recorded.components.length, 1); + assert.equal(result.applied, true); + assert.equal(result.surfaceId, 'surf1'); + assert.equal(result.notFound, undefined); + }); + + it('returns notFound: true when surface does not exist', async () => { + const { appendUI } = await import('./appendUI'); + + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { return { found: false }; }, + appendComponents(_surfaceId: string, _comps: unknown[]) { + return { found: false }; + }, + }; + + const result = await appendUI( + { surfaceId: 'ghost', components: [{ id: 'c1', component: { type: 'Text' } }] }, + { panel: mockPanel }, + ); + + assert.equal(result.applied, false); + assert.equal(result.notFound, true); + assert.equal(result.surfaceId, 'ghost'); + }); + + it('propagates renderErrors from panel.appendComponents', async () => { + const { appendUI } = await import('./appendUI'); + + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { return { found: true }; }, + appendComponents(_surfaceId: string, _comps: unknown[]) { + return { + found: true, + renderErrors: [{ source: 'renderer' as const, message: 'Unknown component type: Table' }], + }; + }, + }; + + const result = await appendUI( + { surfaceId: 'surf1', components: [{ id: 'c1', component: { type: 'Table' } }] }, + { panel: mockPanel }, + ); + + assert.equal(result.applied, true); + assert.ok(Array.isArray(result.renderErrors) && result.renderErrors.length === 1); + assert.equal(result.renderErrors![0].message, 'Unknown component type: Table'); + }); + + it('passes raw component objects to panel.appendComponents', async () => { + const { appendUI } = await import('./appendUI'); + + const components = [ + { id: 'c1', component: { type: 'Button', props: { label: 'OK', action: 'ok' } }, parentId: 'row1' }, + ]; + let received: unknown[] = []; + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { return { found: true }; }, + appendComponents(_surfaceId: string, comps: unknown[]) { + received = comps; + return { found: true }; + }, + }; + + await appendUI({ surfaceId: 'surf1', components }, { panel: mockPanel }); + + assert.equal(received.length, 1); + assert.deepEqual(received[0], components[0]); + }); + + it('calls updateTitle when title is provided', async () => { + const { appendUI } = await import('./appendUI'); + + const components = [{ id: 'c1', component: { type: 'Text' } }]; + let updateTitleCalledWith: { surfaceId: string; title: string } | null = null; + const mockPanel = { + updateTitle(surfaceId: string, title: string) { + updateTitleCalledWith = { surfaceId, title }; + return { found: true }; + }, + appendComponents(_surfaceId: string, _comps: unknown[]) { + return { found: true }; + }, + }; + + const result = await appendUI( + { surfaceId: 'surf1', title: 'Updated Title', components }, + { panel: mockPanel }, + ); + + assert.deepEqual(updateTitleCalledWith, { surfaceId: 'surf1', title: 'Updated Title' }); + assert.equal(result.applied, true); + }); + + it('returns notFound: true immediately when updateTitle returns found: false in combined path', async () => { + const { appendUI } = await import('./appendUI'); + + let appendComponentsCalled = false; + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { return { found: false }; }, + appendComponents(_surfaceId: string, _comps: unknown[]) { + appendComponentsCalled = true; + return { found: true }; + }, + }; + + const result = await appendUI( + { surfaceId: 'ghost', title: 'Missing', components: [{ id: 'c1', component: { type: 'Text' } }] }, + { panel: mockPanel }, + ); + + assert.equal(result.applied, false, 'applied should be false when title update fails'); + assert.equal(result.notFound, true, 'notFound should be true when title update fails'); + assert.equal(result.surfaceId, 'ghost'); + assert.equal(appendComponentsCalled, false, 'appendComponents should NOT be called when title update reports not found'); + }); + + it('merges renderErrors from updateTitle and appendComponents when title is provided', async () => { + const { appendUI } = await import('./appendUI'); + + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { + return { + found: true, + renderErrors: [{ source: 'renderer' as const, message: 'Title render error' }], + }; + }, + appendComponents(_surfaceId: string, _comps: unknown[]) { + return { + found: true, + renderErrors: [{ source: 'renderer' as const, message: 'Append render error' }], + }; + }, + }; + + const result = await appendUI( + { surfaceId: 'surf1', title: 'Updated', components: [{ id: 'c1', component: { type: 'Text' } }] }, + { panel: mockPanel }, + ); + + assert.equal(result.applied, true); + assert.ok(Array.isArray(result.renderErrors) && result.renderErrors.length === 2, + `Expected 2 merged renderErrors, got ${result.renderErrors?.length ?? 0}`); + assert.ok(result.renderErrors!.some(e => e.message === 'Title render error'), + 'Expected title render error to be present'); + assert.ok(result.renderErrors!.some(e => e.message === 'Append render error'), + 'Expected append render error to be present'); + }); + + it('does not call updateTitle when title is not provided', async () => { + const { appendUI } = await import('./appendUI'); + + const components = [{ id: 'c1', component: { type: 'Text' } }]; + let updateTitleCalled = false; + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { + updateTitleCalled = true; + return { found: true }; + }, + appendComponents(_surfaceId: string, _comps: unknown[]) { + return { found: true }; + }, + }; + + await appendUI({ surfaceId: 'surf1', components }, { panel: mockPanel }); + + assert.equal(updateTitleCalled, false, 'updateTitle should not be called when title is absent'); + }); +}); + +// ===================================================== +// appendUI cancellation tests +// ===================================================== + +describe('appendUI cancellation', () => { + it('returns applied: false without calling panel when token is pre-cancelled', async () => { + const { appendUI } = await import('./appendUI'); + + let panelCalled = false; + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { panelCalled = true; return { found: true }; }, + appendComponents(_surfaceId: string, _comps: unknown[]) { panelCalled = true; return { found: true }; }, + }; + + const cancelledToken = { isCancellationRequested: true, onCancellationRequested: () => ({ dispose: () => {} }) }; + + const result = await appendUI( + { surfaceId: 'surf1', components: [{ id: 'c1', component: { type: 'Text' } }] }, + { panel: mockPanel }, + cancelledToken as any, + ); + + assert.equal(panelCalled, false, 'Panel should not be called when token is pre-cancelled'); + assert.equal(result.applied, false); + assert.equal(result.surfaceId, 'surf1'); + }); + + it('proceeds normally when token is not cancelled', async () => { + const { appendUI } = await import('./appendUI'); + + const mockPanel = { + updateTitle(_surfaceId: string, _title: string) { return { found: true }; }, + appendComponents(_surfaceId: string, _comps: unknown[]) { return { found: true }; }, + }; + + const activeToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) }; + + const result = await appendUI( + { surfaceId: 'surf1', components: [{ id: 'c1', component: { type: 'Text' } }] }, + { panel: mockPanel }, + activeToken as any, + ); + + assert.equal(result.applied, true); + assert.equal(result.surfaceId, 'surf1'); + }); +}); diff --git a/src/tools/appendUI.ts b/src/tools/appendUI.ts new file mode 100644 index 0000000..32e7760 --- /dev/null +++ b/src/tools/appendUI.ts @@ -0,0 +1,72 @@ +import type * as vscode from 'vscode'; +import type { A2UIComponent, A2UIRenderIssue, DroppedStyleEntry } from '../a2ui/types'; +import type { AppendUIInput, AppendUIToolResult } from './schemas'; + +export interface AppendUIPanelDependency { + updateTitle(surfaceId: string, title: string): { found: boolean; renderErrors?: A2UIRenderIssue[]; droppedStyles?: DroppedStyleEntry[] }; + appendComponents( + surfaceId: string, + components: A2UIComponent[], + finalize?: boolean, + ): { found: boolean; renderErrors?: A2UIRenderIssue[]; droppedStyles?: DroppedStyleEntry[] }; +} + +export interface AppendUIDependencies { + panel: AppendUIPanelDependency; +} + +async function createDefaultDependencies(): Promise { + const { A2UIPanel } = await import('../a2ui/panel'); + return { + panel: { + updateTitle: (surfaceId, title) => + A2UIPanel.updateTitle(surfaceId, title), + appendComponents: (surfaceId, components, finalize) => + A2UIPanel.appendComponents(surfaceId, components, finalize), + }, + }; +} + +/** + * Core logic for the append_ui tool. + * Appends components to an existing surface without replacing the current component tree. + * If `title` is provided, applies it to the panel title before appending. + * If `finalize` is true, the streaming loading indicator is dismissed after appending. + */ +export async function appendUI( + params: AppendUIInput, + deps?: Partial, + token?: vscode.CancellationToken, +): Promise { + if (token?.isCancellationRequested) { + return { surfaceId: params.surfaceId, applied: false }; + } + + const defaultDeps = deps?.panel ? undefined : await createDefaultDependencies(); + const panel = deps?.panel ?? defaultDeps!.panel; + + let titleErrors: A2UIRenderIssue[] = []; + if (params.title !== undefined) { + const titleResult = panel.updateTitle(params.surfaceId, params.title); + if (!titleResult.found) { + return { surfaceId: params.surfaceId, applied: false, notFound: true }; + } + titleErrors = titleResult.renderErrors ?? []; + } + + const result = panel.appendComponents(params.surfaceId, params.components as A2UIComponent[], params.finalize); + + if (!result.found) { + return { surfaceId: params.surfaceId, applied: false, notFound: true }; + } + + const allErrors = [...titleErrors, ...(result.renderErrors ?? [])]; + const allDropped = [...(result.droppedStyles ?? [])]; + + return { + surfaceId: params.surfaceId, + applied: true, + ...(allErrors.length > 0 ? { renderErrors: allErrors } : {}), + ...(allDropped.length > 0 ? { droppedStyles: allDropped } : {}), + }; +} diff --git a/src/tools/closeUI.test.ts b/src/tools/closeUI.test.ts new file mode 100644 index 0000000..624603d --- /dev/null +++ b/src/tools/closeUI.test.ts @@ -0,0 +1,153 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ===================================================== +// close_ui schema tests +// ===================================================== + +describe('close_ui schema', () => { + it('accepts a valid surfaceId', async () => { + const { CloseUIInputSchema } = await import('./schemas'); + const result = CloseUIInputSchema.safeParse({ surfaceId: 'surf1' }); + assert.ok(result.success, 'Expected valid surfaceId to parse successfully'); + if (result.success) { + assert.equal(result.data.surfaceId, 'surf1'); + } + }); + + it('rejects missing surfaceId', async () => { + const { CloseUIInputSchema } = await import('./schemas'); + const result = CloseUIInputSchema.safeParse({}); + assert.strictEqual(result.success, false); + }); + + it('rejects empty surfaceId', async () => { + const { CloseUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(CloseUIInputSchema, { surfaceId: '' }); + assert.strictEqual(result.success, false, 'Should reject empty surfaceId'); + if (!result.success) { + assert.ok(result.error.includes('surfaceId'), `Expected error to mention surfaceId, got: ${result.error}`); + } + }); + + it('rejects extra unexpected fields gracefully (passes through with strip)', async () => { + const { CloseUIInputSchema } = await import('./schemas'); + const result = CloseUIInputSchema.safeParse({ surfaceId: 'surf1', extra: 'ignored' }); + assert.ok(result.success, 'Zod strips unknown keys by default'); + }); +}); + +// ===================================================== +// closeUI function tests +// ===================================================== + +describe('closeUI function', () => { + it('returns closed: true when panel was open', async () => { + const { closeUI } = await import('./closeUI'); + + let closedId: string | null = null; + const mockPanel = { + closeIfOpen(surfaceId: string) { + closedId = surfaceId; + return true; + }, + }; + + const result = await closeUI({ surfaceId: 'surf1' }, { panel: mockPanel }); + + assert.equal(closedId, 'surf1'); + assert.equal(result.surfaceId, 'surf1'); + assert.equal(result.closed, true); + }); + + it('returns closed: false when panel was not open', async () => { + const { closeUI } = await import('./closeUI'); + + const mockPanel = { + closeIfOpen(_surfaceId: string) { + return false; + }, + }; + + const result = await closeUI({ surfaceId: 'ghost' }, { panel: mockPanel }); + + assert.equal(result.surfaceId, 'ghost'); + assert.equal(result.closed, false); + }); + + it('awaits async closeIfOpen result', async () => { + const { closeUI } = await import('./closeUI'); + + const mockPanel = { + async closeIfOpen(_surfaceId: string): Promise { + return true; + }, + }; + + const result = await closeUI({ surfaceId: 'async-surf' }, { panel: mockPanel }); + + assert.equal(result.closed, true); + }); + + it('result contains surfaceId from input', async () => { + const { closeUI } = await import('./closeUI'); + + const mockPanel = { + closeIfOpen(_surfaceId: string) { + return false; + }, + }; + + const result = await closeUI({ surfaceId: 'my-surface' }, { panel: mockPanel }); + assert.equal(result.surfaceId, 'my-surface'); + }); +}); + +// ===================================================== +// closeUI cancellation tests +// ===================================================== + +describe('closeUI cancellation', () => { + it('returns closed: false without calling panel when token is pre-cancelled', async () => { + const { closeUI } = await import('./closeUI'); + + let panelCalled = false; + const mockPanel = { + closeIfOpen(_surfaceId: string) { + panelCalled = true; + return true; + }, + }; + + const cancelledToken = { isCancellationRequested: true, onCancellationRequested: () => ({ dispose: () => {} }) }; + + const result = await closeUI( + { surfaceId: 'surf1' }, + { panel: mockPanel }, + cancelledToken as any, + ); + + assert.equal(panelCalled, false, 'Panel should not be called when token is pre-cancelled'); + assert.equal(result.closed, false); + assert.equal(result.surfaceId, 'surf1'); + }); + + it('proceeds normally when token is not cancelled', async () => { + const { closeUI } = await import('./closeUI'); + + const mockPanel = { + closeIfOpen(_surfaceId: string) { return true; }, + }; + + const activeToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) }; + + const result = await closeUI( + { surfaceId: 'surf1' }, + { panel: mockPanel }, + activeToken as any, + ); + + assert.equal(result.closed, true); + assert.equal(result.surfaceId, 'surf1'); + }); +}); diff --git a/src/tools/closeUI.ts b/src/tools/closeUI.ts new file mode 100644 index 0000000..f5d1950 --- /dev/null +++ b/src/tools/closeUI.ts @@ -0,0 +1,40 @@ +import type * as vscode from 'vscode'; +import type { CloseUIInput, CloseUIToolResult } from './schemas'; + +export interface CloseUIPanelDependency { + closeIfOpen(surfaceId: string): boolean | Promise; +} + +export interface CloseUIDependencies { + panel: CloseUIPanelDependency; +} + +async function createDefaultDependencies(): Promise { + const { A2UIPanel } = await import('../a2ui/panel'); + return { + panel: { + closeIfOpen: (surfaceId) => A2UIPanel.closeIfOpen(surfaceId), + }, + }; +} + +/** + * Core logic for the close_ui tool. + * Closes an existing surface panel by surfaceId. + */ +export async function closeUI( + params: CloseUIInput, + deps?: Partial, + token?: vscode.CancellationToken, +): Promise { + if (token?.isCancellationRequested) { + return { surfaceId: params.surfaceId, closed: false }; + } + + const defaultDeps = deps?.panel ? undefined : await createDefaultDependencies(); + const panel = deps?.panel ?? defaultDeps!.panel; + + const closed = await panel.closeIfOpen(params.surfaceId); + + return { surfaceId: params.surfaceId, closed }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 730273e..3fbf248 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,12 @@ export * from './schemas'; // Re-export tool functions export { askUser } from './askUser'; export { planReview, planReviewApproval, walkthroughReview } from './planReview'; +export { openWhiteboard } from './openWhiteboard'; +export { renderUI } from './renderUI'; +export { updateUI } from './updateUI'; +export { appendUI } from './appendUI'; +export { closeUI } from './closeUI'; +export { listSurfaces } from './listSurfaces'; // Re-export utils export * from './utils'; @@ -16,16 +22,35 @@ export * from './utils'; // Import for internal use import { askUser } from './askUser'; import { planReviewApproval, walkthroughReview } from './planReview'; +import { openWhiteboard } from './openWhiteboard'; +import { renderUI } from './renderUI'; +import { updateUI } from './updateUI'; +import { appendUI } from './appendUI'; +import { closeUI } from './closeUI'; +import { listSurfaces } from './listSurfaces'; import { readFileAsBuffer, getImageMimeType, validateImageMagicNumber } from './utils'; +import { createWhiteboardLanguageModelResultParts } from './whiteboardToolResult'; import { AskUserInput, ApprovePlanInput, PlanReviewInput, WalkthroughReviewInput, + WhiteboardInput, + RenderUIInput, + UpdateUIInput, + AppendUIInput, + CloseUIInput, + ListSurfacesInput, parseAskUserInput, parseApprovePlanInput, parsePlanReviewInput, parseWalkthroughReviewInput, + parseWhiteboardInput, + parseRenderUIInput, + parseUpdateUIInput, + parseAppendUIInput, + parseCloseUIInput, + parseListSurfacesInput, } from './schemas'; import { Logger } from '../logging'; @@ -209,6 +234,38 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: } }); + // Register the open_whiteboard tool (standalone whiteboard) + const openWhiteboardTool = vscode.lm.registerTool('open_whiteboard', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: WhiteboardInput; + try { + params = parseWhiteboardInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + submitted: false, + canvases: [], + interactionId: '', + error: `Validation error: ${errorMessage}` + })) + ]); + } + + const result = await openWhiteboard(params, context, provider, token); + const resultParts = await createWhiteboardLanguageModelResultParts(result); + + return new vscode.LanguageModelToolResult(resultParts.map((part) => + new vscode.LanguageModelTextPart(part.value) + )); + }, + prepareInvocation(options) { + return { + invocationMessage: options.input.title || 'Open whiteboard' + }; + }, + }); + // Register the walkthrough_review tool (explicit: walkthrough review mode) const walkthroughReviewTool = vscode.lm.registerTool('walkthrough_review', { async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { @@ -244,11 +301,160 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: } }); + // Register the render_ui tool (Phase 2 A2UI surface rendering) + const renderUITool = vscode.lm.registerTool('render_ui', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: RenderUIInput; + try { + params = parseRenderUIInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + surfaceId: '', + rendered: false, + error: `Validation error: ${errorMessage}`, + })) + ]); + } + + const result = await renderUI(params, context, provider, token); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + }, + prepareInvocation(options) { + return { + invocationMessage: options.input.title || 'Render UI surface' + }; + }, + }); + + // Register the update_ui tool (delta: mutate dataModel/title of an existing surface) + const updateUITool = vscode.lm.registerTool('update_ui', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: UpdateUIInput; + try { + params = parseUpdateUIInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + surfaceId: options.input?.surfaceId ?? '', + applied: false, + error: `Validation error: ${errorMessage}`, + })) + ]); + } + + const result = await updateUI(params, undefined, token); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + }, + prepareInvocation(options) { + return { + invocationMessage: options.input.title || `Update surface ${options.input.surfaceId}` + }; + }, + }); + + // Register the append_ui tool (delta: append components to an existing surface) + const appendUITool = vscode.lm.registerTool('append_ui', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: AppendUIInput; + try { + params = parseAppendUIInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + surfaceId: options.input?.surfaceId ?? '', + applied: false, + error: `Validation error: ${errorMessage}`, + })) + ]); + } + + const result = await appendUI(params, undefined, token); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + }, + prepareInvocation(options) { + return { + invocationMessage: options.input.title || `Append to surface ${options.input.surfaceId}` + }; + }, + }); + + // Register the close_ui tool (delta: close an existing surface panel) + const closeUITool = vscode.lm.registerTool('close_ui', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: CloseUIInput; + try { + params = parseCloseUIInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + surfaceId: options.input?.surfaceId ?? '', + closed: false, + error: `Validation error: ${errorMessage}`, + })) + ]); + } + + const result = await closeUI(params, undefined, token); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + }, + prepareInvocation(options) { + return { + invocationMessage: `Close surface ${options.input.surfaceId}` + }; + }, + }); + + const listSurfacesTool = vscode.lm.registerTool('list_surfaces', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: ListSurfacesInput; + try { + params = parseListSurfacesInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + surfaces: [], + error: `Validation error: ${errorMessage}`, + })) + ]); + } + + const result = await listSurfaces(params, undefined, token); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + }, + prepareInvocation(_options) { + return { + invocationMessage: 'List all active surfaces' + }; + }, + }); + (context.subscriptions as unknown as Array).push( confirmationTool, approvePlanTool, planReviewTool, - walkthroughReviewTool + openWhiteboardTool, + walkthroughReviewTool, + renderUITool, + updateUITool, + appendUITool, + closeUITool, + listSurfacesTool, ); // Initialize chat history storage diff --git a/src/tools/listSurfaces.test.ts b/src/tools/listSurfaces.test.ts new file mode 100644 index 0000000..74ed3c3 --- /dev/null +++ b/src/tools/listSurfaces.test.ts @@ -0,0 +1,236 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ===================================================== +// list_surfaces schema tests +// ===================================================== + +describe('list_surfaces schema', () => { + it('accepts empty input (no parameters required)', async () => { + const { ListSurfacesInputSchema } = await import('./schemas'); + const result = ListSurfacesInputSchema.safeParse({}); + assert.ok(result.success, 'Expected empty input to parse successfully'); + }); + + it('rejects extra unexpected fields gracefully (passes through with strip)', async () => { + const { ListSurfacesInputSchema } = await import('./schemas'); + const result = ListSurfacesInputSchema.safeParse({ extra: 'ignored' }); + assert.ok(result.success, 'Zod strips unknown keys by default'); + }); +}); + +// ===================================================== +// listSurfaces function tests +// ===================================================== + +describe('listSurfaces function', () => { + it('returns empty array when no surfaces exist', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + return []; + }, + }; + + const result = await listSurfaces({}, { panel: mockPanel }); + + assert.equal(result.surfaces.length, 0); + assert.ok(Array.isArray(result.surfaces)); + }); + + it('returns array with one surface after creating it', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + return [ + { + surfaceId: 'surf1', + title: 'Test Surface 1', + created: new Date('2025-01-01T10:00:00Z').toISOString(), + }, + ]; + }, + }; + + const result = await listSurfaces({}, { panel: mockPanel }); + + assert.equal(result.surfaces.length, 1); + assert.equal(result.surfaces[0].surfaceId, 'surf1'); + assert.equal(result.surfaces[0].title, 'Test Surface 1'); + assert.ok(result.surfaces[0].created); + }); + + it('returns multiple surfaces after creating them', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + return [ + { + surfaceId: 'surf1', + title: 'Surface 1', + created: new Date('2025-01-01T10:00:00Z').toISOString(), + }, + { + surfaceId: 'surf2', + title: 'Surface 2', + created: new Date('2025-01-01T11:00:00Z').toISOString(), + }, + { + surfaceId: 'surf3', + title: 'Surface 3', + created: new Date('2025-01-01T12:00:00Z').toISOString(), + }, + ]; + }, + }; + + const result = await listSurfaces({}, { panel: mockPanel }); + + assert.equal(result.surfaces.length, 3); + assert.equal(result.surfaces[0].surfaceId, 'surf1'); + assert.equal(result.surfaces[1].surfaceId, 'surf2'); + assert.equal(result.surfaces[2].surfaceId, 'surf3'); + }); + + it('includes surface metadata (id, title, created timestamp)', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + return [ + { + surfaceId: 'test-surface', + title: 'Test Title', + created: new Date('2025-03-12T15:30:00Z').toISOString(), + }, + ]; + }, + }; + + const result = await listSurfaces({}, { panel: mockPanel }); + + assert.equal(result.surfaces.length, 1); + const surface = result.surfaces[0]; + assert.equal(surface.surfaceId, 'test-surface'); + assert.equal(surface.title, 'Test Title'); + assert.ok(surface.created); + assert.equal(typeof surface.created, 'string'); + }); + + it('does not include closed surfaces in the list', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + // Simulate that surf2 was closed + return [ + { + surfaceId: 'surf1', + title: 'Still Open', + created: new Date('2025-01-01T10:00:00Z').toISOString(), + }, + { + surfaceId: 'surf3', + title: 'Also Open', + created: new Date('2025-01-01T12:00:00Z').toISOString(), + }, + ]; + }, + }; + + const result = await listSurfaces({}, { panel: mockPanel }); + + assert.equal(result.surfaces.length, 2); + assert.equal(result.surfaces[0].surfaceId, 'surf1'); + assert.equal(result.surfaces[1].surfaceId, 'surf3'); + assert.ok(!result.surfaces.find((s) => s.surfaceId === 'surf2')); + }); + + it('handles surfaces with no title (undefined becomes empty string)', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + return [ + { + surfaceId: 'untitled', + title: '', // Empty string instead of undefined + created: new Date('2025-01-01T10:00:00Z').toISOString(), + }, + ]; + }, + }; + + const result = await listSurfaces({}, { panel: mockPanel }); + + assert.equal(result.surfaces.length, 1); + assert.equal(result.surfaces[0].surfaceId, 'untitled'); + // Title should be handled gracefully (empty string) + assert.equal(result.surfaces[0].title, ''); + }); +}); + +// ===================================================== +// listSurfaces cancellation tests +// ===================================================== + +describe('listSurfaces cancellation', () => { + it('returns empty array without calling panel when token is pre-cancelled', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + let panelCalled = false; + const mockPanel = { + listSurfaces() { + panelCalled = true; + return []; + }, + }; + + const cancelledToken = { + isCancellationRequested: true, + onCancellationRequested: () => ({ dispose: () => {} }), + }; + + const result = await listSurfaces( + {}, + { panel: mockPanel }, + cancelledToken as any, + ); + + assert.equal(panelCalled, false, 'Panel should not be called when token is pre-cancelled'); + assert.equal(result.surfaces.length, 0); + }); + + it('proceeds normally when token is not cancelled', async () => { + const { listSurfaces } = await import('./listSurfaces'); + + const mockPanel = { + listSurfaces() { + return [ + { + surfaceId: 'surf1', + title: 'Test', + created: new Date().toISOString(), + }, + ]; + }, + }; + + const activeToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }), + }; + + const result = await listSurfaces( + {}, + { panel: mockPanel }, + activeToken as any, + ); + + assert.equal(result.surfaces.length, 1); + assert.equal(result.surfaces[0].surfaceId, 'surf1'); + }); +}); diff --git a/src/tools/listSurfaces.ts b/src/tools/listSurfaces.ts new file mode 100644 index 0000000..86507e9 --- /dev/null +++ b/src/tools/listSurfaces.ts @@ -0,0 +1,44 @@ +import type * as vscode from 'vscode'; +import type { ListSurfacesInput, ListSurfacesToolResult } from './schemas'; + +export interface ListSurfacesPanelDependency { + listSurfaces(): Array<{ + surfaceId: string; + title: string; + created: string; + }>; +} + +export interface ListSurfacesDependencies { + panel: ListSurfacesPanelDependency; +} + +async function createDefaultDependencies(): Promise { + const { A2UIPanel } = await import('../a2ui/panel'); + return { + panel: { + listSurfaces: () => A2UIPanel.listSurfaces(), + }, + }; +} + +/** + * Core logic for the list_surfaces tool. + * Lists all currently active surface panels with their metadata. + */ +export async function listSurfaces( + _params: ListSurfacesInput, + deps?: Partial, + token?: vscode.CancellationToken, +): Promise { + if (token?.isCancellationRequested) { + return { surfaces: [] }; + } + + const defaultDeps = deps?.panel ? undefined : await createDefaultDependencies(); + const panel = deps?.panel ?? defaultDeps!.panel; + + const surfaces = panel.listSurfaces(); + + return { surfaces }; +} diff --git a/src/tools/openWhiteboard.test.ts b/src/tools/openWhiteboard.test.ts new file mode 100644 index 0000000..de0b76c --- /dev/null +++ b/src/tools/openWhiteboard.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import path from 'node:path'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +function createTokenController(initiallyCancelled = false) { + let isCancellationRequested = initiallyCancelled; + let handler: (() => void) | undefined; + + const token = { + get isCancellationRequested() { + return isCancellationRequested; + }, + onCancellationRequested(callback: () => void) { + handler = callback; + return { + dispose() { + if (handler === callback) { + handler = undefined; + } + }, + }; + }, + }; + + return { + token, + cancel() { + isCancellationRequested = true; + handler?.(); + }, + }; +} + +describe('openWhiteboard', () => { + const modulePath = require.resolve('./openWhiteboard.ts'); + let originalLoad: typeof Module._load; + + beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + function loadOpenWhiteboard(mockLogger?: { error?: (...args: unknown[]) => void }) { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === '../logging') { + return { + Logger: { + error: mockLogger?.error ?? (() => { }), + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + return (require('./openWhiteboard.ts') as typeof import('./openWhiteboard')).openWhiteboard; + } + + it('returns a cancelled image result without touching dependencies when already cancelled', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(true); + let saveCalls = 0; + let showCalls = 0; + let refreshCalls = 0; + + const result = await openWhiteboard( + {}, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome: () => { refreshCalls += 1; } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction: async () => { + saveCalls += 1; + return 'wb_never'; + }, + updateWhiteboardInteraction: async () => { + throw new Error('should not update storage'); + }, + }, + panel: { + async showWithOptions() { + showCalls += 1; + return { submitted: true, action: 'approved', canvases: [] }; + }, + closeIfOpen() { + return false; + }, + }, + now: () => 1000, + }, + }, + ); + + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + images: [], + interactionId: '', + userComment: undefined, + }); + assert.strictEqual(saveCalls, 0); + assert.strictEqual(showCalls, 0); + assert.strictEqual(refreshCalls, 0); + }); + + it('returns image-focused results for approved submissions', async () => { + const openWhiteboard = loadOpenWhiteboard(); + + const result = await openWhiteboard( + { + title: 'Blank Whiteboard', + blankCanvas: true, + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction: async () => { + return 'wb_image_contract'; + }, + updateWhiteboardInteraction: async () => { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + return { + submitted: true, + action: 'approved', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'file:///tmp/canvas.png', + }, + ], + userComment: undefined, + }; + }, + closeIfOpen() { + return false; + }, + }, + now: () => 1700000002000, + }, + }, + ); + + assert.deepStrictEqual(result, { + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the returned whiteboard images as confirmed visual input in your next response.', + images: [ + { + canvasId: 'canvas_1700000002000_1', + canvasName: 'Canvas 1', + imageUri: 'file:///tmp/canvas.png', + width: 1600, + height: 900, + }, + ], + interactionId: 'wb_image_contract', + userComment: undefined, + }); + }); + + it('preloads importImages into the initial canvas for annotation', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'whiteboard-import-')); + const imagePath = path.join(tempDirectory, 'mockup.png'); + fs.writeFileSync(imagePath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Zz6kAAAAASUVORK5CYII=', 'base64')); + + try { + const fileUri = `file://${imagePath}`; + const result = await openWhiteboard( + { + title: 'Annotate imported image', + importImages: [ + { + uri: fileUri, + label: 'Mockup', + }, + ], + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction: async () => { + return 'wb_imports'; + }, + updateWhiteboardInteraction: async () => { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + const state = JSON.parse(options.session.canvases[0]!.fabricState) as { + objects: Array>; + }; + assert.equal(state.objects.length, 1); + assert.equal(state.objects[0]?.type, 'image'); + assert.match(String(state.objects[0]?.src ?? ''), /^data:image\/png;base64,/); + assert.equal(state.objects[0]?.whiteboardSourceUri, fileUri); + return { + submitted: false, + action: 'cancelled', + canvases: [], + userComment: undefined, + }; + }, + closeIfOpen() { + return false; + }, + }, + now: () => 1700000003000, + }, + }, + ); + + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + images: [], + interactionId: 'wb_imports', + userComment: undefined, + }); + } finally { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + } + }); + + it('builds starter canvases from seedElements and keeps the image result contract', async () => { + const openWhiteboard = loadOpenWhiteboard(); + + const result = await openWhiteboard( + { + title: 'Seeded whiteboard', + initialCanvases: [ + { + name: 'Seeded Canvas', + seedElements: [ + { + type: 'rectangle', + x: 120, + y: 80, + width: 240, + height: 120, + fillColor: '#dbeafe', + }, + { + type: 'text', + x: 180, + y: 140, + text: 'Seeded', + }, + ], + }, + ], + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction: async () => { + return 'wb_seeded'; + }, + updateWhiteboardInteraction: async () => { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + assert.equal(options.session.canvases.length, 1); + assert.equal(options.session.canvases[0]?.name, 'Seeded Canvas'); + const state = JSON.parse(options.session.canvases[0]!.fabricState) as { + objects: Array>; + }; + assert.equal(state.objects.length, 2); + assert.equal(state.objects[0]?.type, 'rect'); + assert.equal(state.objects[1]?.type, 'i-text'); + return { + submitted: true, + action: 'approved', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'file:///tmp/seeded.png', + }, + ], + userComment: undefined, + }; + }, + closeIfOpen() { + return false; + }, + }, + now: () => 1700000003500, + }, + }, + ); + + assert.deepStrictEqual(result, { + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the returned whiteboard images as confirmed visual input in your next response.', + images: [ + { + canvasId: 'canvas_1700000003500_1', + canvasName: 'Seeded Canvas', + imageUri: 'file:///tmp/seeded.png', + width: 1600, + height: 900, + }, + ], + interactionId: 'wb_seeded', + userComment: undefined, + }); + }); + + it('preserves recreateWithChanges as an image-focused result action', async () => { + const openWhiteboard = loadOpenWhiteboard(); + + const result = await openWhiteboard( + { + title: 'Edit and resubmit', + blankCanvas: true, + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction: async () => { + return 'wb_changes'; + }, + updateWhiteboardInteraction: async () => { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + return { + submitted: true, + action: 'recreateWithChanges', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'file:///tmp/updated.png', + }, + ], + userComment: undefined, + }; + }, + closeIfOpen() { + return false; + }, + }, + now: () => 1700000000001, + }, + }, + ); + + assert.equal(result.submitted, true); + assert.equal(result.action, 'recreateWithChanges'); + assert.equal(result.images[0]?.imageUri, 'file:///tmp/updated.png'); + assert.equal(result.userComment, undefined); + }); + + it('logs and persists cancellation when the whiteboard panel throws', async () => { + let loggedError: unknown; + const openWhiteboard = loadOpenWhiteboard({ + error: (...args) => { + loggedError = args; + }, + }); + let updatedInteractionId = ''; + + const result = await openWhiteboard( + { + title: 'Throwing panel', + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction: async () => { + return 'wb_throw'; + }, + updateWhiteboardInteraction(interactionId) { + updatedInteractionId = interactionId; + }, + }, + panel: { + async showWithOptions() { + throw new Error('panel failed'); + }, + closeIfOpen() { + return false; + }, + }, + now: () => 1700000004000, + }, + }, + ); + + assert.equal(updatedInteractionId, 'wb_throw'); + assert.ok(loggedError, 'expected panel errors to be logged'); + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + images: [], + interactionId: 'wb_throw', + userComment: undefined, + }); + }); +}); diff --git a/src/tools/openWhiteboard.ts b/src/tools/openWhiteboard.ts new file mode 100644 index 0000000..2e609a1 --- /dev/null +++ b/src/tools/openWhiteboard.ts @@ -0,0 +1,403 @@ +import { fileURLToPath } from 'node:url'; +import type * as vscode from 'vscode'; +import { + DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + DEFAULT_WHITEBOARD_CANVAS_NAME, + DEFAULT_WHITEBOARD_CANVAS_WIDTH, + serializeBlankFabricCanvasState, +} from '../whiteboard/canvasState'; +import { + normalizeAndValidateLoadableFabricState, + serializeSeedElementsAsFabricState, +} from '../whiteboard/seededCanvas'; +import { + mergeSubmittedWhiteboardCanvases, + resolveWhiteboardSubmittedCanvases, +} from '../webview/types'; +import type { + WhiteboardCanvas, + WhiteboardPanelOptions, + WhiteboardPanelResult, + WhiteboardReviewAction, + WhiteboardSession, + WhiteboardSessionStatus, + WhiteboardSubmittedCanvas, +} from '../webview/types'; +import type { AgentInteractionProvider } from '../webview/webviewProvider'; +import type { WhiteboardExportedImage, WhiteboardInput, WhiteboardToolResult } from './schemas'; +import { Logger } from '../logging'; +import { getImageMimeType, readFileAsBuffer } from './utils/fileUtils'; + +export interface OpenWhiteboardDependencies { + storage: { + saveWhiteboardInteraction(data: { + title?: string; + context?: string; + canvases?: WhiteboardCanvas[]; + activeCanvasId?: string; + status?: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; + isDebug?: boolean; + }): Promise; + updateWhiteboardInteraction(interactionId: string, updates: { + title?: string; + whiteboardSession?: { + status?: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; + canvases?: WhiteboardCanvas[]; + activeCanvasId?: string; + }; + }): void; + getWhiteboardSession?(interactionId: string): WhiteboardSession | undefined; + }; + panel: { + showWithOptions(extensionUri: vscode.Uri, options: WhiteboardPanelOptions): Promise; + closeIfOpen(interactionId: string): boolean | Promise; + }; + now(): number; +} + +export interface OpenWhiteboardExecutionOptions { + isDebug?: boolean; + dependencies?: Partial; +} + +function toWhiteboardSessionStatus(result: WhiteboardPanelResult): WhiteboardSessionStatus { + if (!result.submitted) { + return 'cancelled'; + } + + return result.action; +} + +function toWhiteboardToolAction(result: WhiteboardPanelResult): WhiteboardReviewAction { + return result.submitted ? result.action : 'cancelled'; +} + +function createWhiteboardInstruction(action: WhiteboardReviewAction): string { + switch (action) { + case 'approved': + return 'The user approved the submitted whiteboard. Use the returned whiteboard images as confirmed visual input in your next response.'; + case 'recreateWithChanges': + return 'The user requested changes to the submitted whiteboard. Address the annotated feedback and call open_whiteboard again with updated whiteboard images before concluding.'; + case 'cancelled': + default: + return 'The whiteboard was cancelled. Do not treat this submission as approved user input.'; + } +} + +function createCanvasRecord(name: string, fabricState: string, index: number, now: number): WhiteboardCanvas { + return { + id: `canvas_${now}_${index + 1}`, + name, + fabricState, + createdAt: now, + updatedAt: now, + }; +} + +function createImportedImageObject( + image: NonNullable[number], + mimeType: string, + dataUri: string, + index: number, +): Record { + return { + type: 'image', + src: dataUri, + left: 40 + (index % 3) * 80, + top: 40 + index * 80, + whiteboardId: `import_image_${index + 1}`, + whiteboardObjectType: 'image', + whiteboardSourceUri: image.uri, + whiteboardMimeType: mimeType, + ...(image.label ? { whiteboardLabel: image.label } : {}), + }; +} + +async function createInitialCanvases( + params: WhiteboardInput, + now: number, +): Promise { + const baseState = JSON.parse(serializeBlankFabricCanvasState()) as { + version?: string; + width?: number; + height?: number; + backgroundColor?: string; + objects?: unknown[]; + }; + + const initialCanvases = (params.initialCanvases ?? []).map((canvas, index) => createCanvasRecord( + canvas.name, + typeof canvas.fabricState === 'string' + ? normalizeAndValidateLoadableFabricState(canvas.fabricState) + : serializeSeedElementsAsFabricState(canvas.seedElements ?? []), + index, + now, + )); + + const importedImages = params.importImages ?? []; + if (importedImages.length === 0) { + if (initialCanvases.length > 0) { + return initialCanvases; + } + + return [createCanvasRecord(DEFAULT_WHITEBOARD_CANVAS_NAME, JSON.stringify(baseState), 0, now)]; + } + + const objects: Record[] = []; + for (const [index, image] of importedImages.entries()) { + let filePath: string; + try { + const parsedUri = new URL(image.uri); + if (parsedUri.protocol !== 'file:') { + throw new Error(`Import image uri must use the file scheme: ${image.uri}`); + } + filePath = fileURLToPath(parsedUri); + } catch (error) { + if (error instanceof Error && error.message.includes('Import image uri must use the file scheme')) { + throw error; + } + throw new Error(`Import image uri must be a valid file URI: ${image.uri}`); + } + + const mimeType = getImageMimeType(filePath); + if (mimeType === 'application/octet-stream') { + throw new Error(`Unsupported import image type: ${image.uri}`); + } + + const fileData = await readFileAsBuffer(filePath); + const dataUri = `data:${mimeType};base64,${Buffer.from(fileData).toString('base64')}`; + objects.push(createImportedImageObject(image, mimeType, dataUri, index)); + } + + const importedImageCanvas = createCanvasRecord( + initialCanvases.length > 0 ? 'Imported Images' : DEFAULT_WHITEBOARD_CANVAS_NAME, + JSON.stringify({ + ...baseState, + objects, + }), + initialCanvases.length, + now, + ); + + return initialCanvases.length > 0 + ? [...initialCanvases, importedImageCanvas] + : [importedImageCanvas]; +} + +function getCanvasDimensions(fabricState?: string): { width: number; height: number } { + if (!fabricState) { + return { + width: DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + }; + } + + try { + const parsed = JSON.parse(fabricState) as { width?: unknown; height?: unknown }; + return { + width: typeof parsed.width === 'number' ? parsed.width : DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + }; + } catch { + return { + width: DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + }; + } +} + +function resolveWhiteboardExportedImages( + submittedCanvases: WhiteboardSubmittedCanvas[], + resolvedCanvases: WhiteboardCanvas[], +): WhiteboardExportedImage[] { + return submittedCanvases.map((canvas) => { + const storedCanvas = resolvedCanvases.find((candidate) => candidate.id === canvas.id); + const { width, height } = getCanvasDimensions(storedCanvas?.fabricState); + return { + canvasId: canvas.id, + canvasName: canvas.name, + imageUri: canvas.imageUri, + width, + height, + }; + }); +} + +async function createDefaultDependencies(): Promise { + const [{ getChatHistoryStorage }, { WhiteboardPanel }] = await Promise.all([ + import('../storage/chatHistoryStorage'), + import('../webview/whiteboardPanel'), + ]); + + const storage = getChatHistoryStorage(); + return { + storage: { + saveWhiteboardInteraction: async (data) => await storage.saveWhiteboardInteraction(data), + updateWhiteboardInteraction: async (interactionId, updates) => await storage.updateWhiteboardInteraction(interactionId, updates), + getWhiteboardSession: (interactionId) => storage.getWhiteboardSession(interactionId), + }, + panel: { + showWithOptions: (extensionUri, options) => WhiteboardPanel.showWithOptions(extensionUri, options), + closeIfOpen: (interactionId) => WhiteboardPanel.closeIfOpen(interactionId), + }, + now: () => Date.now(), + }; +} + +export async function openWhiteboard( + params: WhiteboardInput, + context: vscode.ExtensionContext, + provider: AgentInteractionProvider, + token: vscode.CancellationToken, + options: OpenWhiteboardExecutionOptions = {}, +): Promise { + if (token.isCancellationRequested) { + return { + submitted: false, + action: 'cancelled', + instruction: createWhiteboardInstruction('cancelled'), + images: [], + interactionId: '', + userComment: undefined, + }; + } + + const hasAllDependencies = Boolean( + options.dependencies?.storage + && options.dependencies?.panel + && options.dependencies?.now, + ); + const defaultDependencies = hasAllDependencies + ? undefined + : await createDefaultDependencies(); + const dependencies: OpenWhiteboardDependencies = { + ...(defaultDependencies || {}), + ...options.dependencies, + storage: { + ...(defaultDependencies?.storage || {}), + ...options.dependencies?.storage, + } as OpenWhiteboardDependencies['storage'], + panel: { + ...(defaultDependencies?.panel || {}), + ...options.dependencies?.panel, + } as OpenWhiteboardDependencies['panel'], + now: options.dependencies?.now ?? defaultDependencies?.now ?? Date.now, + }; + + const now = dependencies.now(); + const title = params.title || 'Whiteboard'; + const canvases = await createInitialCanvases(params, now); + const activeCanvasId = canvases[0]?.id; + + const interactionId = await dependencies.storage.saveWhiteboardInteraction({ + title, + context: params.context, + canvases, + activeCanvasId, + status: 'pending', + isDebug: options.isDebug, + }); + + provider.refreshHome(); + + const session = { + id: interactionId, + interactionId, + context: params.context, + title, + canvases, + activeCanvasId, + status: 'pending' as const, + }; + + let cancelledByAgent = false; + const cancellationDisposable = token.onCancellationRequested(async () => { + cancelledByAgent = true; + await dependencies.storage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status: 'cancelled', + }, + }); + void dependencies.panel.closeIfOpen(interactionId); + provider.refreshHome(); + }); + + try { + const result = await dependencies.panel.showWithOptions(context.extensionUri, { + interactionId, + title, + session, + }); + + if (cancelledByAgent) { + return { + submitted: false, + action: 'cancelled', + instruction: createWhiteboardInstruction('cancelled'), + images: [], + interactionId, + userComment: undefined, + }; + } + + const latestSession = dependencies.storage.getWhiteboardSession?.(interactionId); + const resolvedCanvases = result.submitted + ? mergeSubmittedWhiteboardCanvases(result.canvases, latestSession?.canvases ?? session.canvases) + : latestSession?.canvases ?? session.canvases; + const submittedCanvases = result.submitted + ? resolveWhiteboardSubmittedCanvases(result.canvases, resolvedCanvases) + : []; + + const status = toWhiteboardSessionStatus(result); + const action = toWhiteboardToolAction(result); + const submittedAt = result.submitted ? dependencies.now() : undefined; + + await dependencies.storage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status, + submittedAt, + submittedCanvases, + ...(result.submitted + ? { + canvases: resolvedCanvases, + activeCanvasId: latestSession?.activeCanvasId ?? session.activeCanvasId, + } + : {}), + }, + }); + provider.refreshHome(); + + return { + submitted: result.submitted, + action, + instruction: createWhiteboardInstruction(action), + images: resolveWhiteboardExportedImages(submittedCanvases, resolvedCanvases), + interactionId, + userComment: result.userComment, + }; + } catch (error) { + Logger.error('Error showing whiteboard panel:', error); + if (!cancelledByAgent) { + await dependencies.storage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status: 'cancelled', + }, + }); + provider.refreshHome(); + } + return { + submitted: false, + action: 'cancelled', + instruction: createWhiteboardInstruction('cancelled'), + images: [], + interactionId, + userComment: undefined, + }; + } finally { + cancellationDisposable.dispose(); + } +} diff --git a/src/tools/packageMetadata.test.ts b/src/tools/packageMetadata.test.ts new file mode 100644 index 0000000..bdc5e46 --- /dev/null +++ b/src/tools/packageMetadata.test.ts @@ -0,0 +1,143 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const packageJson = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf8') +) as { + contributes?: { + languageModelTools?: Array<{ + name: string; + tags?: string[]; + icon?: string; + modelDescription?: string; + inputSchema?: { + properties?: Record; + }; + }>; + }; +}; + +describe('package metadata', () => { + it('registers open_whiteboard as an image-first language model tool with optional starter canvases', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'open_whiteboard'); + + assert.ok(tool, 'Expected open_whiteboard to be declared in package.json'); + assert.deepStrictEqual(tool.tags, [ + 'whiteboard', + 'diagramming', + 'visual-context', + 'user-interaction', + 'seamless-agent', + ]); + assert.strictEqual(tool.icon, '$(symbol-color)'); + assert.ok(tool.inputSchema?.properties?.context, 'Expected context input schema'); + assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); + assert.ok(tool.inputSchema?.properties?.blankCanvas, 'Expected blankCanvas input schema'); + assert.ok(tool.inputSchema?.properties?.initialCanvases, 'Expected initialCanvases input schema'); + assert.ok(tool.inputSchema?.properties?.importImages, 'Expected importImages input schema'); + }); + + it('describes the image-first whiteboard contract in package metadata', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'open_whiteboard'); + + assert.ok(tool, 'Expected open_whiteboard to be declared in package.json'); + assert.match(tool?.modelDescription ?? '', /initialCanvases/); + assert.match(tool?.modelDescription ?? '', /importImages/); + assert.match(tool?.modelDescription ?? '', /image-first|PNG image URIs/i); + assert.match(tool?.modelDescription ?? '', /seedElements/); + assert.doesNotMatch(tool?.modelDescription ?? '', /scene summary|sceneSummary/i); + assert.match(tool?.inputSchema?.properties?.blankCanvas?.description ?? '', /defaults? to true|blank canvas/i); + assert.match(tool?.inputSchema?.properties?.initialCanvases?.description ?? '', /starter canvases|seedElements|fabricState/i); + assert.ok(tool?.inputSchema?.properties?.initialCanvases?.items?.properties?.seedElements?.items, 'Expected seedElements array items schema'); + assert.match(tool?.inputSchema?.properties?.importImages?.description ?? '', /pre-load|annotate/i); + assert.match(tool?.inputSchema?.properties?.importImages?.items?.properties?.uri?.description ?? '', /file uri/i); + }); + + it('registers render_ui as a language model tool', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'render_ui'); + + assert.ok(tool, 'Expected render_ui to be declared in package.json'); + assert.ok(tool.tags?.includes('ui'), 'Expected ui tag'); + assert.ok(tool.tags?.includes('seamless-agent'), 'Expected seamless-agent tag'); + assert.ok(tool.inputSchema?.properties?.surfaceId, 'Expected surfaceId input schema'); + assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); + assert.ok(tool.inputSchema?.properties?.components, 'Expected components input schema'); + assert.ok(tool.inputSchema?.properties?.dataModel, 'Expected dataModel input schema'); + assert.ok(tool.inputSchema?.properties?.enableA2UI, 'Expected enableA2UI input schema'); + assert.ok(tool.inputSchema?.properties?.a2uiLevel, 'Expected a2uiLevel input schema'); + assert.ok(tool.inputSchema?.properties?.waitForAction, 'Expected waitForAction input schema'); + assert.ok(tool.inputSchema?.properties?.deleteSurface, 'Expected deleteSurface input schema'); + }); + + it('declares all catalog component types in render_ui schema', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'render_ui'); + assert.ok(tool, 'Expected render_ui to be declared in package.json'); + + const componentTypeEnum: string[] = + tool.inputSchema?.properties?.components?.items?.properties?.component?.properties?.type?.enum ?? []; + + const expectedTypes = [ + 'Row', 'Column', 'Card', 'Divider', + 'Text', 'Heading', 'Image', 'Markdown', 'CodeBlock', + 'Button', 'TextField', 'Checkbox', 'Select', + 'MermaidDiagram', 'ProgressBar', 'Badge', + ]; + + for (const t of expectedTypes) { + assert.ok(componentTypeEnum.includes(t), `Expected ${t} in component type enum`); + } + }); + + it('render_ui modelDescription mentions waitForAction and component types', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'render_ui'); + assert.ok(tool, 'Expected render_ui to be declared in package.json'); + assert.match(tool?.modelDescription ?? '', /waitForAction/); + assert.match(tool?.modelDescription ?? '', /Button/); + assert.match(tool?.modelDescription ?? '', /userAction/); + assert.match(tool?.modelDescription ?? '', /surfaceId/); + assert.match(tool?.modelDescription ?? '', /component\.props/); + assert.match(tool?.modelDescription ?? '', /Markdown content is rendered/i); + assert.match(tool?.modelDescription ?? '', /enableA2UI/); + assert.match(tool?.modelDescription ?? '', /diagnostics and applied enhancements/i); + assert.match(tool?.modelDescription ?? '', /deleteSurface/); + }); + + it('registers update_ui as a language model tool', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'update_ui'); + + assert.ok(tool, 'Expected update_ui to be declared in package.json'); + assert.ok(tool.tags?.includes('ui'), 'Expected ui tag'); + assert.ok(tool.tags?.includes('seamless-agent'), 'Expected seamless-agent tag'); + assert.ok(tool.inputSchema?.properties?.surfaceId, 'Expected surfaceId input schema'); + assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); + assert.ok(tool.inputSchema?.properties?.dataModel, 'Expected dataModel input schema'); + assert.match(tool?.modelDescription ?? '', /surfaceId/); + assert.match(tool?.modelDescription ?? '', /dataModel/); + }); + + it('registers append_ui as a language model tool', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'append_ui'); + + assert.ok(tool, 'Expected append_ui to be declared in package.json'); + assert.ok(tool.tags?.includes('ui'), 'Expected ui tag'); + assert.ok(tool.tags?.includes('seamless-agent'), 'Expected seamless-agent tag'); + assert.ok(tool.inputSchema?.properties?.surfaceId, 'Expected surfaceId input schema'); + assert.ok(tool.inputSchema?.properties?.components, 'Expected components input schema'); + assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); + assert.match(tool?.modelDescription ?? '', /surfaceId/); + assert.match(tool?.modelDescription ?? '', /components/); + }); + + it('registers close_ui as a language model tool', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'close_ui'); + + assert.ok(tool, 'Expected close_ui to be declared in package.json'); + assert.ok(tool.tags?.includes('ui'), 'Expected ui tag'); + assert.ok(tool.tags?.includes('seamless-agent'), 'Expected seamless-agent tag'); + assert.ok(tool.inputSchema?.properties?.surfaceId, 'Expected surfaceId input schema'); + assert.match(tool?.modelDescription ?? '', /surfaceId/); + assert.match(tool?.modelDescription ?? '', /close|panel/i); + }); +}); diff --git a/src/tools/planReview.ts b/src/tools/planReview.ts index 62638c6..1818c0d 100644 --- a/src/tools/planReview.ts +++ b/src/tools/planReview.ts @@ -32,7 +32,7 @@ export async function planReview( const storage = getChatHistoryStorage(); // Save the interaction as pending (no chatId needed - each interaction is individual) - const interactionId = storage.savePlanReviewInteraction({ + const interactionId = await storage.savePlanReviewInteraction({ plan, title, mode, @@ -75,7 +75,7 @@ export async function planReview( ? result.action : 'closed'; // Update the stored interaction with the result - storage.updateInteraction(interactionId, { + await storage.updateInteraction(interactionId, { status: interactionState, requiredRevisions: result.requiredRevisions }); @@ -102,7 +102,7 @@ export async function planReview( Logger.error('Error showing plan review panel:', error); // Mark as closed on error - storage.updateInteraction(interactionId, { status: 'closed' }); + await storage.updateInteraction(interactionId, { status: 'closed' }); // Refresh webview provider.refreshHome(); diff --git a/src/tools/renderUI.test.ts b/src/tools/renderUI.test.ts new file mode 100644 index 0000000..60a8999 --- /dev/null +++ b/src/tools/renderUI.test.ts @@ -0,0 +1,1046 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(__filename); + +// ===================================================== +// Schema validation tests +// ===================================================== + +describe('render_ui schema', () => { + it('defaults waitForAction to false', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + surfaceId: 'surf1', + title: 'Sample surface', + components: [ + { + id: 'c1', + component: { type: 'Text', props: { content: 'Hello' } }, + }, + ], + }); + assert.strictEqual(result.waitForAction, false); + assert.strictEqual(result.enableA2UI, true); + assert.strictEqual(result.streaming, false); + assert.strictEqual(result.a2uiLevel, 'basic'); + assert.strictEqual(result.surfaceId, 'surf1'); + }); + + it('accepts waitForAction: true', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'c1', + component: { type: 'Button', props: { label: 'OK', action: 'ok' } }, + }, + ], + waitForAction: true, + }); + assert.strictEqual(result.waitForAction, true); + }); + + it('rejects missing components', async () => { + const { RenderUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(RenderUIInputSchema, {}); + assert.strictEqual(result.success, false); + }); + + it('accepts arbitrary component records in schema and leaves catalog validation to runtime', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'c1', + component: { + type: 'Table', + columns: ['$data.columns'], + }, + }, + ], + }); + assert.ok(result.components); + assert.equal(result.components[0]?.component.type, 'Table'); + }); + + it('preserves parentId adjacency entries', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'row1', + component: { type: 'Row' }, + }, + { + id: 'text1', + parentId: 'row1', + component: { type: 'Text', props: { content: 'Child' } }, + }, + ], + }); + assert.ok(result.components); + assert.equal(result.components[1]?.parentId, 'row1'); + }); + + it('rejects missing component payloads', async () => { + const { RenderUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(RenderUIInputSchema, { + components: [ + { + id: 'c1', + }, + ], + }); + assert.strictEqual(result.success, false); + }); + + it('accepts a top-level dataModel', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'c1', + component: { type: 'Text', props: { content: '$data.greeting' } }, + }, + ], + dataModel: { + greeting: 'Hello from data', + }, + }); + assert.equal(result.dataModel?.greeting, 'Hello from data'); + }); + + it('accepts deleteSurface requests without components when surfaceId is provided', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + surfaceId: 'surface_to_delete', + deleteSurface: true, + }); + assert.equal(result.deleteSurface, true); + assert.equal(result.surfaceId, 'surface_to_delete'); + assert.equal(result.components, undefined); + }); + + it('rejects deleteSurface requests without surfaceId', async () => { + const { RenderUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(RenderUIInputSchema, { + deleteSurface: true, + }); + assert.strictEqual(result.success, false); + }); +}); + +// ===================================================== +// package.json render_ui schema contract tests +// ===================================================== + +describe('render_ui package.json schema contract', () => { + /** + * Reads the component type enum advertised in package.json's render_ui inputSchema. + * This is the agent-facing contract: whatever is listed here is what the LLM + * "knows" it can generate. It must stay in sync with the runtime catalog. + */ + function getRenderUITypeEnum(): string[] { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('../../package.json') as { + contributes?: { + languageModelTools?: Array<{ + name: string; + inputSchema?: { + properties?: { + components?: { + items?: { + properties?: { + component?: { + properties?: { + type?: { enum?: string[] }; + }; + }; + }; + }; + }; + }; + }; + }>; + }; + }; + const tool = pkg.contributes?.languageModelTools?.find((t) => t.name === 'render_ui'); + return ( + tool?.inputSchema?.properties?.components?.items?.properties?.component?.properties?.type + ?.enum ?? [] + ); + } + + it('documents BarChart in the render_ui component type enum', () => { + const types = getRenderUITypeEnum(); + assert.ok( + types.includes('BarChart'), + `package.json render_ui schema must enumerate "BarChart" so agents can discover it. Found: ${types.join(', ')}`, + ); + }); + + it('documents LineChart in the render_ui component type enum', () => { + const types = getRenderUITypeEnum(); + assert.ok( + types.includes('LineChart'), + `package.json render_ui schema must enumerate "LineChart" so agents can discover it. Found: ${types.join(', ')}`, + ); + }); + + it('documents PieChart in the render_ui component type enum', () => { + const types = getRenderUITypeEnum(); + assert.ok( + types.includes('PieChart'), + `package.json render_ui schema must enumerate "PieChart" so agents can discover it. Found: ${types.join(', ')}`, + ); + }); + + it('documents MermaidDiagram Mermaid prop keys in the render_ui props description', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('../../package.json') as { + contributes?: { + languageModelTools?: Array<{ + name: string; + inputSchema?: { + properties?: { + components?: { + items?: { + properties?: { + component?: { + properties?: { + props?: { description?: string }; + }; + }; + }; + }; + }; + }; + }; + }>; + }; + }; + const tool = pkg.contributes?.languageModelTools?.find((t) => t.name === 'render_ui'); + const propsDesc = + tool?.inputSchema?.properties?.components?.items?.properties?.component?.properties?.props + ?.description ?? ''; + // The description must mention at least one accepted Mermaid prop key so agents know + // which key carries the diagram source. + const mentionsMermaidKey = + propsDesc.includes('diagram') || + propsDesc.includes('definition') || + propsDesc.includes('source') || + propsDesc.includes('code'); + assert.ok( + mentionsMermaidKey, + `package.json render_ui props description must mention at least one Mermaid diagram source key (diagram/definition/source/code). Got: "${propsDesc}"`, + ); + }); + + it('documents chart data shape (data array) in the render_ui props description', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('../../package.json') as { + contributes?: { + languageModelTools?: Array<{ + name: string; + inputSchema?: { + properties?: { + components?: { + items?: { + properties?: { + component?: { + properties?: { + props?: { description?: string }; + }; + }; + }; + }; + }; + }; + }; + }>; + }; + }; + const tool = pkg.contributes?.languageModelTools?.find((t) => t.name === 'render_ui'); + const propsDesc = + tool?.inputSchema?.properties?.components?.items?.properties?.component?.properties?.props + ?.description ?? ''; + // The description must give agents a hint about how chart data is structured + // so they can author BarChart/LineChart/PieChart payloads correctly. + const mentionsChartData = propsDesc.includes('data') || propsDesc.includes('label'); + assert.ok( + mentionsChartData, + `package.json render_ui props description must hint at chart data shape (data/label). Got: "${propsDesc}"`, + ); + }); +}); + +// ===================================================== +// Catalog tests +// ===================================================== + +describe('a2ui catalog', () => { + it('allows all catalog types', async () => { + const { isAllowedComponentType } = await import('../a2ui/catalog'); + const allowed = [ + 'Row', 'Column', 'Card', 'Divider', + 'Text', 'Heading', 'Image', 'Markdown', 'CodeBlock', + 'Button', 'TextField', 'Checkbox', 'Select', + 'MermaidDiagram', 'ProgressBar', 'Badge', + 'Table', 'Tabs', 'Toggle', 'HTML', + ]; + for (const type of allowed) { + assert.ok(isAllowedComponentType(type), `Expected ${type} to be allowed`); + } + }); + + it('rejects unsupported types', async () => { + const { isAllowedComponentType } = await import('../a2ui/catalog'); + assert.strictEqual(isAllowedComponentType('Unknown'), false); + assert.strictEqual(isAllowedComponentType('Slider'), false); // Slider is not implemented + assert.strictEqual(isAllowedComponentType(''), false); + assert.strictEqual(isAllowedComponentType('Grid'), false); + }); +}); + +// ===================================================== +// Renderer tests +// ===================================================== + +describe('a2ui renderer', () => { + it('renders a Text component', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', props: { content: 'Hello World' } } }, + ], + }); + assert.ok(html.includes('Hello World'), 'Expected content to appear'); + assert.ok(html.includes('a2ui-text'), 'Expected class name'); + }); + + it('renders component fields when props wrapper is omitted', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', content: 'Direct content' } }, + ], + }); + assert.ok(html.includes('Direct content'), 'Expected direct component fields to be rendered'); + }); + + it('renders Markdown as HTML instead of escaped source text', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Markdown', props: { content: '## Title\n\n**Bold** text' } } }, + ], + }); + assert.ok(html.includes('

Title

'), 'Expected markdown heading output'); + assert.ok(html.includes('Bold'), 'Expected markdown emphasis output'); + }); + + it('interpolates bindings embedded inside literal text', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Badge', props: { label: 'Owner: $data.owner' } } }, + ], + dataModel: { owner: 'Platform Team' }, + }); + assert.ok(html.includes('Owner: Platform Team'), 'Expected embedded binding interpolation'); + }); + + it('resolves canonical A2UI bound values using JSON Pointer paths', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { + id: 'c1', + component: { + Text: { + text: { path: '/user/name', literalString: 'Guest' }, + }, + }, + }, + ], + dataModel: { user: { name: 'Alice' } }, + }); + assert.ok(html.includes('Alice'), 'Expected canonical path binding resolution'); + }); + + it('renders canonical child references without relying on parentId adjacency', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { + id: 'root', + component: { + Column: { + children: { explicitList: ['text_1'] }, + }, + }, + }, + { + id: 'text_1', + component: { + Text: { + text: { literalString: 'Canonical child' }, + }, + }, + }, + ], + }); + assert.ok(html.includes('Canonical child'), 'Expected canonical child references to render'); + }); + + it('renders template children against scoped item data', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { + id: 'root', + component: { + Column: { + children: { + template: { + dataBinding: '/items', + componentId: 'item_text', + }, + }, + }, + }, + }, + { + id: 'item_text', + component: { + Text: { + text: { path: '/name' }, + }, + }, + }, + ], + dataModel: { + items: [ + { name: 'Alpha' }, + { name: 'Beta' }, + ], + }, + }); + assert.ok(html.includes('Alpha'), 'Expected first template item'); + assert.ok(html.includes('Beta'), 'Expected second template item'); + }); + + it('renders Mermaid components with a target container and collapsible source', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'diagram', component: { type: 'MermaidDiagram', props: { content: 'graph LR\nA-->B' } } }, + ], + }); + assert.ok(html.includes('a2ui-mermaid-target'), 'Expected Mermaid render target'); + assert.ok(html.includes('a2ui-mermaid-details'), 'Expected Mermaid source details'); + }); + + it('embeds the diagram source text in rendered Mermaid HTML (regression)', async () => { + // Regression: the rendered HTML must contain the actual diagram definition so the + // browser-side Mermaid runtime can pick it up from .a2ui-mermaid-source. + const { renderSurface } = await import('../a2ui/renderer'); + const diagramSrc = 'graph TD\nX-->Y\nY-->Z'; + const html = renderSurface({ + surfaceId: 'mermaid-regression', + components: [ + { id: 'd1', component: { type: 'MermaidDiagram', props: { content: diagramSrc } } }, + ], + }); + assert.ok( + html.includes('graph TD'), + `Rendered HTML must embed the diagram source; got: ${html.slice(0, 300)}`, + ); + assert.ok(html.includes('X-->Y') || html.includes('X-->Y'), 'Arrow notation must survive HTML rendering'); + }); + + it('embeds diagram source for every accepted Mermaid prop alias', async () => { + // MermaidDiagram accepts: diagram, definition, source, code, text, content. + // Each alias must result in the source text being embedded in the HTML. + const { renderSurface } = await import('../a2ui/renderer'); + const aliases: Array<[string, Record]> = [ + ['diagram', { diagram: 'graph LR\nAlias1-->B' }], + ['definition', { definition: 'graph LR\nAlias2-->B' }], + ['source', { source: 'graph LR\nAlias3-->B' }], + ['code', { code: 'graph LR\nAlias4-->B' }], + ['text', { text: 'graph LR\nAlias5-->B' }], + ['content', { content: 'graph LR\nAlias6-->B' }], + ]; + for (const [alias, props] of aliases) { + const html = renderSurface({ + surfaceId: `alias-${alias}`, + components: [{ id: 'diag', component: { type: 'MermaidDiagram', props } }], + }); + assert.ok( + html.includes(`Alias${aliases.findIndex(([a]) => a === alias) + 1}`), + `Mermaid prop alias "${alias}" must embed diagram source in HTML`, + ); + } + }); + + it('throws RendererError for unsupported component type', async () => { + const { renderSurface, RendererError } = await import('../a2ui/renderer'); + assert.throws( + () => + renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'InvalidComponent', props: {} } }, + ], + }), + (err: unknown) => { + assert.ok(err instanceof RendererError, 'Expected RendererError'); + assert.match(err.message, /Unsupported component type/); + return true; + }, + ); + }); + + it('resolves $data.path bindings', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', props: { content: '$data.greeting' } } }, + ], + dataModel: { greeting: 'Hello from data' }, + }); + assert.ok(html.includes('Hello from data'), 'Expected resolved value'); + assert.ok(!html.includes('$data.greeting'), 'Should not contain unresolved binding'); + }); + + it('resolves nested $data.path bindings', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Heading', props: { content: '$data.user.name', level: 2 } } }, + ], + dataModel: { user: { name: 'Alice' } }, + }); + assert.ok(html.includes('Alice'), 'Expected nested resolved value'); + }); + + it('renders nested layout components', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'row1', component: { type: 'Row' } }, + { id: 'text1', parentId: 'row1', component: { type: 'Text', props: { content: 'First' } } }, + { id: 'text2', parentId: 'row1', component: { type: 'Text', props: { content: 'Second' } } }, + ], + }); + assert.ok(html.includes('a2ui-row'), 'Expected row class'); + assert.ok(html.includes('First'), 'Expected first child'); + assert.ok(html.includes('Second'), 'Expected second child'); + }); + + it('renders a Button component with action', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'btn1', component: { type: 'Button', props: { label: 'Submit', action: 'submit' } } }, + ], + }); + assert.ok(html.includes('a2ui-button'), 'Expected button class'); + assert.ok(html.includes('Submit'), 'Expected label'); + assert.ok(html.includes('data-action="submit"'), 'Expected action attribute'); + }); + + it('renders visible labels for text fields and selects', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'name', component: { type: 'TextField', props: { label: 'Name', placeholder: 'Enter your name' } } }, + { + id: 'color', + component: { + type: 'Select', + props: { + label: 'Favorite Color', + value: 'blue', + options: [ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + ], + }, + }, + }, + ], + }); + assert.ok(html.includes('a2ui-field-label'), 'Expected visible field labels'); + assert.ok(html.includes('Name'), 'Expected text field label'); + assert.ok(html.includes('Favorite Color'), 'Expected select label'); + assert.ok(html.includes('option value="blue" selected'), 'Expected selected object option value'); + }); + + it('renders helper text and required state for form controls', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { + id: 'name', + component: { + type: 'TextField', + props: { + label: 'Approver', + required: true, + helperText: 'Required field', + ariaLabel: 'Approver name', + }, + }, + }, + ], + }); + assert.ok(html.includes('a2ui-required'), 'Expected required marker'); + assert.ok(html.includes('Required field'), 'Expected helper text'); + assert.ok(html.includes('aria-label="Approver name"'), 'Expected aria-label'); + }); + + it('renders progress labels and values', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'progress', component: { type: 'ProgressBar', props: { label: 'Completion', value: 72, max: 100 } } }, + ], + }); + assert.ok(html.includes('Completion'), 'Expected progress label'); + assert.ok(html.includes('72%'), 'Expected progress percentage'); + }); + + it('renders a Card with nested children', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'card1', component: { type: 'Card' } }, + { id: 'txt1', parentId: 'card1', component: { type: 'Text', props: { content: 'Card content' } } }, + ], + }); + assert.ok(html.includes('a2ui-card'), 'Expected card class'); + assert.ok(html.includes('Card content'), 'Expected nested content'); + }); + + it('escapes HTML in text content', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', props: { content: '' } } }, + ], + }); + assert.ok(!html.includes('