diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html
index b2c08b5dae..fa573c1d0e 100644
--- a/mcp/packages/plugin/index.html
+++ b/mcp/packages/plugin/index.html
@@ -3,12 +3,83 @@
- Penpot plugin example
+ Penpot MCP Plugin
-
+
+
+
+ Not connected
+
-
Not connected
+
+
+
+
+
+
+
+
+
diff --git a/mcp/packages/plugin/public/icon.jpg b/mcp/packages/plugin/public/icon.jpg
new file mode 100644
index 0000000000..9df8dd26a4
Binary files /dev/null and b/mcp/packages/plugin/public/icon.jpg differ
diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json
index e2a769c7f8..aa97095b30 100644
--- a/mcp/packages/plugin/public/manifest.json
+++ b/mcp/packages/plugin/public/manifest.json
@@ -1,6 +1,7 @@
{
"name": "Penpot MCP Plugin",
"code": "plugin.js",
+ "icon": "icon.jpg",
"version": 2,
"description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts
index da87fa025f..8aad137ec3 100644
--- a/mcp/packages/plugin/src/main.ts
+++ b/mcp/packages/plugin/src/main.ts
@@ -6,21 +6,33 @@ document.body.dataset.theme = searchParams.get("theme") ?? "light";
// WebSocket connection management
let ws: WebSocket | null = null;
-const statusElement = document.getElementById("connection-status");
+
+const statusPill = document.getElementById("connection-status") as HTMLElement;
+const statusText = document.getElementById("status-text") as HTMLElement;
+const currentTaskEl = document.getElementById("current-task") as HTMLElement;
+const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement;
+const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement;
+const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
+const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement;
/**
- * Updates the connection status display element.
+ * Updates the status pill and button visibility based on connection state.
*
- * @param status - the base status text to display
- * @param isConnectedState - whether the connection is in a connected state (affects color)
- * @param message - optional additional message to append to the status
+ * @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error")
+ * @param label - human-readable label to display inside the pill
*/
-function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void {
- if (statusElement) {
- const displayText = message ? `${status}: ${message}` : status;
- statusElement.textContent = displayText;
- statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
+function updateConnectionStatus(code: string, label: string): void {
+ if (statusPill) {
+ statusPill.dataset.status = code;
}
+ if (statusText) {
+ statusText.textContent = label;
+ }
+
+ const isConnected = code === "connected";
+ if (connectBtn) connectBtn.hidden = isConnected;
+ if (disconnectBtn) disconnectBtn.hidden = !isConnected;
+
parent.postMessage(
{
type: "update-connection-status",
@@ -30,6 +42,34 @@ function updateConnectionStatus(code: string, status: string, isConnectedState:
);
}
+/**
+ * Updates the "Current task" display with the currently executing task name.
+ *
+ * @param taskName - the task name to display, or null to reset to "---"
+ */
+function updateCurrentTask(taskName: string | null): void {
+ if (currentTaskEl) {
+ currentTaskEl.textContent = taskName ?? "---";
+ }
+ if (taskName === null) {
+ updateExecutedCode(null);
+ }
+}
+
+/**
+ * Updates the executed code textarea with the last code run during task execution.
+ *
+ * @param code - the code string to display, or null to clear
+ */
+function updateExecutedCode(code: string | null): void {
+ if (executedCodeEl) {
+ executedCodeEl.value = code ?? "";
+ }
+ if (copyCodeBtn) {
+ copyCodeBtn.disabled = !code;
+ }
+}
+
/**
* Sends a task response back to the MCP server via WebSocket.
*
@@ -49,7 +89,7 @@ function sendTaskResponse(response: any): void {
*/
function connectToMcpServer(baseUrl?: string, token?: string): void {
if (ws?.readyState === WebSocket.OPEN) {
- updateConnectionStatus("connected", "Already connected", true);
+ updateConnectionStatus("connected", "Connected");
return;
}
@@ -62,17 +102,22 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
}
ws = new WebSocket(wsUrl);
- updateConnectionStatus("connecting", "Connecting...", false);
+ updateConnectionStatus("connecting", "Connecting...");
ws.onopen = () => {
console.log("Connected to MCP server");
- updateConnectionStatus("connected", "Connected to MCP server", true);
+ updateConnectionStatus("connected", "Connected");
};
ws.onmessage = (event) => {
try {
console.log("Received from MCP server:", event.data);
const request = JSON.parse(event.data);
+ // Track the current task received from the MCP server
+ if (request.task) {
+ updateCurrentTask(request.task);
+ updateExecutedCode(request.params?.code ?? null);
+ }
// Forward the task request to the plugin for execution
parent.postMessage(request, "*");
} catch (error) {
@@ -84,8 +129,9 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
// If we've send the error update we don't send the disconnect as well
if (!wsError) {
console.log("Disconnected from MCP server");
- const message = event.reason || undefined;
- updateConnectionStatus("disconnected", "Disconnected", false, message);
+ const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected";
+ updateConnectionStatus("disconnected", label);
+ updateCurrentTask(null);
}
ws = null;
};
@@ -94,19 +140,34 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
console.error("WebSocket error:", error);
wsError = error;
// note: WebSocket error events typically don't contain detailed error messages
- updateConnectionStatus("error", "Connection error", false);
+ updateConnectionStatus("error", "Connection error");
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
- const message = error instanceof Error ? error.message : undefined;
- updateConnectionStatus("error", "Connection failed", false, message);
+ const reason = error instanceof Error ? error.message : undefined;
+ const label = reason ? `Connection failed: ${reason}` : "Connection failed";
+ updateConnectionStatus("error", label);
}
}
-document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
+copyCodeBtn?.addEventListener("click", () => {
+ const code = executedCodeEl?.value;
+ if (!code) return;
+
+ navigator.clipboard.writeText(code).then(() => {
+ copyCodeBtn.classList.add("copied");
+ setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500);
+ });
+});
+
+connectBtn?.addEventListener("click", () => {
connectToMcpServer();
});
+disconnectBtn?.addEventListener("click", () => {
+ ws?.close();
+});
+
// Listen plugin.ts messages
window.addEventListener("message", (event) => {
if (event.data.type === "start-server") {
diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts
index e113f7adc3..e2b5bee38e 100644
--- a/mcp/packages/plugin/src/plugin.ts
+++ b/mcp/packages/plugin/src/plugin.ts
@@ -10,8 +10,8 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, {
- width: 158,
- height: 200,
+ width: 236,
+ height: 210,
hidden: !!mcp,
} as any);
diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css
index 030f2204e9..7061657b33 100644
--- a/mcp/packages/plugin/src/style.css
+++ b/mcp/packages/plugin/src/style.css
@@ -1,10 +1,178 @@
@import "@penpot/plugin-styles/styles.css";
body {
- line-height: 1.5;
- padding: 10px;
+ margin: 0;
+ padding: 0;
}
-p {
- margin-block-end: 0.75rem;
+.plugin-container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-8);
+ padding: var(--spacing-16) var(--spacing-8);
+ box-sizing: border-box;
+}
+
+/* ── Status pill ─────────────────────────────────────────────────── */
+
+.status-pill {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-8);
+ padding: var(--spacing-8) var(--spacing-16);
+ border-radius: var(--spacing-8);
+ border: 1px solid var(--background-quaternary);
+ color: var(--foreground-secondary);
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.status-pill[data-status="connected"] {
+ border-color: var(--accent-primary);
+ color: var(--accent-primary);
+}
+
+.status-pill[data-status="disconnected"],
+.status-pill[data-status="error"] {
+ border-color: var(--error-500);
+ color: var(--error-500);
+}
+
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: currentColor;
+ flex-shrink: 0;
+}
+
+/* ── Collapsible section ─────────────────────────────────────────── */
+
+.collapsible-section {
+ border: 1px solid var(--background-quaternary);
+ border-radius: var(--spacing-8);
+ overflow: hidden;
+}
+
+.collapsible-header {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-8);
+ padding: var(--spacing-8) var(--spacing-12);
+ cursor: pointer;
+ color: var(--foreground-secondary);
+ list-style: none;
+ user-select: none;
+}
+
+.collapsible-header::-webkit-details-marker {
+ display: none;
+}
+
+.collapsible-arrow {
+ flex-shrink: 0;
+ transition: transform 0.2s ease;
+}
+
+details[open] > .collapsible-header .collapsible-arrow {
+ transform: rotate(90deg);
+}
+
+.collapsible-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-4);
+ padding: var(--spacing-4) var(--spacing-12) var(--spacing-12);
+ border-top: 1px solid var(--background-quaternary);
+}
+
+/* ── Tool section ────────────────────────────────────────────────── */
+
+.tool-label {
+ color: var(--foreground-secondary);
+}
+
+.tool-display {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-8);
+ padding: var(--spacing-8) var(--spacing-12);
+ border-radius: var(--spacing-8);
+ background-color: var(--background-tertiary);
+ color: var(--foreground-secondary);
+ min-height: 32px;
+ box-sizing: border-box;
+}
+
+.tool-icon {
+ flex-shrink: 0;
+ opacity: 0.7;
+}
+
+/* ── Code section ────────────────────────────────────────────────── */
+
+.code-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: var(--spacing-8);
+}
+
+.code-textarea {
+ width: 100%;
+ height: 100px;
+ resize: vertical;
+ padding: var(--spacing-8) var(--spacing-12);
+ border-radius: var(--spacing-8);
+ border: 1px solid var(--background-quaternary);
+ background-color: var(--background-tertiary);
+ color: var(--foreground-secondary);
+ font-family: monospace;
+ font-size: 11px;
+ line-height: 1.5;
+ box-sizing: border-box;
+ outline: none;
+}
+
+.copy-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid var(--background-quaternary);
+ border-radius: var(--spacing-4);
+ background-color: transparent;
+ color: var(--foreground-secondary);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+}
+
+.copy-btn:hover:not(:disabled) {
+ background-color: var(--background-tertiary);
+ color: var(--foreground-primary);
+}
+
+.copy-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.copy-btn.copied {
+ color: var(--accent-primary);
+ border-color: var(--accent-primary);
+}
+
+/* ── Action buttons ──────────────────────────────────────────────── */
+
+#connect-btn,
+#disconnect-btn {
+ width: 100%;
+ margin-top: var(--spacing-4);
}