diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index c58bb98cf..00f9e5d97 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -129,7 +129,7 @@ exports.checkAvailability = async function () { * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete */ exports.sendPrompt = async function (params) { - const { prompt, projectPath, sessionAction, model, locale } = params; + const { prompt, projectPath, sessionAction, model, locale, selectionContext } = params; const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); // Handle session @@ -145,8 +145,17 @@ exports.sendPrompt = async function (params) { currentAbortController = new AbortController(); + // 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; + } + // Run the query asynchronously — don't await here so we return requestId immediately - _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal, locale) + _runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale) .catch(err => { console.error("[Phoenix AI] Query error:", err); }); @@ -207,11 +216,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) const queryOptions = { cwd: projectPath || process.cwd(), - maxTurns: 10, + maxTurns: undefined, allowedTools: [ "Read", "Edit", "Write", "Glob", "Grep", "mcp__phoenix-editor__getEditorState", - "mcp__phoenix-editor__takeScreenshot" + "mcp__phoenix-editor__takeScreenshot", + "mcp__phoenix-editor__execJsInLivePreview", + "mcp__phoenix-editor__controlEditor" ], mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: "acceptEdits", diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index a1c2eb097..52372d8c4 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -21,9 +21,10 @@ /** * MCP server factory for exposing Phoenix editor context to Claude Code. * - * Provides two tools: + * Provides three tools: * - getEditorState: returns active file, working set, and live preview file * - takeScreenshot: captures a screenshot of the Phoenix window as base64 PNG + * - execJsInLivePreview: executes JS in the live preview iframe * * Uses the Claude Code SDK's in-process MCP server support (createSdkMcpServer / tool). */ @@ -40,7 +41,9 @@ const { z } = require("zod"); function createEditorMcpServer(sdkModule, nodeConnector) { const getEditorStateTool = sdkModule.tool( "getEditorState", - "Get the current Phoenix editor state: active file, working set (open files), and live preview file.", + "Get the current Phoenix editor state: active file, working set (open files), live preview file, " + + "and cursor/selection info (current line text with surrounding context, or selected text). " + + "Long lines are trimmed to 200 chars and selections to 10K chars — use the Read tool for full content.", {}, async function () { try { @@ -59,8 +62,12 @@ function createEditorMcpServer(sdkModule, nodeConnector) { const takeScreenshotTool = sdkModule.tool( "takeScreenshot", - "Take a screenshot of the Phoenix Code editor window. Returns a PNG image.", - { selector: z.string().optional().describe("Optional CSS selector to capture a specific element") }, + "Take a screenshot of the Phoenix Code editor window. Returns a PNG image. " + + "Prefer capturing specific regions instead of the full page: " + + "use selector '#panel-live-preview-frame' for the live preview content, " + + "or '.editor-holder' for the code editor area. " + + "Only omit the selector when you need to see the full application layout.", + { selector: z.string().optional().describe("CSS selector to capture a specific element. Use '#panel-live-preview-frame' for the live preview, '.editor-holder' for the code editor.") }, async function (args) { try { const result = await nodeConnector.execPeer("takeScreenshot", { @@ -84,9 +91,86 @@ function createEditorMcpServer(sdkModule, nodeConnector) { } ); + const execJsInLivePreviewTool = sdkModule.tool( + "execJsInLivePreview", + "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") }, + async function (args) { + try { + const result = await nodeConnector.execPeer("execJsInLivePreview", { + code: args.code + }); + if (result.error) { + return { + content: [{ type: "text", text: "Error: " + result.error }], + isError: true + }; + } + return { + content: [{ type: "text", text: result.result || "undefined" }] + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error executing JS in live preview: " + err.message }], + isError: true + }; + } + } + ); + + const controlEditorTool = sdkModule.tool( + "controlEditor", + "Control the Phoenix editor: open/close files, navigate to lines, and select text ranges. " + + "Accepts an array of operations to batch multiple actions in one call. " + + "All line and ch (column) parameters are 1-based.\n\n" + + "Operations:\n" + + "- open: Open a file in the active pane. Params: filePath\n" + + "- close: Close a file (force, no save prompt). Params: filePath\n" + + "- 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", + { + operations: z.array(z.object({ + operation: z.enum(["open", "close", "openInWorkingSet", "setSelection", "setCursorPos"]), + filePath: z.string().describe("Absolute path to the file"), + 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") + })).describe("Array of editor operations to execute sequentially") + }, + async function (args) { + const results = []; + let hasError = false; + for (const op of args.operations) { + try { + const result = await nodeConnector.execPeer("controlEditor", op); + results.push(result); + if (!result.success) { + hasError = true; + } + } catch (err) { + results.push({ success: false, error: err.message }); + hasError = true; + } + } + return { + content: [{ type: "text", text: JSON.stringify(results) }], + isError: hasError + }; + } + ); + return sdkModule.createSdkMcpServer({ name: "phoenix-editor", - tools: [getEditorStateTool, takeScreenshotTool] + tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool] }); } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 7411047e7..396371f25 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -29,7 +29,10 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), ProjectManager = require("project/ProjectManager"), + EditorManager = require("editor/EditorManager"), FileSystem = require("filesystem/FileSystem"), + LiveDevMain = require("LiveDevelopment/main"), + WorkspaceManager = require("view/WorkspaceManager"), SnapshotStore = require("core-ai/AISnapshotStore"), PhoenixConnectors = require("core-ai/aiPhoenixConnectors"), Strings = require("strings"), @@ -51,6 +54,17 @@ define(function (require, exports, module) { let _toolStreamStaleTimer = null; // timer to start rotating activity text let _toolStreamRotateTimer = null; // interval for cycling activity phrases + // Context bar state + let _selectionDismissed = false; // user dismissed selection chip + let _lastSelectionInfo = null; // {filePath, fileName, startLine, endLine, selectedText} + let _lastCursorLine = null; // cursor line when no selection + let _lastCursorFile = null; // file name for cursor chip + let _cursorDismissed = false; // user dismissed cursor chip + let _cursorDismissedLine = null; // line that was dismissed + let _livePreviewActive = false; // live preview panel is open + let _livePreviewDismissed = false; // user dismissed live preview chip + let $contextBar; // DOM ref + // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; @@ -75,6 +89,7 @@ define(function (require, exports, module) { '' + Strings.AI_CHAT_THINKING + '' + '' + '
' + + '
' + '
' + '' + '' + + '' + ); + $lpChip.find(".ai-context-chip-close").on("click", function () { + _livePreviewDismissed = true; + _renderContextBar(); + }); + $contextBar.append($lpChip); + } + + // Selection or cursor chip + if (_lastSelectionInfo && !_selectionDismissed) { + const label = StringUtils.format(Strings.AI_CHAT_CONTEXT_SELECTION, + _lastSelectionInfo.startLine, _lastSelectionInfo.endLine) + + " in " + _lastSelectionInfo.fileName; + const $chip = $( + '' + + '' + + '' + + '' + + '' + ); + $chip.find(".ai-context-chip-label").text(label); + $chip.find(".ai-context-chip-close").on("click", function () { + _selectionDismissed = true; + _renderContextBar(); + }); + $contextBar.append($chip); + } else if (_lastCursorLine !== null && !_lastSelectionInfo && !_cursorDismissed) { + const label = StringUtils.format(Strings.AI_CHAT_CONTEXT_CURSOR, _lastCursorLine) + + " in " + _lastCursorFile; + const $cursorChip = $( + '' + + '' + + '' + + '' + + '' + ); + $cursorChip.find(".ai-context-chip-label").text(label); + $cursorChip.find(".ai-context-chip-close").on("click", function () { + _cursorDismissed = true; + _cursorDismissedLine = _lastCursorLine; + _renderContextBar(); + }); + $contextBar.append($cursorChip); + } + + // Toggle visibility + $contextBar.toggleClass("has-chips", $contextBar.children().length > 0); + } + /** * Send the current input as a message to Claude. */ @@ -258,11 +503,27 @@ define(function (require, exports, module) { const prompt = text; console.log("[AI UI] Sending prompt:", text.slice(0, 60)); + // Gather selection context if available and not dismissed + let selectionContext = null; + if (_lastSelectionInfo && !_selectionDismissed && _lastSelectionInfo.selectedText) { + let selectedText = _lastSelectionInfo.selectedText; + if (selectedText.length > 10000) { + selectedText = selectedText.slice(0, 10000); + } + selectionContext = { + filePath: _lastSelectionInfo.filePath, + startLine: _lastSelectionInfo.startLine, + endLine: _lastSelectionInfo.endLine, + selectedText: selectedText + }; + } + _nodeConnector.execPeer("sendPrompt", { prompt: prompt, projectPath: projectPath, sessionAction: "continue", - locale: brackets.getLocale() + locale: brackets.getLocale(), + selectionContext: selectionContext }).then(function (result) { _currentRequestId = result.requestId; console.log("[AI UI] RequestId:", result.requestId); @@ -298,6 +559,13 @@ define(function (require, exports, module) { _isStreaming = false; _firstEditInResponse = true; _undoApplied = false; + _selectionDismissed = false; + _lastSelectionInfo = null; + _lastCursorLine = null; + _lastCursorFile = null; + _cursorDismissed = false; + _cursorDismissedLine = null; + _livePreviewDismissed = false; SnapshotStore.reset(); PhoenixConnectors.clearPreviousContentMap(); if ($messages) { @@ -350,7 +618,12 @@ define(function (require, exports, module) { Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: Strings.AI_CHAT_TOOL_EDIT }, Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: Strings.AI_CHAT_TOOL_WRITE }, Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: Strings.AI_CHAT_TOOL_RUN_CMD }, - Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: Strings.AI_CHAT_TOOL_SKILL } + Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: Strings.AI_CHAT_TOOL_SKILL }, + "mcp__phoenix-editor__getEditorState": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_EDITOR_STATE }, + "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 }, + TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS } }; function _onProgress(_event, data) { @@ -391,6 +664,42 @@ define(function (require, exports, module) { } } + /** + * Start an elapsed-time counter on a tool indicator. Called when the tool's + * stale timer fires (no streaming activity for 2s). + */ + function _startElapsedTimer($tool) { + if ($tool.data("elapsedTimer")) { + return; // already running + } + const startTime = $tool.data("startTime") || Date.now(); + const $header = $tool.find(".ai-tool-header"); + let $elapsed = $header.find(".ai-tool-elapsed"); + if (!$elapsed.length) { + $elapsed = $(''); + $header.append($elapsed); + } + function update() { + const secs = Math.floor((Date.now() - startTime) / 1000); + if (secs < 60) { + $elapsed.text(secs + "s"); + } else { + const m = Math.floor(secs / 60); + const s = secs % 60; + $elapsed.text(m + "m " + (s < 10 ? "0" : "") + s + "s"); + } + } + update(); + const timerId = setInterval(function () { + if ($tool.hasClass("ai-tool-done")) { + clearInterval(timerId); + return; + } + update(); + }, 1000); + $tool.data("elapsedTimer", timerId); + } + function _onToolStream(_event, data) { const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId; _traceToolStreamCounts[uniqueToolId] = (_traceToolStreamCounts[uniqueToolId] || 0) + 1; @@ -436,6 +745,7 @@ define(function (require, exports, module) { if ($livePreview.length && !$tool.hasClass("ai-tool-done")) { $livePreview.text(phrases[idx]); } + _startElapsedTimer($tool); _toolStreamRotateTimer = setInterval(function () { idx = (idx + 1) % phrases.length; const $p = $tool.find(".ai-tool-preview"); @@ -490,7 +800,8 @@ define(function (require, exports, module) { Edit: "new_string", Bash: "command", Grep: "pattern", - Glob: "pattern" + Glob: "pattern", + "mcp__phoenix-editor__execJsInLivePreview": "code" }[toolName]; if (!interestingKey) { @@ -862,6 +1173,7 @@ define(function (require, exports, module) { if ($target.length) { try { $target.html(marked.parse(_segmentText, { breaks: true, gfm: true })); + _enhanceColorCodes($target); } catch (e) { $target.text(_segmentText); } @@ -869,6 +1181,65 @@ define(function (require, exports, module) { } } + const HEX_COLOR_RE = /#[a-f0-9]{3,8}\b/gi; + + /** + * Scan text nodes inside an element for hex color codes and wrap each + * with an inline swatch element showing the actual color. + */ + function _enhanceColorCodes($el) { + // Walk text nodes inside the element, but skip
 blocks and already-enhanced swatches
+        const walker = document.createTreeWalker(
+            $el[0],
+            NodeFilter.SHOW_TEXT,
+            {
+                acceptNode: function (node) {
+                    const parent = node.parentNode;
+                    if (parent.closest && parent.closest("pre, .ai-color-swatch")) {
+                        return NodeFilter.FILTER_REJECT;
+                    }
+                    return HEX_COLOR_RE.test(node.nodeValue)
+                        ? NodeFilter.FILTER_ACCEPT
+                        : NodeFilter.FILTER_REJECT;
+                }
+            }
+        );
+
+        const textNodes = [];
+        let n;
+        while ((n = walker.nextNode())) {
+            textNodes.push(n);
+        }
+
+        textNodes.forEach(function (textNode) {
+            const text = textNode.nodeValue;
+            HEX_COLOR_RE.lastIndex = 0;
+            const frag = document.createDocumentFragment();
+            let lastIndex = 0;
+            let match;
+            while ((match = HEX_COLOR_RE.exec(text)) !== null) {
+                // Append any text before the match
+                if (match.index > lastIndex) {
+                    frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
+                }
+                const color = match[0];
+                const swatch = document.createElement("span");
+                swatch.className = "ai-color-swatch";
+                swatch.style.setProperty("--swatch-color", color);
+                const preview = document.createElement("span");
+                preview.className = "ai-color-swatch-preview";
+                swatch.appendChild(preview);
+                swatch.appendChild(document.createTextNode(color));
+                frag.appendChild(swatch);
+                lastIndex = HEX_COLOR_RE.lastIndex;
+            }
+            if (lastIndex < text.length) {
+                frag.appendChild(document.createTextNode(text.slice(lastIndex)));
+            }
+            textNode.parentNode.replaceChild(frag, textNode);
+        });
+    }
+
     function _appendToolIndicator(toolName, toolId) {
         // Remove thinking indicator on first content
         if (!_hasReceivedContent) {
@@ -899,6 +1270,7 @@ define(function (require, exports, module) {
         $tool.find(".ai-tool-label").text(config.label + "...");
         $tool.css("--tool-color", config.color);
         $tool.attr("data-tool-icon", config.icon);
+        $tool.data("startTime", Date.now());
         $messages.append($tool);
         _scrollToBottom();
     }
@@ -926,8 +1298,46 @@ define(function (require, exports, module) {
         // Update label to include summary
         $tool.find(".ai-tool-label").text(detail.summary);
 
-        // Add expandable detail if available
-        if (detail.lines && detail.lines.length) {
+        // For TodoWrite, render a mini task-list widget and auto-expand
+        if (toolName === "TodoWrite" && toolInput && toolInput.todos) {
+            const $detail = $('
'); + const $todoList = $('
'); + toolInput.todos.forEach(function (todo) { + let iconClass, statusClass; + if (todo.status === "completed") { + iconClass = "fa-solid fa-circle-check"; + statusClass = "completed"; + } else if (todo.status === "in_progress") { + iconClass = "fa-solid fa-spinner fa-spin"; + statusClass = "in_progress"; + } else { + iconClass = "fa-regular fa-circle"; + statusClass = "pending"; + } + const $item = $( + '
' + + '' + + '' + + '
' + ); + $item.find(".ai-todo-content").text(todo.content); + $todoList.append($item); + }); + $detail.append($todoList); + $tool.append($detail); + $tool.addClass("ai-tool-expanded"); + $tool.find(".ai-tool-header").on("click", function () { + $tool.toggleClass("ai-tool-expanded"); + }).css("cursor", "pointer"); + } else if (toolName === "mcp__phoenix-editor__takeScreenshot") { + const $detail = $('
'); + $tool.append($detail); + $tool.data("awaitingScreenshot", true); + $tool.find(".ai-tool-header").on("click", function () { + $tool.toggleClass("ai-tool-expanded"); + }).css("cursor", "pointer"); + } else if (detail.lines && detail.lines.length) { + // Add expandable detail if available const $detail = $('
'); detail.lines.forEach(function (line) { $detail.append($('
').text(line)); @@ -955,6 +1365,14 @@ define(function (require, exports, module) { clearTimeout(_toolStreamStaleTimer); clearInterval(_toolStreamRotateTimer); + // Stop the elapsed timer and remove the element + const elapsedTimer = $tool.data("elapsedTimer"); + if (elapsedTimer) { + clearInterval(elapsedTimer); + $tool.removeData("elapsedTimer"); + } + $tool.find(".ai-tool-elapsed").remove(); + // Delay marking as done so the streaming preview stays visible briefly. // The ai-tool-done class hides the preview via CSS; deferring it lets the // browser paint the preview before it disappears. @@ -1012,8 +1430,105 @@ define(function (require, exports, module) { summary: input.skill ? StringUtils.format(Strings.AI_CHAT_TOOL_SKILL_NAME, input.skill) : Strings.AI_CHAT_TOOL_SKILL, lines: input.args ? [input.args] : [] }; - default: - return { summary: toolName, lines: [] }; + case "mcp__phoenix-editor__getEditorState": + return { summary: Strings.AI_CHAT_TOOL_EDITOR_STATE, lines: [] }; + case "mcp__phoenix-editor__takeScreenshot": { + let screenshotTarget = Strings.AI_CHAT_TOOL_SCREENSHOT_FULL_PAGE; + if (input.selector) { + if (input.selector.indexOf("live-preview") !== -1 || input.selector.indexOf("panel-live-preview") !== -1) { + screenshotTarget = Strings.AI_CHAT_TOOL_SCREENSHOT_LIVE_PREVIEW; + } else { + screenshotTarget = input.selector; + } + } + return { + summary: StringUtils.format(Strings.AI_CHAT_TOOL_SCREENSHOT_OF, screenshotTarget), + lines: input.selector ? [input.selector] : [] + }; + } + case "mcp__phoenix-editor__execJsInLivePreview": + return { + summary: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS, + lines: input.code ? input.code.split("\n").slice(0, 20) : [] + }; + case "TodoWrite": { + const todos = input.todos || []; + const completed = todos.filter(function (t) { return t.status === "completed"; }).length; + return { + summary: StringUtils.format(Strings.AI_CHAT_TOOL_TASKS_SUMMARY, completed, todos.length), + lines: [] + }; + } + case "mcp__phoenix-editor__controlEditor": { + // Multi-operation batch format + if (input.operations && input.operations.length) { + if (input.operations.length === 1) { + // Single operation — show its detail + const op = input.operations[0]; + const fn = (op.filePath || "").split("/").pop(); + let opSummary; + switch (op.operation) { + case "open": + case "openInWorkingSet": + opSummary = "Open " + fn; + break; + case "close": + opSummary = "Close " + fn; + break; + case "setCursorPos": + opSummary = "Go to L" + (op.line || "?") + " in " + fn; + break; + case "setSelection": + opSummary = "Select L" + (op.startLine || "?") + "-L" + (op.endLine || "?") + " in " + fn; + break; + default: + opSummary = Strings.AI_CHAT_TOOL_CONTROL_EDITOR; + } + return { summary: opSummary, lines: [op.filePath || ""] }; + } + // Multiple operations — summarize + const count = input.operations.length; + const opTypes = {}; + input.operations.forEach(function (op) { + const t = op.operation || "open"; + opTypes[t] = (opTypes[t] || 0) + 1; + }); + const parts = Object.keys(opTypes).map(function (t) { + const label = (t === "open" || t === "openInWorkingSet") ? "Open" : + t === "close" ? "Close" : + t === "setCursorPos" ? "Navigate" : + t === "setSelection" ? "Select" : t; + return label + " " + opTypes[t]; + }); + return { summary: parts.join(", ") + " files", lines: [] }; + } + // Legacy single-operation format + const fileName = (input.filePath || "").split("/").pop(); + let opSummary; + switch (input.operation) { + case "open": + case "openInWorkingSet": + opSummary = "Open " + fileName; + break; + case "close": + opSummary = "Close " + fileName; + break; + case "setCursorPos": + opSummary = "Go to L" + (input.line || "?") + " in " + fileName; + break; + case "setSelection": + opSummary = "Select L" + (input.startLine || "?") + "-L" + (input.endLine || "?") + " in " + fileName; + break; + default: + opSummary = Strings.AI_CHAT_TOOL_CONTROL_EDITOR; + } + return { summary: opSummary, lines: [input.filePath || ""] }; + } + default: { + // Fallback: use TOOL_CONFIG label if available + const cfg = TOOL_CONFIG[toolName]; + return { summary: cfg ? cfg.label : toolName, lines: [] }; + } } } @@ -1026,6 +1541,13 @@ define(function (require, exports, module) { function _finishActiveTools() { $messages.find(".ai-msg-tool:not(.ai-tool-done)").each(function () { const $prev = $(this); + // Clear any running elapsed timer + const et = $prev.data("elapsedTimer"); + if (et) { + clearInterval(et); + $prev.removeData("elapsedTimer"); + } + $prev.find(".ai-tool-elapsed").remove(); // _updateToolIndicator already ran — let the delayed timeout handle it if ($prev.find(".ai-tool-icon").length) { return; diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js index 1807fd08c..41a716e50 100644 --- a/src/core-ai/aiPhoenixConnectors.js +++ b/src/core-ai/aiPhoenixConnectors.js @@ -32,13 +32,21 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), MainViewManager = require("view/MainViewManager"), + EditorManager = require("editor/EditorManager"), FileSystem = require("filesystem/FileSystem"), + LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"), + LiveDevMain = require("LiveDevelopment/main"), + WorkspaceManager = require("view/WorkspaceManager"), SnapshotStore = require("core-ai/AISnapshotStore"), + EventDispatcher = require("utils/EventDispatcher"), Strings = require("strings"); // filePath → previous content before edit, for undo/snapshot support const _previousContentMap = {}; + // Last screenshot base64 data, for displaying in tool indicators + let _lastScreenshotBase64 = null; + // --- Editor state --- /** @@ -74,11 +82,63 @@ define(function (require, exports, module) { } } - return { + const result = { activeFile: activeFilePath, workingSet: workingSetPaths, livePreviewFile: livePreviewFile }; + + // Include cursor/selection info from the active editor + const editor = EditorManager.getActiveEditor(); + if (editor) { + const doc = editor.document; + const totalLines = doc.getLine(doc.getText().split("\n").length - 1) !== undefined + ? doc.getText().split("\n").length : 0; + + if (editor.hasSelection()) { + const sel = editor.getSelection(); + let selectedText = editor.getSelectedText(); + if (selectedText.length > 10000) { + selectedText = selectedText.slice(0, 10000) + "...(truncated, use Read tool for full content)"; + } + result.cursorInfo = { + hasSelection: true, + startLine: sel.start.line + 1, + endLine: sel.end.line + 1, + selectedText: selectedText, + totalLines: totalLines + }; + } else { + const cursor = editor.getCursorPos(); + const lineNum = cursor.line; + const lineText = doc.getLine(lineNum) || ""; + // Include a few surrounding lines for context + const contextStart = Math.max(0, lineNum - 2); + const contextEnd = Math.min(totalLines - 1, lineNum + 2); + const MAX_LINE_LEN = 200; + const contextLines = []; + for (let i = contextStart; i <= contextEnd; i++) { + const prefix = (i === lineNum) ? "> " : " "; + let text = doc.getLine(i) || ""; + if (text.length > MAX_LINE_LEN) { + text = text.slice(0, MAX_LINE_LEN) + "..."; + } + contextLines.push(prefix + (i + 1) + ": " + text); + } + const trimmedLineText = lineText.length > MAX_LINE_LEN + ? lineText.slice(0, MAX_LINE_LEN) + "..." : lineText; + result.cursorInfo = { + hasSelection: false, + line: lineNum + 1, + column: cursor.ch + 1, + lineText: trimmedLineText, + context: contextLines.join("\n"), + totalLines: totalLines + }; + } + } + + return result; } // --- Screenshot --- @@ -104,6 +164,8 @@ define(function (require, exports, module) { binary += String.fromCharCode.apply(null, chunk); } const base64 = btoa(binary); + _lastScreenshotBase64 = base64; + exports.trigger("screenshotCaptured", base64); deferred.resolve({ base64: base64 }); }) .catch(function (err) { @@ -242,10 +304,175 @@ define(function (require, exports, module) { }); } + /** + * Get the last captured screenshot as base64 PNG. + * @return {string|null} + */ + function getLastScreenshot() { + return _lastScreenshotBase64; + } + + // --- Live preview JS execution --- + + /** + * Execute JavaScript in the live preview iframe. + * Reuses the pattern from phoenix-builder-client.js: fast path if connected, + * otherwise auto-opens live preview and waits for connection. + * @param {Object} params - { code: string } + * @return {$.Promise} resolves with { result } or { error } + */ + function execJsInLivePreview(params) { + const deferred = new $.Deferred(); + + function _evaluate() { + LiveDevProtocol.evaluate(params.code) + .done(function (evalResult) { + deferred.resolve({ result: JSON.stringify(evalResult) }); + }) + .fail(function (err) { + deferred.resolve({ error: (err && err.message) || String(err) || "evaluate() failed" }); + }); + } + + // Fast path: already connected + if (LiveDevProtocol.getConnectionIds().length > 0) { + _evaluate(); + return deferred.promise(); + } + + // 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.aiExecJsLivePreview"); + } + + const timeoutTimer = setTimeout(function () { + if (settled) { return; } + cleanup(); + deferred.resolve({ error: "Timed out waiting for live preview connection (30s)" }); + }, TIMEOUT); + + function onConnected() { + if (settled) { return; } + cleanup(); + clearTimeout(timeoutTimer); + _evaluate(); + } + + LiveDevProtocol.on("ConnectionConnect.aiExecJsLivePreview", 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); + + return deferred.promise(); + } + + // --- Editor control --- + + /** + * Control the editor: open/close files, set cursor, set selection. + * Called from the node-side MCP server via execPeer. + * @param {Object} params - { operation, filePath, startLine, startCh, endLine, endCh, line, ch } + * @return {$.Promise} resolves with { success: true } or { success: false, error: "..." } + */ + function controlEditor(params) { + const deferred = new $.Deferred(); + const vfsPath = SnapshotStore.realToVfsPath(params.filePath); + + switch (params.operation) { + case "open": + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) + .done(function () { deferred.resolve({ success: true }); }) + .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + break; + + case "close": { + const file = FileSystem.getFileForPath(vfsPath); + CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) + .done(function () { deferred.resolve({ success: true }); }) + .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + break; + } + + case "openInWorkingSet": + CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: vfsPath }) + .done(function () { deferred.resolve({ success: true }); }) + .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + break; + + case "setSelection": + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) + .done(function () { + const editor = EditorManager.getActiveEditor(); + if (editor) { + editor.setSelection( + { line: params.startLine - 1, ch: params.startCh - 1 }, + { line: params.endLine - 1, ch: params.endCh - 1 }, + true + ); + deferred.resolve({ success: true }); + } else { + deferred.resolve({ success: false, error: "No active editor after opening file" }); + } + }) + .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + break; + + case "setCursorPos": + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) + .done(function () { + const editor = EditorManager.getActiveEditor(); + if (editor) { + editor.setCursorPos(params.line - 1, params.ch - 1, true); + deferred.resolve({ success: true }); + } else { + deferred.resolve({ success: false, error: "No active editor after opening file" }); + } + }) + .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + break; + + default: + deferred.resolve({ success: false, error: "Unknown operation: " + params.operation }); + } + + return deferred.promise(); + } + exports.getEditorState = getEditorState; exports.takeScreenshot = takeScreenshot; exports.getFileContent = getFileContent; exports.applyEditToBuffer = applyEditToBuffer; exports.getPreviousContent = getPreviousContent; exports.clearPreviousContentMap = clearPreviousContentMap; + exports.getLastScreenshot = getLastScreenshot; + exports.execJsInLivePreview = execJsInLivePreview; + exports.controlEditor = controlEditor; + + EventDispatcher.makeEventDispatcher(exports); }); diff --git a/src/core-ai/main.js b/src/core-ai/main.js index 2a5618299..48bab8a91 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -50,6 +50,14 @@ define(function (require, exports, module) { return PhoenixConnectors.takeScreenshot(params); }; + exports.execJsInLivePreview = async function (params) { + return PhoenixConnectors.execJsInLivePreview(params); + }; + + exports.controlEditor = async function (params) { + return PhoenixConnectors.controlEditor(params); + }; + AppInit.appReady(function () { SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 65be0c33d..21a76ca79 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1820,6 +1820,15 @@ define({ "AI_CHAT_TOOL_WRITE": "Write", "AI_CHAT_TOOL_RUN_CMD": "Run command", "AI_CHAT_TOOL_SKILL": "Skill", + "AI_CHAT_TOOL_EDITOR_STATE": "Editor state", + "AI_CHAT_TOOL_SCREENSHOT": "Screenshot", + "AI_CHAT_TOOL_SCREENSHOT_OF": "Screenshot of {0}", + "AI_CHAT_TOOL_SCREENSHOT_LIVE_PREVIEW": "live preview", + "AI_CHAT_TOOL_SCREENSHOT_FULL_PAGE": "full page", + "AI_CHAT_TOOL_LIVE_PREVIEW_JS": "Live Preview JS", + "AI_CHAT_TOOL_CONTROL_EDITOR": "Editor", + "AI_CHAT_TOOL_TASKS": "Tasks", + "AI_CHAT_TOOL_TASKS_SUMMARY": "{0} of {1} tasks done", "AI_CHAT_TOOL_SEARCHED": "Searched: {0}", "AI_CHAT_TOOL_GREP": "Grep: {0}", "AI_CHAT_TOOL_READ_FILE": "Read {0}", @@ -1847,6 +1856,9 @@ define({ "AI_CHAT_WORKING": "Working...", "AI_CHAT_WRITING": "Writing...", "AI_CHAT_PROCESSING": "Processing...", + "AI_CHAT_CONTEXT_SELECTION": "Selection L{0}-L{1}", + "AI_CHAT_CONTEXT_CURSOR": "Line {0}", + "AI_CHAT_CONTEXT_LIVE_PREVIEW": "Live Preview", // demo start - Phoenix Code Playground - Interactive Onboarding "DEMO_SECTION1_TITLE": "Edit in Live Preview", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 972c6a744..911846120 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -28,7 +28,7 @@ overflow: hidden; background-color: @bc-sidebar-bg; color: @project-panel-text-1; - font-size: 12px; + font-size: @sidebar-content-font-size; } /* ── Header ─────────────────────────────────────────────────────────── */ @@ -42,7 +42,7 @@ .ai-chat-title { font-weight: 400; - font-size: 15px; + font-size: @label-font-size; color: @project-panel-text-2; line-height: 19px; } @@ -54,7 +54,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: 13px; + font-size: @menu-item-font-size; padding: 0 8px; border-radius: 3px; cursor: pointer; @@ -84,7 +84,7 @@ max-width: 100%; .ai-msg-label { - font-size: 10px; + font-size: @sidebar-xs-font-size; color: @project-panel-text-2; margin-bottom: 3px; font-weight: 600; @@ -92,7 +92,7 @@ } .ai-msg-content { - font-size: 12px; + font-size: @sidebar-content-font-size; line-height: 1.55; white-space: normal; word-wrap: break-word; @@ -152,7 +152,7 @@ background-color: rgba(255, 255, 255, 0.08); padding: 1px 4px; border-radius: 3px; - font-size: 11px; + font-size: @sidebar-small-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; } @@ -168,7 +168,7 @@ background: none; padding: 0; border-radius: 0; - font-size: 11px; + font-size: @sidebar-small-font-size; line-height: 1.5; color: @project-panel-text-1; } @@ -195,29 +195,34 @@ color: @project-panel-text-1; } - h1 { font-size: 14px; margin: 12px 0 4px 0; } - h2 { font-size: 13px; margin: 10px 0 4px 0; } - h3 { font-size: 12px; margin: 8px 0 3px 0; } - h4 { font-size: 12px; margin: 6px 0 2px 0; opacity: 0.85; } + h1 { font-size: @label-font-size; margin: 12px 0 4px 0; } + h2 { font-size: @menu-item-font-size; margin: 10px 0 4px 0; } + h3 { font-size: @sidebar-content-font-size; margin: 8px 0 3px 0; } + h4 { font-size: @sidebar-content-font-size; margin: 6px 0 2px 0; opacity: 0.85; } table { - width: 100%; + display: block; + overflow-x: auto; border-collapse: collapse; margin: 6px 0; - font-size: 11px; + font-size: @sidebar-small-font-size; + cursor: default; } th, td { padding: 4px 8px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.06); + max-width: 50%; + white-space: nowrap; + overflow-wrap: normal; } th { font-weight: 600; color: @project-panel-text-2; border-bottom-color: rgba(255, 255, 255, 0.1); - font-size: 10px; + font-size: @sidebar-xs-font-size; text-transform: uppercase; letter-spacing: 0.3px; } @@ -246,7 +251,7 @@ } .ai-tool-icon { - font-size: 10px; + font-size: @sidebar-xs-font-size; width: 14px; text-align: center; flex-shrink: 0; @@ -264,7 +269,7 @@ } .ai-tool-label { - font-size: 11px; + font-size: @sidebar-small-font-size; color: @project-panel-text-2; line-height: 1.3; @@ -279,8 +284,24 @@ padding: 0 8px 4px 28px; } + .ai-tool-screenshot { + max-width: 100%; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 4px; + max-height: 120px; + object-fit: cover; + object-position: top left; + cursor: pointer; + transition: max-height 0.2s ease; + } + + &.ai-tool-expanded .ai-tool-screenshot.expanded { + max-height: none; + } + .ai-tool-detail-line { - font-size: 10px; + font-size: @sidebar-xs-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.7; @@ -290,7 +311,7 @@ } .ai-tool-preview { - font-size: 10px; + font-size: @sidebar-xs-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.5; @@ -336,7 +357,7 @@ .ai-tool-diff-toggle { background: none; border: none; - font-size: 10px; + font-size: @sidebar-xs-font-size; color: @project-panel-text-2; padding: 1px 4px; cursor: pointer; @@ -351,7 +372,7 @@ .ai-tool-diff { display: none; padding: 4px 8px 4px 28px; - font-size: 11px; + font-size: @sidebar-small-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; line-height: 1.5; overflow-x: auto; @@ -373,10 +394,51 @@ } .ai-tool-edit-error { - font-size: 11px; + font-size: @sidebar-small-font-size; color: #e88; padding: 3px 8px 3px 28px; } + + .ai-tool-elapsed { + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + opacity: 0.5; + margin-left: auto; + } +} + +/* ── TodoWrite task list widget ────────────────────────────────────── */ +.ai-todo-list { + padding: 2px 8px 4px 28px; +} + +.ai-todo-item { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 2px 0; + font-size: @sidebar-small-font-size; +} + +.ai-todo-icon { + width: 14px; + text-align: center; + flex-shrink: 0; + font-size: @sidebar-xs-font-size; + + &.completed { color: #66bb6a; } + &.in_progress { color: #e8a838; } + &.pending { color: @project-panel-text-2; opacity: 0.4; } +} + +.ai-todo-content { + color: @project-panel-text-2; + line-height: 1.4; + + &.completed { + text-decoration: line-through; + opacity: 0.6; + } } @keyframes ai-spin { @@ -403,6 +465,25 @@ } } +/* ── Inline color swatch ───────────────────────────────────────────── */ +.ai-color-swatch { + display: inline-flex; + align-items: center; + gap: 3px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + font-size: @sidebar-small-font-size; +} + +.ai-color-swatch-preview { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + background-color: var(--swatch-color); + border: 1px solid rgba(255, 255, 255, 0.15); + flex-shrink: 0; +} + @keyframes ai-dot-pulse { 0%, 60%, 100% { opacity: 0.2; transform: scale(0.8); } 30% { opacity: 0.8; transform: scale(1); } @@ -420,7 +501,7 @@ display: flex; align-items: center; justify-content: space-between; - font-size: 11px; + font-size: @sidebar-small-font-size; font-weight: 600; color: @project-panel-text-2; margin-bottom: 4px; @@ -430,7 +511,7 @@ background: none; border: 1px solid rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.9); - font-size: 10px; + font-size: @sidebar-xs-font-size; padding: 1px 8px; border-radius: 3px; cursor: pointer; @@ -460,7 +541,7 @@ } .ai-edit-summary-name { - font-size: 11px; + font-size: @sidebar-small-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; overflow: hidden; @@ -478,7 +559,7 @@ .ai-edit-summary-stats { display: flex; gap: 6px; - font-size: 10px; + font-size: @sidebar-xs-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; flex-shrink: 0; } @@ -504,7 +585,7 @@ background: none; border: 1px dashed rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.7); - font-size: 10px; + font-size: @sidebar-xs-font-size; padding: 2px 14px; border-radius: 3px; cursor: pointer; @@ -554,7 +635,7 @@ color: #e88; border-left: 2px solid rgba(255, 80, 80, 0.4); padding: 4px 8px; - font-size: 11px; + font-size: @sidebar-small-font-size; background-color: rgba(255, 80, 80, 0.04); border-radius: 0 3px 3px 0; } @@ -566,7 +647,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - font-size: 11px; + font-size: @sidebar-small-font-size; color: @project-panel-text-2; flex-shrink: 0; opacity: 0.6; @@ -609,13 +690,77 @@ } } + .ai-chat-context-bar { + display: none; + flex-wrap: wrap; + gap: 4px; + padding: 0 4px 4px 4px; + + &.has-chips { + display: flex; + } + } + + .ai-context-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: @sidebar-xs-font-size; + line-height: 1; + padding: 3px 6px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.12); + color: @project-panel-text-2; + background: rgba(255, 255, 255, 0.04); + max-width: 100%; + overflow: hidden; + + .ai-context-chip-icon { + flex-shrink: 0; + font-size: 10px; + opacity: 0.7; + } + + .ai-context-chip-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ai-context-chip-close { + flex-shrink: 0; + background: none; + border: none; + color: @project-panel-text-2; + font-size: @sidebar-small-font-size; + line-height: 1; + padding: 0 0 0 2px; + cursor: pointer; + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + } + + .ai-context-chip-livepreview { + border-color: rgba(76, 175, 80, 0.3); + .ai-context-chip-icon { color: #66bb6a; } + } + + .ai-context-chip-selection { + border-color: rgba(107, 158, 255, 0.3); + .ai-context-chip-icon { color: #6b9eff; } + } + .ai-chat-textarea { flex: 1; min-width: 0; background: none; border: none !important; color: @project-panel-text-1; - font-size: 12px; + font-size: @sidebar-content-font-size; font-family: inherit; padding: 7px 0 7px 10px; margin: 0; @@ -657,7 +802,7 @@ } i { - font-size: 12px; + font-size: @sidebar-content-font-size; } } @@ -681,7 +826,7 @@ } i { - font-size: 12px; + font-size: @sidebar-content-font-size; } } } @@ -704,14 +849,14 @@ } .ai-unavailable-title { - font-size: 13px; + font-size: @menu-item-font-size; font-weight: 600; color: @project-panel-text-1; margin-bottom: 6px; } .ai-unavailable-message { - font-size: 11px; + font-size: @sidebar-small-font-size; line-height: 1.5; margin-bottom: 12px; opacity: 0.6; @@ -721,7 +866,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: 11px; + font-size: @sidebar-small-font-size; padding: 3px 12px; border-radius: 3px; cursor: pointer; diff --git a/src/styles/brackets_variables.less b/src/styles/brackets_variables.less index 663c67166..b690782ed 100644 --- a/src/styles/brackets_variables.less +++ b/src/styles/brackets_variables.less @@ -33,6 +33,9 @@ @title-font-size: 18px; // headings such as the editor titlebar @label-font-size: 15px; // labels on buttons, menubar, etc. @menu-item-font-size: 14px; // individual menu items +@sidebar-content-font-size: 13px; // body text in sidebar panels (e.g. AI chat) +@sidebar-small-font-size: 12px; // secondary content, inline code, status text +@sidebar-xs-font-size: 11px; // meta info, timestamps, chips /* CSS triangles */ @inline-triangle-size: 9px;