diff --git a/phoenix-builder-mcp/log-buffer.js b/phoenix-builder-mcp/log-buffer.js index 3c60d6b10..f11b3d2a2 100644 --- a/phoenix-builder-mcp/log-buffer.js +++ b/phoenix-builder-mcp/log-buffer.js @@ -4,10 +4,12 @@ export class LogBuffer { constructor() { this._entries = []; this._readIndex = 0; + this._totalPushed = 0; } push(entry) { this._entries.push(entry); + this._totalPushed++; if (this._entries.length > MAX_ENTRIES) { const overflow = this._entries.length - MAX_ENTRIES; this._entries.splice(0, overflow); @@ -25,6 +27,23 @@ export class LogBuffer { return newEntries; } + totalPushed() { + return this._totalPushed; + } + + getTail(n, before) { + const firstIndex = this._totalPushed - this._entries.length; + let endIdx = this._entries.length; + if (before != null) { + endIdx = Math.max(0, Math.min(this._entries.length, before - firstIndex)); + } + if (n === 0) { + return this._entries.slice(0, endIdx); + } + const startIdx = Math.max(0, endIdx - n); + return this._entries.slice(startIdx, endIdx); + } + clear() { this._entries = []; this._readIndex = 0; diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js index dad421369..14ac66298 100644 --- a/phoenix-builder-mcp/mcp-tools.js +++ b/phoenix-builder-mcp/mcp-tools.js @@ -1,5 +1,20 @@ import { z } from "zod"; +const DEFAULT_MAX_CHARS = 10000; + +function _trimToCharBudget(lines, maxChars) { + let total = 0; + // Walk backwards (newest first) to keep the most recent entries + let startIdx = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + const cost = lines[i].length + 1; // +1 for newline + if (total + cost > maxChars) { break; } + total += cost; + startIdx = i; + } + return { lines: lines.slice(startIdx), trimmed: startIdx }; +} + export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) { server.tool( "start_phoenix", @@ -67,9 +82,18 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe server.tool( "get_terminal_logs", - "Get stdout/stderr output from the Electron process. By default returns new logs since last call; set clear=true to get all logs and clear the buffer.", - { clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read.") }, - async ({ clear }) => { + "Get stdout/stderr output from the Electron process. Returns last 50 entries by default. " + + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + + "prefer filter + small tail to keep responses compact.", + { + clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read."), + tail: z.number().default(50).describe("Return last N entries. 0 = all."), + before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."), + filter: z.string().optional().describe("Optional regex to filter log entries by text content. Applied before tail/before."), + maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.") + }, + async ({ clear, tail, before, filter, maxChars }) => { let logs; if (clear) { logs = processManager.getTerminalLogs(false); @@ -77,11 +101,58 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe } else { logs = processManager.getTerminalLogs(true); } - const text = logs.map(e => `[${e.stream}] ${e.text}`).join(""); + const totalEntries = processManager.getTerminalLogsTotalPushed(); + let filterRe; + if (filter) { + try { + filterRe = new RegExp(filter, "i"); + } catch (e) { + return { + content: [{ + type: "text", + text: `Invalid filter regex: ${e.message}` + }] + }; + } + logs = logs.filter(e => filterRe.test(e.text)); + } + const matchedEntries = logs.length; + const endIdx = before != null ? Math.max(0, Math.min(matchedEntries, before)) : matchedEntries; + if (tail > 0) { + const startIdx = Math.max(0, endIdx - tail); + logs = logs.slice(startIdx, endIdx); + } else { + logs = logs.slice(0, endIdx); + } + let lines = logs.map(e => `[${e.stream}] ${e.text}`); + let trimmed = 0; + if (maxChars > 0) { + const result = _trimToCharBudget(lines, maxChars); + lines = result.lines; + trimmed = result.trimmed; + } + const showing = lines.length; + const rangeEnd = endIdx; + const rangeStart = rangeEnd - logs.length; + const actualStart = rangeStart + trimmed; + const hasMore = actualStart > 0; + let header = `[Logs: ${totalEntries} total`; + if (filter) { + header += `, ${matchedEntries} matched /${filter}/i`; + } + header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`; + if (trimmed > 0) { + header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`; + } + if (hasMore) { + header += ` hasMore=true, use before=${actualStart} to page back.`; + } + header += `]`; + const text = lines.join(""); return { content: [{ type: "text", - text: text || "(no terminal logs)" + text: text ? header + "\n" + text : "(no terminal logs)" }] }; } @@ -89,17 +160,60 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe server.tool( "get_browser_console_logs", - "Get console logs captured from the Phoenix browser runtime from boot time. Fetches the full retained log buffer directly from the browser instance.", + "Get console logs from the Phoenix browser runtime. Returns last 50 entries by default. " + + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + + "prefer filter + small tail to keep responses compact.", { - instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected."), + tail: z.number().default(50).describe("Return last N entries. 0 = all."), + before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."), + filter: z.string().optional().describe("Optional regex to filter log entries by message content. Applied before tail/before."), + maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.") }, - async ({ instance }) => { + async ({ instance, tail, before, filter, maxChars }) => { try { - const logs = await wsControlServer.requestLogs(instance); + const result = await wsControlServer.requestLogs(instance, { tail, before, filter }); + const entries = result.entries || []; + const totalEntries = result.totalEntries || entries.length; + const matchedEntries = result.matchedEntries != null ? result.matchedEntries : entries.length; + const rangeEnd = result.rangeEnd != null ? result.rangeEnd : matchedEntries; + let lines = entries.map(e => `[${e.level}] ${e.message}`); + let trimmed = 0; + if (maxChars > 0) { + const trimResult = _trimToCharBudget(lines, maxChars); + lines = trimResult.lines; + trimmed = trimResult.trimmed; + } + const showing = lines.length; + const rangeStart = rangeEnd - entries.length; + const actualStart = rangeStart + trimmed; + const hasMore = actualStart > 0; + let header = `[Logs: ${totalEntries} total`; + if (filter) { + header += `, ${matchedEntries} matched /${filter}/i`; + } + header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`; + if (trimmed > 0) { + header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`; + } + if (hasMore) { + header += ` hasMore=true, use before=${actualStart} to page back.`; + } + header += `]`; + if (showing === 0) { + return { + content: [{ + type: "text", + text: "(no browser logs)" + }] + }; + } + const text = lines.join("\n"); return { content: [{ type: "text", - text: JSON.stringify(logs.length > 0 ? logs : "(no browser logs)") + text: header + "\n" + text }] }; } catch (err) { @@ -226,6 +340,39 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe } ); + server.tool( + "exec_js_in_live_preview", + "Execute JavaScript in the live preview iframe (the page being previewed), NOT in Phoenix itself. " + + "Auto-opens the live preview panel if it is not already visible. " + + "Code is evaluated via eval() in the global scope of the previewed page. " + + "Note: eval() is synchronous — async/await is NOT supported. " + + "Only available when an HTML file is selected in the live preview — " + + "does not work for markdown or other non-HTML file types. " + + "Use this to inspect or manipulate the user's live-previewed web page (e.g. document.title, DOM queries).", + { + code: z.string().describe("JavaScript code to execute in the live preview iframe"), + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + }, + async ({ code, instance }) => { + try { + const result = await wsControlServer.requestExecJsLivePreview(code, instance); + return { + content: [{ + type: "text", + text: result !== undefined ? String(result) : "(undefined)" + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + server.tool( "get_phoenix_status", "Check the status of the Phoenix process and WebSocket connection.", diff --git a/phoenix-builder-mcp/process-manager.js b/phoenix-builder-mcp/process-manager.js index d49a4a91d..cafc8b028 100644 --- a/phoenix-builder-mcp/process-manager.js +++ b/phoenix-builder-mcp/process-manager.js @@ -112,12 +112,17 @@ export function createProcessManager() { terminalLogs.clear(); } + function getTerminalLogsTotalPushed() { + return terminalLogs.totalPushed(); + } + return { start, stop, isRunning, getPid, getTerminalLogs, - clearTerminalLogs + clearTerminalLogs, + getTerminalLogsTotalPushed }; } diff --git a/phoenix-builder-mcp/ws-control-server.js b/phoenix-builder-mcp/ws-control-server.js index b7beb76e2..452a2ac86 100644 --- a/phoenix-builder-mcp/ws-control-server.js +++ b/phoenix-builder-mcp/ws-control-server.js @@ -73,7 +73,12 @@ export function createWSControlServer(port) { const pending4 = pendingRequests.get(msg.id); if (pending4) { pendingRequests.delete(msg.id); - pending4.resolve(msg.entries || []); + pending4.resolve({ + entries: msg.entries || [], + totalEntries: msg.totalEntries || (msg.entries ? msg.entries.length : 0), + matchedEntries: msg.matchedEntries, + rangeEnd: msg.rangeEnd + }); } break; } @@ -91,6 +96,19 @@ export function createWSControlServer(port) { break; } + case "exec_js_live_preview_response": { + const pending6 = pendingRequests.get(msg.id); + if (pending6) { + pendingRequests.delete(msg.id); + if (msg.error) { + pending6.reject(new Error(msg.error)); + } else { + pending6.resolve(msg.result); + } + } + break; + } + case "reload_response": { const pending3 = pendingRequests.get(msg.id); if (pending3) { @@ -260,7 +278,7 @@ export function createWSControlServer(port) { }); } - function requestLogs(instanceName) { + function requestLogs(instanceName, { tail = 50, before, filter } = {}) { return new Promise((resolve, reject) => { const resolved = _resolveClient(instanceName); if (resolved.error) { @@ -291,7 +309,14 @@ export function createWSControlServer(port) { } }); - client.ws.send(JSON.stringify({ type: "get_logs_request", id })); + const msg = { type: "get_logs_request", id, tail }; + if (before != null) { + msg.before = before; + } + if (filter) { + msg.filter = filter; + } + client.ws.send(JSON.stringify(msg)); }); } @@ -330,6 +355,41 @@ export function createWSControlServer(port) { }); } + function requestExecJsLivePreview(code, instanceName) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("exec_js_live_preview request timed out (60s)")); + }, 60000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + client.ws.send(JSON.stringify({ type: "exec_js_live_preview_request", id, code })); + }); + } + function getBrowserLogs(sinceLast, instanceName) { const resolved = _resolveClient(instanceName); if (resolved.error) { @@ -381,6 +441,7 @@ export function createWSControlServer(port) { requestReload, requestLogs, requestExecJs, + requestExecJsLivePreview, getBrowserLogs, clearBrowserLogs, isClientConnected, diff --git a/src/phoenix-builder/phoenix-builder-boot.js b/src/phoenix-builder/phoenix-builder-boot.js index eb40d6682..c577720ec 100644 --- a/src/phoenix-builder/phoenix-builder-boot.js +++ b/src/phoenix-builder/phoenix-builder-boot.js @@ -49,6 +49,7 @@ let ws = null; let logBuffer = []; const capturedLogs = []; + let totalLogsPushed = 0; let flushTimer = null; let reconnectTimer = null; let reconnectDelay = RECONNECT_BASE_MS; @@ -133,6 +134,7 @@ }; logBuffer.push(entry); capturedLogs.push(entry); + totalLogsPushed++; // Cap buffer size — drop oldest entries to prevent unbounded memory growth if (logBuffer.length > MAX_BUFFER_SIZE) { logBuffer = logBuffer.slice(logBuffer.length - MAX_BUFFER_SIZE); @@ -309,10 +311,35 @@ // --- Register built-in handler for get_logs_request --- // Returns the full capturedLogs buffer to the MCP server on demand. registerHandler("get_logs_request", function (msg) { + const tail = typeof msg.tail === "number" ? msg.tail : 50; + const before = typeof msg.before === "number" ? msg.before : null; + const filterStr = msg.filter || null; + let source = capturedLogs; + if (filterStr) { + try { + const re = new RegExp(filterStr, "i"); + source = capturedLogs.filter(function (e) { return re.test(e.message); }); + } catch (e) { + // invalid regex — skip filtering + source = capturedLogs; + } + } + const matchedEntries = source.length; + const endIdx = before != null ? Math.max(0, Math.min(matchedEntries, before)) : matchedEntries; + let entries; + if (tail === 0) { + entries = source.slice(0, endIdx); + } else { + const startIdx = Math.max(0, endIdx - tail); + entries = source.slice(startIdx, endIdx); + } _sendMessage({ type: "get_logs_response", id: msg.id, - entries: capturedLogs.slice() + entries: entries, + totalEntries: totalLogsPushed, + matchedEntries: matchedEntries, + rangeEnd: endIdx }); }); diff --git a/src/phoenix-builder/phoenix-builder-client.js b/src/phoenix-builder/phoenix-builder-client.js index e37fad13d..a6316f57a 100644 --- a/src/phoenix-builder/phoenix-builder-client.js +++ b/src/phoenix-builder/phoenix-builder-client.js @@ -27,6 +27,9 @@ define(function (require, exports, module) { const CommandManager = require("command/CommandManager"); const Commands = require("command/Commands"); + const LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"); + const LiveDevMain = require("LiveDevelopment/main"); + const WorkspaceManager = require("view/WorkspaceManager"); const boot = window._phoenixBuilder || null; @@ -87,10 +90,90 @@ define(function (require, exports, module) { }); } + function _handleExecJsLivePreviewRequest(msg) { + function _evaluate() { + LiveDevProtocol.evaluate(msg.code) + .done(function (evalResult) { + boot.sendMessage({ + type: "exec_js_live_preview_response", + id: msg.id, + result: JSON.stringify(evalResult) + }); + }) + .fail(function (err) { + boot.sendMessage({ + type: "exec_js_live_preview_response", + id: msg.id, + error: (err && err.message) || String(err) || "evaluate() failed" + }); + }); + } + + // Fast path: already connected + if (LiveDevProtocol.getConnectionIds().length > 0) { + _evaluate(); + return; + } + + // Need to ensure live preview is open and connected + const panel = WorkspaceManager.getPanelForID("live-preview-panel"); + if (!panel || !panel.isVisible()) { + CommandManager.execute("file.liveFilePreview"); + } else { + LiveDevMain.openLivePreview(); + } + + // Wait for a live preview connection (up to 30s) + const TIMEOUT = 30000; + const POLL_INTERVAL = 500; + let settled = false; + let pollTimer = null; + + function cleanup() { + settled = true; + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + LiveDevProtocol.off("ConnectionConnect.execJsLivePreview"); + } + + const timeoutTimer = setTimeout(function () { + if (settled) { return; } + cleanup(); + boot.sendMessage({ + type: "exec_js_live_preview_response", + id: msg.id, + error: "Timed out waiting for live preview connection (30s)" + }); + }, TIMEOUT); + + function onConnected() { + if (settled) { return; } + cleanup(); + clearTimeout(timeoutTimer); + _evaluate(); + } + + LiveDevProtocol.on("ConnectionConnect.execJsLivePreview", onConnected); + + // Safety-net poll in case the event was missed + pollTimer = setInterval(function () { + if (settled) { + clearInterval(pollTimer); + return; + } + if (LiveDevProtocol.getConnectionIds().length > 0) { + onConnected(); + } + }, POLL_INTERVAL); + } + // Register handlers on the boot module if (boot) { boot.registerHandler("screenshot_request", _handleScreenshotRequest); boot.registerHandler("reload_request", _handleReloadRequest); + boot.registerHandler("exec_js_live_preview_request", _handleExecJsLivePreviewRequest); } exports.connect = function (url) { if (boot) { boot.connect(url); } };