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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 31 additions & 7 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,17 @@ function _isSafeReadOnlyBash(rawCmd) {
}

/**
* Lazily import the ESM @anthropic-ai/claude-code module.
* Lazily import the ESM Claude Agent SDK module.
*/
async function getQueryFn() {
if (!queryModule) {
queryModule = await import("@anthropic-ai/claude-code");
// The JS SDK was split out of @anthropic-ai/claude-code in v2 and
// moved to @anthropic-ai/claude-agent-sdk. The CLI binary still
// ships under @anthropic-ai/claude-code (used by Phoenix's
// terminal "claude" command); the SDK now lives in its own
// package with the same query() signature and SDKResultMessage
// shape, so the rest of this file is unchanged.
queryModule = await import("@anthropic-ai/claude-agent-sdk");
}
return queryModule.query;
}
Expand Down Expand Up @@ -679,13 +685,31 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
"<img> tags over div background-image so the user can swap, inspect, and resize " +
"them in the editor — only fall back to background-image when an effect (parallax, " +
"cover-with-overlay, repeating tile) genuinely requires it." +
"\n\nYou can debug and inspect the live preview directly — these tools are for " +
"active iteration, not just final verification:" +
"\n\nThe live preview is the rendered view of the HTML/CSS/JS/SVG or Markdown file " +
"currently active in the editor." +
"\n\nYou ALWAYS have live visibility into the editor through the phoenix-editor tools " +
"listed below. NEVER tell the user you can't see what's open / what they're looking " +
"at / what file they're on / what's selected / what's in the live preview — call " +
"getEditorState (and takeScreenshot / execJsInLivePreview as needed) instead. " +
"ALWAYS prefer the phoenix-editor MCP for ANY preview interaction — screenshots, " +
"JS evaluation, DOM inspection, console/network reads, viewport resizing, reloads. " +
"Do NOT reach for other MCP servers like chrome-devtools to open a separate browser " +
"session for the same things; the user's live preview inside Phoenix reflects their " +
"current (possibly unsaved) edits, while a fresh browser session would miss those. " +
"phoenix-editor.takeScreenshot, phoenix-editor.execJsInLivePreview, " +
"phoenix-editor.resizeLivePreview, and phoenix-editor.controlEditor cover virtually " +
"every \"look at / poke at the page\" need. Only fall back to chrome-devtools or " +
"another browser MCP if the user explicitly asks for a non-Phoenix browser context. " +
"These tools are for active iteration, not just final verification:" +
"\n- takeScreenshot: see the rendered HTML preview, the rendered Markdown preview, " +
"the editor, or any panel. Use it to confirm visual output, diagnose layout/styling " +
"bugs, or check that HTML or Markdown rendered as expected. Pass reload=true to " +
"force-reload the preview before capturing (useful after JS edits) — saves a tool " +
"call vs. reloading separately." +
"bugs, or check that HTML or Markdown rendered as expected. Simple selector rule: " +
"if the question is about the rendered live preview pass " +
"selector='#panel-live-preview-frame' (targeted shot is easier to reason about); for " +
"anything else — Problems panel, file tree, toolbar, any other Phoenix UI, or just " +
"\"what is the user looking at\" — omit the selector and capture the full editor " +
"window. Pass reload=true to force-reload the preview before capturing (useful after " +
"JS edits) — saves a tool call vs. reloading separately." +
"\n- execJsInLivePreview: run JS inside the HTML preview iframe to read the DOM, " +
"query computed styles, click elements, or capture console output. Use it to debug " +
"behavior, not just to verify." +
Expand Down
96 changes: 72 additions & 24 deletions src-node/mcp-editor-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,22 @@ const CLARIFICATION_HINT =
// is reliable in practice, so these should never fire during normal use —
// they exist so a stalled promise chain (live preview wedged, etc.) surfaces
// a deterministic error to Claude instead of the handler hanging forever.
// Tools whose runtime is bounded by user-supplied code (execJsInLivePreview)
// intentionally have no timeout — the code is allowed to run as long as it takes.
const EXEC_PEER_TIMEOUT_MS = {
getEditorState: 5000,
takeScreenshot: 15000,
controlEditor: 5000,
resizeLivePreview: 5000
};

function _execPeerWithTimeout(nodeConnector, fn, args, label) {
const ms = EXEC_PEER_TIMEOUT_MS[fn];
// Floor for caller-provided timeouts (e.g. execJsInLivePreview's
// timeoutMs). 5s minimum stops the model from spamming impatient retries
// on a preview that's just taking a beat to settle. No ceiling — the
// model picks the upper bound based on the task (a user can legitimately
// ask for a long-running inspection).
const MIN_CALLER_TIMEOUT_MS = 5000;

function _execPeerWithTimeout(nodeConnector, fn, args, label, overrideMs) {
const ms = overrideMs || EXEC_PEER_TIMEOUT_MS[fn];
const call = nodeConnector.execPeer(fn, args);
if (!ms) {
return call; // no timeout configured for this tool
Expand All @@ -65,6 +70,17 @@ function _execPeerWithTimeout(nodeConnector, fn, args, label) {
});
}

/**
* Clamp a caller-supplied timeoutMs into the allowed range. Returns a
* sane default when missing/invalid.
*/
function _resolveCallerTimeout(timeoutMs, defaultMs) {
if (typeof timeoutMs !== "number" || !isFinite(timeoutMs)) {
return defaultMs;
}
return Math.max(MIN_CALLER_TIMEOUT_MS, timeoutMs);
}

/**
* Append a clarification hint to an MCP tool result if the user has queued a message.
*/
Expand Down Expand Up @@ -93,16 +109,27 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
"getEditorState",
"Get the current Phoenix editor state: active file, working set (open files with isDirty flag), live preview file, " +
"cursor/selection info (current line text with surrounding context, or selected text), " +
"and the currently selected element in the live preview (tag, selector, text preview) if any. " +
"the currently selected element in the live preview (tag, selector, text preview) if any, " +
"and inDesignMode (true when the code editor is hidden and the live preview is expanded " +
"to fill the workspace — full-bleed, content-focused view). " +
"The live preview selected element may differ from the editor cursor — use execJsInLivePreview to inspect it further. " +
"Long lines are trimmed to 200 chars and selections to 10K chars — use the Read tool for full content.",
{},
async function () {
let result;
try {
const state = await _execPeerWithTimeout(nodeConnector, "getEditorState", {}, "getEditorState");
// Append a fallback hint so the model has a clear next step if the
// state alone doesn't answer the user's question — e.g. they're
// pointing at a UI panel (Problems, search, sidebar) that's
// visible on screen but not represented in this JSON.
const hint = "\n\nIf this state isn't enough to identify what the user is " +
"asking about (e.g. they're pointing at a Phoenix UI panel like the " +
"Problems panel, search bar, or sidebar that isn't represented here), " +
"call takeScreenshot with no selector to capture the full editor window " +
"and see what's on their screen.";
result = {
content: [{ type: "text", text: JSON.stringify(state) }]
content: [{ type: "text", text: JSON.stringify(state) + hint }]
};
} catch (err) {
result = {
Expand All @@ -116,17 +143,21 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)

const takeScreenshotTool = sdkModule.tool(
"takeScreenshot",
"Take a screenshot of the Phoenix Code editor application window or the live preview within it (not a web page). " +
"The editor window contains: a toolbar at the top, a file tree sidebar on the left, " +
"the code editor area in the center, and optionally a live preview panel on the right. " +
"The preview panel shows either an HTML/CSS/JS browser view or a rendered markdown preview " +
"(when a markdown file is open, the panel shows a WYSIWYG markdown editor/viewer). " +
"By default captures the entire editor window and returns the screenshot as an inline PNG image. " +
"If filePath is specified, saves to that file and returns the path instead. " +
"Prefer capturing specific regions using the selector parameter instead of the full window: " +
"use '#panel-live-preview-frame' to capture the preview panel (works for both HTML live preview and markdown preview), " +
"or '.editor-holder' to capture only the code editor area. " +
"Only omit the selector when you need to see the full editor application layout. " +
"Take a screenshot of the Phoenix Code editor application window (or a region within it). " +
"This captures the EDITOR APPLICATION, not the rendered web page on its own — the editor window " +
"contains a toolbar at the top, a file tree sidebar on the left, the code editor area in the " +
"center, and optionally a live preview panel on the right. The preview panel shows either an " +
"HTML/CSS/JS browser view or a rendered markdown preview (when a markdown file is open, the " +
"panel shows a WYSIWYG markdown editor/viewer). " +
"Returns the screenshot as an inline PNG image; if filePath is specified, saves to that file " +
"and returns the path instead. " +
"Simple rule for the selector parameter:" +
"\n- If the question is about the rendered live preview (\"how does it look\", \"is the page " +
"rendering\", \"check the preview\", layout/styling/markdown verification): pass " +
"selector='#panel-live-preview-frame'. The targeted shot is far easier to reason about than the " +
"full editor." +
"\n- For anything else — Problems panel, file tree, toolbar, search bar, any editor UI, or " +
"\"what is the user looking at\" — omit the selector and capture the full editor window. " +
"Note: live preview screenshots may include Phoenix toolbox overlays on selected elements. " +
"Use purePreview=true to temporarily hide these overlays and render the page as it would appear in a real browser. " +
"Use reload=true to force-reload the live preview before capturing — useful after editing JS, " +
Expand Down Expand Up @@ -177,14 +208,24 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
"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") },
"(e.g. document.title, DOM queries).\n\n" +
"Pass timeoutMs to bound how long to wait if the live preview is wedged or slow to respond. " +
"Defaults to 10000 (10s). Floored at 5000 (the preview frame may still be settling); no " +
"upper limit — pick whatever fits the snippet you're running.",
{
code: z.string().describe("JavaScript code to execute in the live preview iframe"),
timeoutMs: z.number().int().optional().describe(
"Max wait in milliseconds before giving up on the live preview. " +
"Floored at 5000, no upper limit. Default 10000."
)
},
async function (args) {
let toolResult;
const timeoutMs = _resolveCallerTimeout(args.timeoutMs, 10000);
try {
const result = await _execPeerWithTimeout(nodeConnector, "execJsInLivePreview", {
code: args.code
}, "execJsInLivePreview");
}, "execJsInLivePreview", timeoutMs);
if (result.error) {
toolResult = {
content: [{ type: "text", text: "Error: " + result.error }],
Expand Down Expand Up @@ -216,22 +257,29 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
"- openInWorkingSet: Open a file and pin it to the working set. Params: filePath\n" +
"- setSelection: Open a file and select a range. Params: filePath, startLine, startCh, endLine, endCh\n" +
"- setCursorPos: Open a file and set cursor position. Params: filePath, line, ch\n" +
"- toggleLivePreview: Show or hide the live preview panel. Params: show (boolean)\n" +
"- toggleLivePreview: Show or hide the live preview panel. Params: showPreview (boolean)\n" +
"- toggleDesignMode: Switch design mode on or off. Design mode hides the code editor and " +
"expands the live preview to fill the workspace, giving the user a content-focused, " +
"browser-like view of their page. Use it when the user wants to see how the page looks " +
"without code chrome (e.g. presenting a draft, polishing visuals); turn it off when " +
"switching back to code editing. Params: enabled (boolean — true for design mode on, " +
"false to return to the code editor + side-by-side preview).\n" +
"- reloadLivePreview: Force-reload the live preview iframe (and any popped-out preview tabs). " +
"Use after editing JS that doesn't appear to have hot-reloaded. Note: if you're about to call " +
"takeScreenshot anyway, prefer takeScreenshot({ reload: true }) — it reloads and captures in " +
"one step. No params.",
{
operations: z.array(z.object({
operation: z.enum(["open", "close", "openInWorkingSet", "setSelection", "setCursorPos", "toggleLivePreview", "reloadLivePreview"]),
filePath: z.string().optional().describe("Absolute path to the file (not required for toggleLivePreview)"),
operation: z.enum(["open", "close", "openInWorkingSet", "setSelection", "setCursorPos", "toggleLivePreview", "toggleDesignMode", "reloadLivePreview"]),
filePath: z.string().optional().describe("Absolute path to the file (not required for toggleLivePreview / toggleDesignMode / reloadLivePreview)"),
startLine: z.number().optional().describe("Start line (1-based) for setSelection"),
startCh: z.number().optional().describe("Start column (1-based) for setSelection"),
endLine: z.number().optional().describe("End line (1-based) for setSelection"),
endCh: z.number().optional().describe("End column (1-based) for setSelection"),
line: z.number().optional().describe("Line number (1-based) for setCursorPos"),
ch: z.number().optional().describe("Column (1-based) for setCursorPos"),
showPreview: z.boolean().optional().describe("true to show, false to hide live preview (for toggleLivePreview)")
showPreview: z.boolean().optional().describe("true to show, false to hide live preview (for toggleLivePreview)"),
enabled: z.boolean().optional().describe("true to turn design mode on (full live preview, code editor hidden), false to return to code editor view (for toggleDesignMode)")
})).describe("Array of editor operations to execute sequentially")
},
async function (args) {
Expand Down
Loading
Loading