diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index ee724402a2..c76f7ece71 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -147,11 +147,25 @@ exports.sendPrompt = async function (params) { // Prepend selection context to the prompt if available let enrichedPrompt = prompt; - if (selectionContext && selectionContext.selectedText) { - enrichedPrompt = - "The user has selected the following text in " + selectionContext.filePath + - " (lines " + selectionContext.startLine + "-" + selectionContext.endLine + "):\n" + - "```\n" + selectionContext.selectedText + "\n```\n\n" + prompt; + if (selectionContext) { + if (selectionContext.selectedText) { + enrichedPrompt = + "The user has selected the following text in " + selectionContext.filePath + + " (lines " + selectionContext.startLine + "-" + selectionContext.endLine + "):\n" + + "```\n" + selectionContext.selectedText + "\n```\n\n" + prompt; + } else { + let previewSnippet = ""; + if (selectionContext.selectionPreview) { + previewSnippet = "\nPreview of selection:\n```\n" + + selectionContext.selectionPreview + "\n```\n"; + } + enrichedPrompt = + "The user has selected lines " + selectionContext.startLine + "-" + + selectionContext.endLine + " in " + selectionContext.filePath + + ". Use the Read tool with offset=" + (selectionContext.startLine - 1) + + " and limit=" + (selectionContext.endLine - selectionContext.startLine + 1) + + " to read the selected content if needed." + previewSnippet + "\n" + prompt; + } } // Run the query asynchronously — don't await here so we return requestId immediately @@ -222,7 +236,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) "mcp__phoenix-editor__getEditorState", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", - "mcp__phoenix-editor__controlEditor" + "mcp__phoenix-editor__controlEditor", + "mcp__phoenix-editor__resizeLivePreview", + "mcp__phoenix-editor__wait" ], mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: "acceptEdits", diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index 767243041d..66d64ec284 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -170,9 +170,57 @@ function createEditorMcpServer(sdkModule, nodeConnector) { } ); + const resizeLivePreviewTool = sdkModule.tool( + "resizeLivePreview", + "Resize the live preview panel to a specific width for responsive testing. " + + "Provide a width in pixels based on the target device (e.g. 390 for a phone, 768 for a tablet, 1440 for desktop).", + { + width: z.number().describe("Target width in pixels") + }, + async function (args) { + try { + const result = await nodeConnector.execPeer("resizeLivePreview", { + width: args.width + }); + if (result.error) { + return { + content: [{ type: "text", text: "Error: " + result.error }], + isError: true + }; + } + return { + content: [{ type: "text", text: JSON.stringify(result) }] + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error resizing live preview: " + err.message }], + isError: true + }; + } + } + ); + + const waitTool = sdkModule.tool( + "wait", + "Wait for a specified number of seconds before continuing. " + + "Useful for waiting after DOM changes, animations, live preview updates, or resize operations " + + "before taking a screenshot or inspecting state. Maximum 60 seconds.", + { + seconds: z.number().min(0.1).max(60).describe("Number of seconds to wait (0.1–60)") + }, + async function (args) { + const ms = Math.round(args.seconds * 1000); + await new Promise(function (resolve) { setTimeout(resolve, ms); }); + return { + content: [{ type: "text", text: "Waited " + args.seconds + " seconds." }] + }; + } + ); + return sdkModule.createSdkMcpServer({ name: "phoenix-editor", - tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool] + tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool, + resizeLivePreviewTool, waitTool] }); } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 396371f250..a786b82cd8 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -506,15 +506,31 @@ define(function (require, exports, module) { // Gather selection context if available and not dismissed let selectionContext = null; if (_lastSelectionInfo && !_selectionDismissed && _lastSelectionInfo.selectedText) { + const MAX_INLINE_SELECTION = 500; + const MAX_PREVIEW_LINES = 3; + const MAX_PREVIEW_LINE_LEN = 80; let selectedText = _lastSelectionInfo.selectedText; - if (selectedText.length > 10000) { - selectedText = selectedText.slice(0, 10000); + let selectionPreview = null; + if (selectedText.length > MAX_INLINE_SELECTION) { + const lines = selectedText.split("\n"); + const headLines = lines.slice(0, MAX_PREVIEW_LINES).map(function (l) { + return l.length > MAX_PREVIEW_LINE_LEN ? l.slice(0, MAX_PREVIEW_LINE_LEN) + "..." : l; + }); + const tailLines = lines.length > MAX_PREVIEW_LINES * 2 + ? lines.slice(-MAX_PREVIEW_LINES).map(function (l) { + return l.length > MAX_PREVIEW_LINE_LEN ? l.slice(0, MAX_PREVIEW_LINE_LEN) + "..." : l; + }) + : []; + selectionPreview = headLines.join("\n") + + (tailLines.length ? "\n...\n" + tailLines.join("\n") : ""); + selectedText = null; } selectionContext = { filePath: _lastSelectionInfo.filePath, startLine: _lastSelectionInfo.startLine, endLine: _lastSelectionInfo.endLine, - selectedText: selectedText + selectedText: selectedText, + selectionPreview: selectionPreview }; } @@ -623,6 +639,8 @@ define(function (require, exports, module) { "mcp__phoenix-editor__takeScreenshot": { icon: "fa-solid fa-camera", color: "#c084fc", label: Strings.AI_CHAT_TOOL_SCREENSHOT }, "mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS }, "mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR }, + "mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW }, + "mcp__phoenix-editor__wait": { icon: "fa-solid fa-hourglass-half", color: "#adb9bd", label: Strings.AI_CHAT_TOOL_WAIT }, TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS } }; @@ -1174,6 +1192,7 @@ define(function (require, exports, module) { try { $target.html(marked.parse(_segmentText, { breaks: true, gfm: true })); _enhanceColorCodes($target); + _addCopyButtons($target); } catch (e) { $target.text(_segmentText); } @@ -1240,6 +1259,35 @@ define(function (require, exports, module) { }); } + /** + * Inject a copy-to-clipboard button into each
block inside the given container.
+ * Idempotent: skips elements that already have a .ai-copy-btn.
+ */
+ function _addCopyButtons($container) {
+ $container.find("pre").each(function () {
+ const $pre = $(this);
+ if ($pre.find(".ai-copy-btn").length) {
+ return;
+ }
+ const $btn = $('');
+ $btn.on("click", function (e) {
+ e.stopPropagation();
+ const $code = $pre.find("code");
+ const text = ($code.length ? $code[0] : $pre[0]).textContent;
+ Phoenix.app.copyToClipboard(text);
+ const $icon = $btn.find("i");
+ $icon.removeClass("fa-copy").addClass("fa-check");
+ $btn.attr("title", Strings.AI_CHAT_COPIED_CODE);
+ setTimeout(function () {
+ $icon.removeClass("fa-check").addClass("fa-copy");
+ $btn.attr("title", Strings.AI_CHAT_COPY_CODE);
+ }, 1500);
+ });
+ $pre.append($btn);
+ });
+ }
+
function _appendToolIndicator(toolName, toolId) {
// Remove thinking indicator on first content
if (!_hasReceivedContent) {
@@ -1329,6 +1377,20 @@ define(function (require, exports, module) {
$tool.find(".ai-tool-header").on("click", function () {
$tool.toggleClass("ai-tool-expanded");
}).css("cursor", "pointer");
+ } else if (toolName === "mcp__phoenix-editor__wait" && toolInput && toolInput.seconds) {
+ // Countdown timer: update label every second
+ const totalSec = Math.ceil(toolInput.seconds);
+ let remaining = totalSec;
+ const $label = $tool.find(".ai-tool-label");
+ const countdownId = setInterval(function () {
+ remaining--;
+ if (remaining <= 0) {
+ clearInterval(countdownId);
+ $label.text(StringUtils.format(Strings.AI_CHAT_TOOL_WAITED, totalSec));
+ } else {
+ $label.text(StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, remaining));
+ }
+ }, 1000);
} else if (toolName === "mcp__phoenix-editor__takeScreenshot") {
const $detail = $('');
$tool.append($detail);
@@ -1451,6 +1513,16 @@ define(function (require, exports, module) {
summary: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS,
lines: input.code ? input.code.split("\n").slice(0, 20) : []
};
+ case "mcp__phoenix-editor__resizeLivePreview":
+ return {
+ summary: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW,
+ lines: input.width ? [input.width + "px"] : []
+ };
+ case "mcp__phoenix-editor__wait":
+ return {
+ summary: StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, input.seconds || "?"),
+ lines: []
+ };
case "TodoWrite": {
const todos = input.todos || [];
const completed = todos.filter(function (t) { return t.status === "completed"; }).length;
@@ -1606,6 +1678,9 @@ define(function (require, exports, module) {
// Finalize: remove ai-stream-target class so future messages get their own container
$messages.find(".ai-stream-target").removeClass("ai-stream-target");
+ // Ensure copy buttons are present on all code blocks
+ _addCopyButtons($messages);
+
// Mark all active tool indicators as done
_finishActiveTools();
}
diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js
index 0038673236..75c1c64db4 100644
--- a/src/core-ai/aiPhoenixConnectors.js
+++ b/src/core-ai/aiPhoenixConnectors.js
@@ -36,9 +36,11 @@ define(function (require, exports, module) {
FileSystem = require("filesystem/FileSystem"),
LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"),
LiveDevMain = require("LiveDevelopment/main"),
+ LivePreviewConstants = require("LiveDevelopment/LivePreviewConstants"),
WorkspaceManager = require("view/WorkspaceManager"),
SnapshotStore = require("core-ai/AISnapshotStore"),
EventDispatcher = require("utils/EventDispatcher"),
+ StringUtils = require("utils/StringUtils"),
Strings = require("strings");
// filePath → previous content before edit, for undo/snapshot support
@@ -47,6 +49,146 @@ define(function (require, exports, module) {
// Last screenshot base64 data, for displaying in tool indicators
let _lastScreenshotBase64 = null;
+ // Banner / live preview mode state
+ let _activeExecJsCount = 0;
+ let _savedLivePreviewMode = null;
+ let _bannerDismissed = false;
+ let _bannerEl = null;
+ let _bannerStyleInjected = false;
+ let _bannerAutoHideTimer = null;
+
+ /**
+ * Inject banner CSS once into the document head.
+ */
+ function _injectBannerStyles() {
+ if (_bannerStyleInjected) {
+ return;
+ }
+ _bannerStyleInjected = true;
+ const style = document.createElement("style");
+ style.textContent =
+ "@keyframes ai-banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }" +
+ ".ai-lp-banner {" +
+ " position: absolute; top: 0; left: 0; right: 0; bottom: 0;" +
+ " display: flex; align-items: center; justify-content: center; gap: 8px;" +
+ " background: rgba(24,24,28,0.52);" +
+ " backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);" +
+ " z-index: 10; border-radius: 3px;" +
+ " font-size: 12px; color: #e0e0e0; pointer-events: auto;" +
+ " transition: opacity 0.3s ease;" +
+ "}" +
+ ".ai-lp-banner .ai-lp-banner-icon {" +
+ " color: #66bb6a; animation: ai-banner-pulse 1.5s ease-in-out infinite;" +
+ "}" +
+ ".ai-lp-banner .ai-lp-banner-close {" +
+ " position: absolute; right: 6px; top: 50%; transform: translateY(-50%);" +
+ " background: none; border: none; color: #aaa; cursor: pointer;" +
+ " font-size: 14px; padding: 2px 5px; line-height: 1;" +
+ "}" +
+ ".ai-lp-banner .ai-lp-banner-close:hover { color: #fff; }";
+ document.head.appendChild(style);
+ }
+
+ /**
+ * Show a banner overlay on the live preview toolbar.
+ * @param {string} text - Banner message text
+ */
+ function _showBanner(text) {
+ if (_bannerDismissed) {
+ return;
+ }
+ _injectBannerStyles();
+ const toolbar = document.getElementById("live-preview-plugin-toolbar");
+ if (!toolbar) {
+ return;
+ }
+ // Ensure toolbar can host absolutely positioned children
+ if (getComputedStyle(toolbar).position === "static") {
+ toolbar.style.position = "relative";
+ }
+ if (_bannerEl && _bannerEl.parentNode) {
+ // Update text on existing banner
+ const textSpan = _bannerEl.querySelector(".ai-lp-banner-text");
+ if (textSpan) {
+ textSpan.textContent = text;
+ }
+ _bannerEl.style.opacity = "1";
+ return;
+ }
+ const banner = document.createElement("div");
+ banner.className = "ai-lp-banner";
+ banner.innerHTML =
+ '' +
+ '