Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,531 changes: 1,032 additions & 499 deletions crates/goose-mcp/src/autovisualiser/mod.rs

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-base.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* MCP App Base — shared foundation styles for autovisualiser templates.
*
* All color, typography, and spacing values come from the 70+ host theme tokens.
* Fallback defaults (--color-background-primary: #fff etc.) are defined here
* for resilience, but are overridden at runtime by the bridge's applyTheme().
*/

/* ── Fallback tokens (overridden by host) ─────────────────────────── */
:root {
--color-background-primary: light-dark(#ffffff, #1a1d21);
--color-background-secondary: light-dark(#f4f6f7, #22252a);
--color-background-tertiary: light-dark(#e3e6ea, #2a2e35);
--color-text-primary: light-dark(#3f434b, #e0e0e0);
--color-text-secondary: light-dark(#878787, #a0a0a0);
--color-text-tertiary: light-dark(#a7b0b9, #666);
--color-border-primary: light-dark(#e3e6ea, #333);
--color-border-secondary: light-dark(#e3e6ea, #333);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: monospace;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-text-sm-size: 0.875rem;
--font-text-md-size: 1rem;
--font-text-xs-size: 0.75rem;
--font-heading-sm-size: 1.125rem;
--font-heading-xs-size: 1rem;
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}

/* ── Reset ─────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

html, body {
overflow: hidden;
}

body {
font-family: var(--font-sans);
font-size: var(--font-text-sm-size);
font-weight: var(--font-weight-normal);
color: var(--color-text-primary);
background: linear-gradient(
light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.2)),
light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.2))
),
var(--color-background-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

/* ── Fullscreen / PiP centering ────────────────────────────────────── */
:root[data-display-mode="fullscreen"] body,
:root[data-display-mode="pip"] body {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

/* ── Loading state ─────────────────────────────────────────────────── */
.av-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--color-text-tertiary);
font-size: var(--font-text-sm-size);
letter-spacing: 0.01em;
}

.av-loading.hidden {
display: none;
}

/* ── Tooltip ───────────────────────────────────────────────────────── */
.av-tooltip {
position: absolute;
background: var(--color-background-inverse, rgba(0, 0, 0, 0.85));
color: var(--color-text-inverse, #fff);
padding: 6px 10px;
border-radius: var(--border-radius-sm);
font-size: var(--font-text-xs-size);
line-height: 1.4;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
max-width: 280px;
}

.av-tooltip strong {
font-weight: var(--font-weight-semibold);
}
262 changes: 262 additions & 0 deletions crates/goose-mcp/src/autovisualiser/templates/assets/mcp-app-bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* MCP App Bridge — shared protocol layer for autovisualiser templates.
*
* Provides:
* McpAppBridge.init(options) → bootstraps the MCP Apps lifecycle
*
* options:
* appName – string, e.g. "autovisualiser-chart"
* onData – function(data): called when tool-result or tool-input arrives
* onTheme – function(): called after theme CSS vars are applied (optional)
* extractData – function(msg): custom data extractor (optional, has sensible default)
*/
var McpAppBridge = (function () {
"use strict";

var _nextId = 1;
var _currentData = null;
var _onData = null;
var _onTheme = null;
var _extractData = null;

// ── JSON-RPC helpers ────────────────────────────────────────────────

function sendRequest(method, params) {
return new Promise(function (resolve, reject) {
var id = _nextId++;
function handler(event) {
if (event.data && event.data.id === id) {
window.removeEventListener("message", handler);
if (event.data.result) resolve(event.data.result);
else if (event.data.error) reject(event.data.error);
}
}
window.addEventListener("message", handler);
window.parent.postMessage(
{ jsonrpc: "2.0", id: id, method: method, params: params },
"*"
);
});
}

function sendNotification(method, params) {
window.parent.postMessage(
{ jsonrpc: "2.0", method: method, params: params },
"*"
);
}

// ── Size reporting ──────────────────────────────────────────────────

function reportSize() {
// In fullscreen/pip the host controls sizing — skip size reports
// to avoid a feedback loop when transitioning back to inline.
if (_displayMode === "fullscreen" || _displayMode === "pip") return;

var h = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
sendNotification("ui/notifications/size-changed", {
width: document.body.scrollWidth,
height: h,
});
}

// ── Theming ─────────────────────────────────────────────────────────

var _displayMode = "inline";

function applyTheme(hostContext) {
if (!hostContext) return;
// Clear resolved color cache — theme change means light-dark() flips.
_probeCache = {};
if (hostContext.theme)
document.documentElement.style.colorScheme = hostContext.theme;
if (hostContext.displayMode) {
_displayMode = hostContext.displayMode;
document.documentElement.setAttribute("data-display-mode", _displayMode);
}
if (hostContext.styles && hostContext.styles.variables) {
var vars = hostContext.styles.variables;
for (var key in vars) {
if (vars[key]) document.documentElement.style.setProperty(key, vars[key]);
}
}
if (hostContext.styles && hostContext.styles.css && hostContext.styles.css.fonts) {
if (!document.getElementById("mcp-host-fonts")) {
var style = document.createElement("style");
style.id = "mcp-host-fonts";
style.textContent = hostContext.styles.css.fonts;
document.head.appendChild(style);
}
}
if (_onTheme) _onTheme();
}

// ── Default data extractor ──────────────────────────────────────────

function defaultExtractData(msg) {
var sc = msg.params && msg.params.structuredContent;
if (sc) {
if (sc.data) return sc.data;
return sc;
}
var args = msg.params && msg.params.arguments;
if (args) {
if (args.data) return args.data;
return args;
}
return null;
}

// ── Read a computed CSS variable ────────────────────────────────────

// Host tokens may use light-dark(light, dark) syntax. getComputedStyle
// returns the raw string for custom properties, so we resolve it by
// reading the value through a real CSS property on a hidden probe element.
var _probe = null;
var _probeCache = {};

function resolveColor(raw) {
if (!raw) return raw;
// Fast path: no light-dark() wrapper
if (raw.indexOf("light-dark(") === -1) return raw;

var cached = _probeCache[raw];
if (cached) return cached;

if (!_probe) {
_probe = document.createElement("div");
_probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;";
document.body.appendChild(_probe);
}
_probe.style.color = raw;
var resolved = getComputedStyle(_probe).color;
_probeCache[raw] = resolved;
return resolved;
}

function cssVar(name) {
var raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return resolveColor(raw);
}

// ── Public API ──────────────────────────────────────────────────────

function init(options) {
_onData = options.onData;
_onTheme = options.onTheme || null;
_extractData = options.extractData || defaultExtractData;

// Size observation
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(reportSize).observe(document.body);
}
window.addEventListener("resize", reportSize);

// Message listener
window.addEventListener("message", function (event) {
var msg = event.data;
if (!msg || msg.jsonrpc !== "2.0") return;

if (
msg.method === "ui/notifications/tool-result" ||
msg.method === "ui/notifications/tool-input"
) {
var data = _extractData(msg);
if (data) {
_currentData = data;
_onData(data);
}
}

if (msg.method === "ui/notifications/host-context-changed") {
applyTheme(msg.params);
}

if (msg.method === "ui/resource-teardown" && msg.id) {
window.parent.postMessage(
{ jsonrpc: "2.0", id: msg.id, result: {} },
"*"
);
}
});

// Handshake
var appIdentity = {
name: options.appName || "autovisualiser",
version: "1.0.0",
};
sendRequest("ui/initialize", {
protocolVersion: "2026-01-26",
appInfo: appIdentity,
clientInfo: appIdentity,
appCapabilities: {
availableDisplayModes: ["inline", "fullscreen"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include PiP in declared display modes

ui/initialize advertises only ['inline','fullscreen'], so the desktop host will treat PiP as unsupported (useDisplayMode.ts reads appCapabilities.availableDisplayModes and gates controls/requests from that list). As a result, none of these migrated visualizers can enter PiP even though their templates/style logic handle data-display-mode="pip", making the new PiP flow unreachable.

Useful? React with 👍 / 👎.

},
capabilities: {},
})
.then(function (result) {
applyTheme(result.hostContext || result);
sendNotification("ui/notifications/initialized", {});
reportSize();
})
.catch(function (err) {
console.warn("[" + (options.appName || "autovisualiser") + "] init failed:", err);
reportSize();
});
}

function positionTooltip(tooltipEl, event, offsetX, offsetY) {
var ox = offsetX || 10;
var oy = offsetY || -10;
var el = tooltipEl instanceof HTMLElement ? tooltipEl : tooltipEl.node();
if (!el) return;
var rect = el.getBoundingClientRect();
var w = rect.width || 150;
var h = rect.height || 40;
var x = event.pageX + ox;
var y = event.pageY + oy;
if (x + w > window.innerWidth - 8) x = event.pageX - w - ox;
if (y + h > window.innerHeight - 8) y = event.pageY - h - Math.abs(oy);
if (x < 8) x = 8;
if (y < 8) y = 8;
el.style.left = x + "px";
el.style.top = y + "px";
}

/**
* Display an error message in the page body.
* Hides the loading indicator and shows a styled error box.
*/
function showError(message) {
var loader = document.getElementById("loadingIndicator");
if (loader) loader.classList.add("hidden");

var el = document.createElement("div");
el.style.cssText =
"padding:24px;text-align:center;color:" +
(cssVar("--color-text-secondary") || "#878787") +
";font-size:14px;font-family:" +
(cssVar("--font-sans") || "sans-serif");
el.textContent = "Unable to render visualization: " + message;
document.body.appendChild(el);
}

return {
init: init,
cssVar: cssVar,
reportSize: reportSize,
positionTooltip: positionTooltip,
showError: showError,
get currentData() {
return _currentData;
},
get displayMode() {
return _displayMode;
},
};
})();
Loading
Loading