From 43eec3d1b9f23939ca6155352c4ba461b934a895 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 17:11:06 +0530 Subject: [PATCH 1/6] feat: deepen AI chat editor integration with context chips and live preview tools - Add context bar above chat input showing selection/cursor position and live preview status as dismissable chips - Send selected text as context with chat prompts so Claude sees what the user is looking at - Add execJsInLivePreview MCP tool to execute JS in the live preview iframe - Enrich getEditorState with cursor/selection info and surrounding lines - Add human-readable labels for MCP tools (Editor state, Screenshot, Live Preview JS) instead of raw mcp__ identifiers - Show captured screenshots inline as collapsible thumbnails - Guide Claude to prefer specific selectors for screenshots - Remove maxTurns cap so agent runs to completion - Namespace all event listeners with .off before .on to prevent leaks --- src-node/claude-code-agent.js | 18 +- src-node/mcp-editor-tools.js | 49 ++++- src/core-ai/AIChatPanel.js | 294 ++++++++++++++++++++++++++++- src/core-ai/aiPhoenixConnectors.js | 156 ++++++++++++++- src/core-ai/main.js | 4 + src/nls/root/strings.js | 9 + src/styles/Extn-AIChatPanel.less | 80 ++++++++ 7 files changed, 593 insertions(+), 17 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index c58bb98cf..d3c836efe 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,12 @@ 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" ], mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: "acceptEdits", diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index a1c2eb097..efc16cec8 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,41 @@ 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 + }; + } + } + ); + return sdkModule.createSdkMcpServer({ name: "phoenix-editor", - tools: [getEditorStateTool, takeScreenshotTool] + tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool] }); } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 7411047e7..75e340b1d 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 +478,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 +534,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 +593,10 @@ 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 } }; function _onProgress(_event, data) { @@ -490,7 +736,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) { @@ -926,8 +1173,17 @@ 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 screenshot tools, add a detail container that will be populated + // when the screenshot capture completes (via screenshotCaptured event) + 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)); @@ -1012,8 +1268,32 @@ 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) : [] + }; + default: { + // Fallback: use TOOL_CONFIG label if available + const cfg = TOOL_CONFIG[toolName]; + return { summary: cfg ? cfg.label : toolName, lines: [] }; + } } } diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js index 1807fd08c..62e22fcc4 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,102 @@ 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(); + } + exports.getEditorState = getEditorState; exports.takeScreenshot = takeScreenshot; exports.getFileContent = getFileContent; exports.applyEditToBuffer = applyEditToBuffer; exports.getPreviousContent = getPreviousContent; exports.clearPreviousContentMap = clearPreviousContentMap; + exports.getLastScreenshot = getLastScreenshot; + exports.execJsInLivePreview = execJsInLivePreview; + + EventDispatcher.makeEventDispatcher(exports); }); diff --git a/src/core-ai/main.js b/src/core-ai/main.js index 2a5618299..fd4ad0df7 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -50,6 +50,10 @@ define(function (require, exports, module) { return PhoenixConnectors.takeScreenshot(params); }; + exports.execJsInLivePreview = async function (params) { + return PhoenixConnectors.execJsInLivePreview(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..1f3c8e419 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1820,6 +1820,12 @@ 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_SEARCHED": "Searched: {0}", "AI_CHAT_TOOL_GREP": "Grep: {0}", "AI_CHAT_TOOL_READ_FILE": "Read {0}", @@ -1847,6 +1853,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..38338f890 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -279,6 +279,22 @@ 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-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; @@ -609,6 +625,70 @@ } } + .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: 10px; + 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: 9px; + 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: 11px; + 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; From 7e759910c83199a071df3a633b2495d25882c42c Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 18:38:29 +0530 Subject: [PATCH 2/6] feat: add TodoWrite task list rendering, elapsed timer, and batch controlEditor - Render TodoWrite tool as a visual task list with status icons (completed, in-progress, pending) that auto-expands and is collapsible - Show elapsed time counter on long-running tools after 2s of inactivity - Change controlEditor MCP tool to accept an operations array for batch file open/close/navigate actions in a single call - Wire controlEditor through to browser-side with full operation support - Add corresponding i18n strings and LESS styles --- src-node/claude-code-agent.js | 3 +- src-node/mcp-editor-tools.js | 47 +++++++- src/core-ai/AIChatPanel.js | 165 ++++++++++++++++++++++++++++- src/core-ai/aiPhoenixConnectors.js | 73 +++++++++++++ src/core-ai/main.js | 4 + src/nls/root/strings.js | 3 + src/styles/Extn-AIChatPanel.less | 41 +++++++ 7 files changed, 330 insertions(+), 6 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index d3c836efe..00f9e5d97 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -221,7 +221,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) "Read", "Edit", "Write", "Glob", "Grep", "mcp__phoenix-editor__getEditorState", "mcp__phoenix-editor__takeScreenshot", - "mcp__phoenix-editor__execJsInLivePreview" + "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 efc16cec8..52372d8c4 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -123,9 +123,54 @@ function createEditorMcpServer(sdkModule, nodeConnector) { } ); + 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, execJsInLivePreviewTool] + tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool] }); } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 75e340b1d..4dce6c9c4 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -596,7 +596,9 @@ define(function (require, exports, module) { 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__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) { @@ -637,6 +639,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; @@ -682,6 +720,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"); @@ -1146,6 +1185,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(); } @@ -1173,9 +1213,38 @@ define(function (require, exports, module) { // Update label to include summary $tool.find(".ai-tool-label").text(detail.summary); - // For screenshot tools, add a detail container that will be populated - // when the screenshot capture completes (via screenshotCaptured event) - if (toolName === "mcp__phoenix-editor__takeScreenshot") { + // 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); @@ -1211,6 +1280,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. @@ -1289,6 +1366,79 @@ define(function (require, exports, module) { 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]; @@ -1306,6 +1456,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 62e22fcc4..41a716e50 100644 --- a/src/core-ai/aiPhoenixConnectors.js +++ b/src/core-ai/aiPhoenixConnectors.js @@ -392,6 +392,78 @@ define(function (require, exports, module) { 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; @@ -400,6 +472,7 @@ define(function (require, exports, module) { 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 fd4ad0df7..48bab8a91 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -54,6 +54,10 @@ define(function (require, exports, module) { 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 1f3c8e419..21a76ca79 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1826,6 +1826,9 @@ define({ "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}", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 38338f890..7f29426c4 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -393,6 +393,47 @@ color: #e88; padding: 3px 8px 3px 28px; } + + .ai-tool-elapsed { + font-size: 10px; + 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: 11px; +} + +.ai-todo-icon { + width: 14px; + text-align: center; + flex-shrink: 0; + font-size: 10px; + + &.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 { From 47ba5f39916de388c3d00687eeeef5db3b93b073 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 21:53:35 +0530 Subject: [PATCH 3/6] fix: defer AI context chip updates when sidebar tab is inactive Reading editor selection data (getSelection/getSelectedText) from both activeEditorChange and cursorActivity handlers during inline editor operations interferes with the inline editor's cursor position tracking. Guard _updateSelectionChip and _updateLivePreviewChip with an AI tab visibility check, and refresh when the tab becomes active. Also pass the editor instance directly from event handlers to avoid redundant getActiveEditor() calls. --- src/core-ai/AIChatPanel.js | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 4dce6c9c4..d0f4adf5d 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -227,17 +227,21 @@ define(function (require, exports, module) { } if (newEditor) { newEditor.off("cursorActivity.aiContext"); - newEditor.on("cursorActivity.aiContext", _updateSelectionChip); + newEditor.on("cursorActivity.aiContext", function (_evt, editor) { + _updateSelectionChip(editor); + }); } - _updateSelectionChip(); + _updateSelectionChip(newEditor); }); // Bind to current editor if already active const currentEditor = EditorManager.getActiveEditor(); if (currentEditor) { currentEditor.off("cursorActivity.aiContext"); - currentEditor.on("cursorActivity.aiContext", _updateSelectionChip); + currentEditor.on("cursorActivity.aiContext", function (_evt, editor) { + _updateSelectionChip(editor); + }); } - _updateSelectionChip(); + _updateSelectionChip(currentEditor); // Track live preview status — listen to both LiveDev status changes // and panel show/hide events so the chip updates when the panel is closed @@ -254,6 +258,16 @@ define(function (require, exports, module) { WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN + ".aiChat", _updateLivePreviewChip); _updateLivePreviewChip(); + // Refresh context bar when the AI tab becomes active (DOM updates + // are deferred while the tab is hidden to avoid layout interference) + SidebarTabs.off("tabChanged.aiChat"); + SidebarTabs.on("tabChanged.aiChat", function (_event, tabId) { + if (tabId === "ai") { + _updateSelectionChip(); + _updateLivePreviewChip(); + } + }); + // When a screenshot is captured, attach the image to the awaiting tool indicator PhoenixConnectors.off("screenshotCaptured.aiChat"); PhoenixConnectors.on("screenshotCaptured.aiChat", function (_event, base64) { @@ -301,9 +315,17 @@ define(function (require, exports, module) { /** * Update the selection/cursor chip based on the active editor state. + * Skipped when the AI tab isn't active — calling getSelection()/getSelectedText() + * from both activeEditorChange and cursorActivity during inline editor operations + * interferes with the inline editor's cursor position tracking. */ - function _updateSelectionChip() { - const editor = EditorManager.getActiveEditor(); + function _updateSelectionChip(editor) { + if (SidebarTabs.getActiveTab() !== "ai") { + return; + } + if (!editor) { + editor = EditorManager.getActiveEditor(); + } if (!editor) { _lastSelectionInfo = null; _lastCursorLine = null; @@ -360,6 +382,9 @@ define(function (require, exports, module) { * Update the live preview chip based on panel visibility. */ function _updateLivePreviewChip() { + if (SidebarTabs.getActiveTab() !== "ai") { + return; + } const panel = WorkspaceManager.getPanelForID("live-preview-panel"); const wasActive = _livePreviewActive; _livePreviewActive = !!(panel && panel.isVisible()); From 6d616df171da9c5c2cd1b67d75731e91c07794bb Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 22:03:38 +0530 Subject: [PATCH 4/6] chore: better visual styles in ai panel --- src/styles/Extn-AIChatPanel.less | 78 ++++++++++++++++---------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 7f29426c4..b1b35b6ac 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: 13px; } /* ── 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: 11px; color: @project-panel-text-2; margin-bottom: 3px; font-weight: 600; @@ -92,7 +92,7 @@ } .ai-msg-content { - font-size: 12px; + font-size: 13px; 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: 12px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; } @@ -168,7 +168,7 @@ background: none; padding: 0; border-radius: 0; - font-size: 11px; + font-size: 12px; line-height: 1.5; color: @project-panel-text-1; } @@ -195,16 +195,16 @@ 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: 13px; margin: 8px 0 3px 0; } + h4 { font-size: 13px; margin: 6px 0 2px 0; opacity: 0.85; } table { width: 100%; border-collapse: collapse; margin: 6px 0; - font-size: 11px; + font-size: 12px; } th, td { @@ -217,7 +217,7 @@ font-weight: 600; color: @project-panel-text-2; border-bottom-color: rgba(255, 255, 255, 0.1); - font-size: 10px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; } @@ -246,7 +246,7 @@ } .ai-tool-icon { - font-size: 10px; + font-size: 11px; width: 14px; text-align: center; flex-shrink: 0; @@ -264,7 +264,7 @@ } .ai-tool-label { - font-size: 11px; + font-size: 12px; color: @project-panel-text-2; line-height: 1.3; @@ -296,7 +296,7 @@ } .ai-tool-detail-line { - font-size: 10px; + font-size: 11px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.7; @@ -306,7 +306,7 @@ } .ai-tool-preview { - font-size: 10px; + font-size: 11px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.5; @@ -352,7 +352,7 @@ .ai-tool-diff-toggle { background: none; border: none; - font-size: 10px; + font-size: 11px; color: @project-panel-text-2; padding: 1px 4px; cursor: pointer; @@ -367,7 +367,7 @@ .ai-tool-diff { display: none; padding: 4px 8px 4px 28px; - font-size: 11px; + font-size: 12px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; line-height: 1.5; overflow-x: auto; @@ -389,13 +389,13 @@ } .ai-tool-edit-error { - font-size: 11px; + font-size: 12px; color: #e88; padding: 3px 8px 3px 28px; } .ai-tool-elapsed { - font-size: 10px; + font-size: 11px; color: @project-panel-text-2; opacity: 0.5; margin-left: auto; @@ -412,14 +412,14 @@ align-items: flex-start; gap: 6px; padding: 2px 0; - font-size: 11px; + font-size: 12px; } .ai-todo-icon { width: 14px; text-align: center; flex-shrink: 0; - font-size: 10px; + font-size: 11px; &.completed { color: #66bb6a; } &.in_progress { color: #e8a838; } @@ -477,7 +477,7 @@ display: flex; align-items: center; justify-content: space-between; - font-size: 11px; + font-size: 12px; font-weight: 600; color: @project-panel-text-2; margin-bottom: 4px; @@ -487,7 +487,7 @@ background: none; border: 1px solid rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.9); - font-size: 10px; + font-size: 11px; padding: 1px 8px; border-radius: 3px; cursor: pointer; @@ -517,7 +517,7 @@ } .ai-edit-summary-name { - font-size: 11px; + font-size: 12px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; overflow: hidden; @@ -535,7 +535,7 @@ .ai-edit-summary-stats { display: flex; gap: 6px; - font-size: 10px; + font-size: 11px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; flex-shrink: 0; } @@ -561,7 +561,7 @@ background: none; border: 1px dashed rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.7); - font-size: 10px; + font-size: 11px; padding: 2px 14px; border-radius: 3px; cursor: pointer; @@ -611,7 +611,7 @@ color: #e88; border-left: 2px solid rgba(255, 80, 80, 0.4); padding: 4px 8px; - font-size: 11px; + font-size: 12px; background-color: rgba(255, 80, 80, 0.04); border-radius: 0 3px 3px 0; } @@ -623,7 +623,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - font-size: 11px; + font-size: 12px; color: @project-panel-text-2; flex-shrink: 0; opacity: 0.6; @@ -681,7 +681,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: 10px; + font-size: 11px; line-height: 1; padding: 3px 6px; border-radius: 3px; @@ -693,7 +693,7 @@ .ai-context-chip-icon { flex-shrink: 0; - font-size: 9px; + font-size: 10px; opacity: 0.7; } @@ -708,7 +708,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: 11px; + font-size: 12px; line-height: 1; padding: 0 0 0 2px; cursor: pointer; @@ -736,7 +736,7 @@ background: none; border: none !important; color: @project-panel-text-1; - font-size: 12px; + font-size: 13px; font-family: inherit; padding: 7px 0 7px 10px; margin: 0; @@ -778,7 +778,7 @@ } i { - font-size: 12px; + font-size: 13px; } } @@ -802,7 +802,7 @@ } i { - font-size: 12px; + font-size: 13px; } } } @@ -825,14 +825,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: 12px; line-height: 1.5; margin-bottom: 12px; opacity: 0.6; @@ -842,7 +842,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: 11px; + font-size: 12px; padding: 3px 12px; border-radius: 3px; cursor: pointer; From 6248d501b2aa93670f923f9b67639bba4279b816 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 22:08:38 +0530 Subject: [PATCH 5/6] fix(styles): use shared font-size variables for AI chat panel consistency Add sidebar-scoped font-size variables to brackets_variables.less and replace all hardcoded pixel values in the AI chat panel. Bumps sizes by 1px across the board to align with the file tree and other sidebar UI. --- src/styles/Extn-AIChatPanel.less | 66 +++++++++++++++--------------- src/styles/brackets_variables.less | 3 ++ 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index b1b35b6ac..7c53ca351 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: 13px; + font-size: @sidebar-content-font-size; } /* ── Header ─────────────────────────────────────────────────────────── */ @@ -84,7 +84,7 @@ max-width: 100%; .ai-msg-label { - font-size: 11px; + 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: 13px; + 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: 12px; + 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: 12px; + font-size: @sidebar-small-font-size; line-height: 1.5; color: @project-panel-text-1; } @@ -197,14 +197,14 @@ 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: 13px; margin: 8px 0 3px 0; } - h4 { font-size: 13px; margin: 6px 0 2px 0; opacity: 0.85; } + 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%; border-collapse: collapse; margin: 6px 0; - font-size: 12px; + font-size: @sidebar-small-font-size; } th, td { @@ -217,7 +217,7 @@ font-weight: 600; color: @project-panel-text-2; border-bottom-color: rgba(255, 255, 255, 0.1); - font-size: 11px; + font-size: @sidebar-xs-font-size; text-transform: uppercase; letter-spacing: 0.3px; } @@ -246,7 +246,7 @@ } .ai-tool-icon { - font-size: 11px; + font-size: @sidebar-xs-font-size; width: 14px; text-align: center; flex-shrink: 0; @@ -264,7 +264,7 @@ } .ai-tool-label { - font-size: 12px; + font-size: @sidebar-small-font-size; color: @project-panel-text-2; line-height: 1.3; @@ -296,7 +296,7 @@ } .ai-tool-detail-line { - font-size: 11px; + font-size: @sidebar-xs-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.7; @@ -306,7 +306,7 @@ } .ai-tool-preview { - font-size: 11px; + font-size: @sidebar-xs-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.5; @@ -352,7 +352,7 @@ .ai-tool-diff-toggle { background: none; border: none; - font-size: 11px; + font-size: @sidebar-xs-font-size; color: @project-panel-text-2; padding: 1px 4px; cursor: pointer; @@ -367,7 +367,7 @@ .ai-tool-diff { display: none; padding: 4px 8px 4px 28px; - font-size: 12px; + font-size: @sidebar-small-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; line-height: 1.5; overflow-x: auto; @@ -389,13 +389,13 @@ } .ai-tool-edit-error { - font-size: 12px; + font-size: @sidebar-small-font-size; color: #e88; padding: 3px 8px 3px 28px; } .ai-tool-elapsed { - font-size: 11px; + font-size: @sidebar-xs-font-size; color: @project-panel-text-2; opacity: 0.5; margin-left: auto; @@ -412,14 +412,14 @@ align-items: flex-start; gap: 6px; padding: 2px 0; - font-size: 12px; + font-size: @sidebar-small-font-size; } .ai-todo-icon { width: 14px; text-align: center; flex-shrink: 0; - font-size: 11px; + font-size: @sidebar-xs-font-size; &.completed { color: #66bb6a; } &.in_progress { color: #e8a838; } @@ -477,7 +477,7 @@ display: flex; align-items: center; justify-content: space-between; - font-size: 12px; + font-size: @sidebar-small-font-size; font-weight: 600; color: @project-panel-text-2; margin-bottom: 4px; @@ -487,7 +487,7 @@ background: none; border: 1px solid rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.9); - font-size: 11px; + font-size: @sidebar-xs-font-size; padding: 1px 8px; border-radius: 3px; cursor: pointer; @@ -517,7 +517,7 @@ } .ai-edit-summary-name { - font-size: 12px; + font-size: @sidebar-small-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; overflow: hidden; @@ -535,7 +535,7 @@ .ai-edit-summary-stats { display: flex; gap: 6px; - font-size: 11px; + font-size: @sidebar-xs-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; flex-shrink: 0; } @@ -561,7 +561,7 @@ background: none; border: 1px dashed rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.7); - font-size: 11px; + font-size: @sidebar-xs-font-size; padding: 2px 14px; border-radius: 3px; cursor: pointer; @@ -611,7 +611,7 @@ color: #e88; border-left: 2px solid rgba(255, 80, 80, 0.4); padding: 4px 8px; - font-size: 12px; + font-size: @sidebar-small-font-size; background-color: rgba(255, 80, 80, 0.04); border-radius: 0 3px 3px 0; } @@ -623,7 +623,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - font-size: 12px; + font-size: @sidebar-small-font-size; color: @project-panel-text-2; flex-shrink: 0; opacity: 0.6; @@ -681,7 +681,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: @sidebar-xs-font-size; line-height: 1; padding: 3px 6px; border-radius: 3px; @@ -708,7 +708,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: 12px; + font-size: @sidebar-small-font-size; line-height: 1; padding: 0 0 0 2px; cursor: pointer; @@ -736,7 +736,7 @@ background: none; border: none !important; color: @project-panel-text-1; - font-size: 13px; + font-size: @sidebar-content-font-size; font-family: inherit; padding: 7px 0 7px 10px; margin: 0; @@ -778,7 +778,7 @@ } i { - font-size: 13px; + font-size: @sidebar-content-font-size; } } @@ -802,7 +802,7 @@ } i { - font-size: 13px; + font-size: @sidebar-content-font-size; } } } @@ -832,7 +832,7 @@ } .ai-unavailable-message { - font-size: 12px; + font-size: @sidebar-small-font-size; line-height: 1.5; margin-bottom: 12px; opacity: 0.6; @@ -842,7 +842,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: 12px; + 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; From e5237b6ecb4d6699c73481e68aaa9e9723a7fdc2 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 22:46:58 +0530 Subject: [PATCH 6/6] feat: add table horizontal scroll and inline color swatches in AI chat Tables now use display:block with overflow-x:auto so wide tables scroll horizontally instead of crushing columns. Cells use white-space:nowrap to prevent character-level wrapping. Color hex codes in assistant messages are post-processed into inline swatches showing the actual color next to the code. --- src/core-ai/AIChatPanel.js | 60 ++++++++++++++++++++++++++++++++ src/styles/Extn-AIChatPanel.less | 26 +++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index d0f4adf5d..396371f25 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -1173,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); } @@ -1180,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) {
diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less
index 7c53ca351..911846120 100644
--- a/src/styles/Extn-AIChatPanel.less
+++ b/src/styles/Extn-AIChatPanel.less
@@ -201,16 +201,21 @@
         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: @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 {
@@ -460,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); }