From eaa5149f27cce06e30a7a769c566ed892c16fd7b Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 16 Feb 2026 18:44:56 +0530 Subject: [PATCH 1/6] chore: claude code integration first draft --- src-node/claude-code-agent.js | 401 +++++++++++++++++ src-node/index.js | 1 + src-node/package-lock.json | 230 ++++++++++ src-node/package.json | 3 +- src/core-ai/AIChatPanel.js | 735 +++++++++++++++++++++++++++++++ src/core-ai/main.js | 30 +- src/styles/Extn-AIChatPanel.less | 584 ++++++++++++++++++++++++ src/styles/brackets.less | 1 + 8 files changed, 1970 insertions(+), 15 deletions(-) create mode 100644 src-node/claude-code-agent.js create mode 100644 src/core-ai/AIChatPanel.js create mode 100644 src/styles/Extn-AIChatPanel.less diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js new file mode 100644 index 0000000000..1a57141ec8 --- /dev/null +++ b/src-node/claude-code-agent.js @@ -0,0 +1,401 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Claude Code SDK integration via NodeConnector. + * + * Provides AI chat capabilities by bridging the Claude Code CLI/SDK + * with Phoenix's browser-side chat panel. Handles streaming responses, + * edit/write interception, and session management. + */ + +const { execSync } = require("child_process"); +const path = require("path"); + +const CONNECTOR_ID = "ph_ai_claude"; + +// Lazy-loaded ESM module reference +let queryModule = null; + +// Session state +let currentSessionId = null; + +// Active query state +let currentAbortController = null; + +// Streaming throttle +const TEXT_STREAM_THROTTLE_MS = 50; + +const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); + +/** + * Lazily import the ESM @anthropic-ai/claude-code module. + */ +async function getQueryFn() { + if (!queryModule) { + queryModule = await import("@anthropic-ai/claude-code"); + } + return queryModule.query; +} + +/** + * Find the user's globally installed Claude CLI, skipping node_modules copies. + */ +function findGlobalClaudeCli() { + const locations = [ + "/usr/local/bin/claude", + "/usr/bin/claude", + (process.env.HOME || "") + "/.local/bin/claude", + (process.env.HOME || "") + "/.nvm/versions/node/" + + (process.version.startsWith("v") ? process.version : "v" + process.version) + + "/bin/claude" + ]; + + // Try 'which -a' first to find all claude binaries, filtering out node_modules + try { + const allPaths = execSync("which -a claude 2>/dev/null || which claude", { encoding: "utf8" }) + .trim() + .split("\n") + .filter(p => p && !p.includes("node_modules")); + if (allPaths.length > 0) { + console.log("[Phoenix AI] Found global Claude CLI at:", allPaths[0]); + return allPaths[0]; + } + } catch { + // which failed, try manual locations + } + + // Check common locations + for (const loc of locations) { + try { + execSync(`test -x "${loc}"`, { encoding: "utf8" }); + console.log("[Phoenix AI] Found global Claude CLI at:", loc); + return loc; + } catch { + // Not found at this location + } + } + + console.log("[Phoenix AI] Global Claude CLI not found"); + return null; +} + +/** + * Check whether Claude CLI is available. + * Called from browser via execPeer("checkAvailability"). + */ +exports.checkAvailability = async function () { + try { + const claudePath = findGlobalClaudeCli(); + if (claudePath) { + // Also verify the SDK can be imported + await getQueryFn(); + return { available: true, claudePath: claudePath }; + } + // No global CLI found — try importing SDK anyway (it might find its own) + await getQueryFn(); + return { available: true, claudePath: null }; + } catch (err) { + return { available: false, claudePath: null, error: err.message }; + } +}; + +/** + * Send a prompt to Claude and stream results back to the browser. + * Called from browser via execPeer("sendPrompt", {prompt, projectPath, sessionAction, model}). + * + * Returns immediately with a requestId. Results are sent as events: + * aiProgress, aiTextStream, aiEditResult, aiError, aiComplete + */ +exports.sendPrompt = async function (params) { + const { prompt, projectPath, sessionAction, model } = params; + const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); + + // Handle session + if (sessionAction === "new") { + currentSessionId = null; + } + + // Cancel any in-flight query + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } + + currentAbortController = new AbortController(); + + // Run the query asynchronously — don't await here so we return requestId immediately + _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal) + .catch(err => { + console.error("[Phoenix AI] Query error:", err); + }); + + return { requestId: requestId }; +}; + +/** + * Cancel the current in-flight query. + */ +exports.cancelQuery = async function () { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + return { success: true }; + } + return { success: false }; +}; + +/** + * Destroy the current session (clear session ID). + */ +exports.destroySession = async function () { + currentSessionId = null; + currentAbortController = null; + return { success: true }; +}; + +/** + * Internal: run a Claude SDK query and stream results back to the browser. + */ +async function _runQuery(requestId, prompt, projectPath, model, signal) { + const collectedEdits = []; + let queryFn; + + try { + queryFn = await getQueryFn(); + } catch (err) { + nodeConnector.triggerPeer("aiError", { + requestId: requestId, + error: "Failed to load Claude Code SDK: " + err.message + }); + return; + } + + // Send initial progress + nodeConnector.triggerPeer("aiProgress", { + requestId: requestId, + message: "Analyzing...", + phase: "start" + }); + + const queryOptions = { + cwd: projectPath || process.cwd(), + maxTurns: 10, + allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"], + permissionMode: "acceptEdits", + includePartialMessages: true, + abortController: currentAbortController, + hooks: { + PreToolUse: [ + { + matcher: "Edit", + hooks: [ + async (input) => { + console.log("[Phoenix AI] Intercepted Edit tool"); + collectedEdits.push({ + file: input.tool_input.file_path, + oldText: input.tool_input.old_string, + newText: input.tool_input.new_string + }); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Edit delegated to Phoenix editor" + } + }; + } + ] + }, + { + matcher: "Write", + hooks: [ + async (input) => { + console.log("[Phoenix AI] Intercepted Write tool"); + collectedEdits.push({ + file: input.tool_input.file_path, + oldText: null, + newText: input.tool_input.content + }); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Write delegated to Phoenix editor" + } + }; + } + ] + } + ] + } + }; + + // Set Claude CLI path if found + const claudePath = findGlobalClaudeCli(); + if (claudePath) { + queryOptions.pathToClaudeCodeExecutable = claudePath; + } + + if (model) { + queryOptions.model = model; + } + + // Resume session if we have an existing one (already cleared if sessionAction was "new") + if (currentSessionId) { + queryOptions.resume = currentSessionId; + } + + try { + const result = queryFn({ + prompt: prompt, + options: queryOptions + }); + + let accumulatedText = ""; + let lastStreamTime = 0; + + // Tool input tracking + let activeToolName = null; + let activeToolIndex = null; + let activeToolInputJson = ""; + let toolCounter = 0; + + for await (const message of result) { + // Check abort + if (signal.aborted) { + break; + } + + // Capture session_id from first message + if (message.session_id && !currentSessionId) { + currentSessionId = message.session_id; + } + + // Handle streaming events + if (message.type === "stream_event") { + const event = message.event; + + // Tool use start — send initial indicator + if (event.type === "content_block_start" && + event.content_block?.type === "tool_use") { + activeToolName = event.content_block.name; + activeToolIndex = event.index; + activeToolInputJson = ""; + toolCounter++; + nodeConnector.triggerPeer("aiProgress", { + requestId: requestId, + toolName: activeToolName, + toolId: toolCounter, + phase: "tool_use" + }); + } + + // Accumulate tool input JSON + if (event.type === "content_block_delta" && + event.delta?.type === "input_json_delta" && + event.index === activeToolIndex) { + activeToolInputJson += event.delta.partial_json; + } + + // Tool block complete — parse input and send details + if (event.type === "content_block_stop" && + event.index === activeToolIndex && + activeToolName) { + let toolInput = {}; + try { + toolInput = JSON.parse(activeToolInputJson); + } catch (e) { + // ignore parse errors + } + nodeConnector.triggerPeer("aiToolInfo", { + requestId: requestId, + toolName: activeToolName, + toolId: toolCounter, + toolInput: toolInput + }); + activeToolName = null; + activeToolIndex = null; + activeToolInputJson = ""; + } + + // Stream text deltas (throttled) + if (event.type === "content_block_delta" && + event.delta?.type === "text_delta") { + accumulatedText += event.delta.text; + const now = Date.now(); + if (now - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) { + lastStreamTime = now; + nodeConnector.triggerPeer("aiTextStream", { + requestId: requestId, + text: accumulatedText + }); + accumulatedText = ""; + } + } + } + } + + // Flush any remaining accumulated text + if (accumulatedText) { + nodeConnector.triggerPeer("aiTextStream", { + requestId: requestId, + text: accumulatedText + }); + } + + // Send collected edits if any + if (collectedEdits.length > 0) { + nodeConnector.triggerPeer("aiEditResult", { + requestId: requestId, + edits: collectedEdits + }); + } + + // Signal completion + nodeConnector.triggerPeer("aiComplete", { + requestId: requestId, + sessionId: currentSessionId + }); + + } catch (err) { + if (signal.aborted) { + // Query was cancelled, not an error + nodeConnector.triggerPeer("aiComplete", { + requestId: requestId, + sessionId: currentSessionId + }); + return; + } + + // If we collected edits before error, send them + if (collectedEdits.length > 0) { + nodeConnector.triggerPeer("aiEditResult", { + requestId: requestId, + edits: collectedEdits + }); + } + + nodeConnector.triggerPeer("aiError", { + requestId: requestId, + error: err.message || String(err) + }); + } +} diff --git a/src-node/index.js b/src-node/index.js index c3087273f4..95472485af 100644 --- a/src-node/index.js +++ b/src-node/index.js @@ -69,6 +69,7 @@ const LivePreview = require("./live-preview"); require("./test-connection"); require("./utils"); require("./git/cli"); +require("./claude-code-agent"); function randomNonce(byteLength) { const randomBuffer = new Uint8Array(byteLength); crypto.getRandomValues(randomBuffer); diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 86b04e13a1..9ba207622a 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -9,6 +9,7 @@ "version": "5.1.4-0", "license": "GNU-AGPL3.0", "dependencies": { + "@anthropic-ai/claude-code": "^1.0.0", "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", "cross-spawn": "^7.0.6", @@ -23,6 +24,26 @@ "node": "24" } }, + "node_modules/@anthropic-ai/claude-code": { + "version": "1.0.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.128.tgz", + "integrity": "sha512-uUg5cFMJfeQetQzFw76Vpbro6DAXst2Lpu8aoZWRFSoQVYu5ZSAnbBoxaWmW/IgnHSqIIvtMwzCoqmcA9j9rNQ==", + "license": "SEE LICENSE IN README.md", + "bin": { + "claude": "cli.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + } + }, "node_modules/@expo/sudo-prompt": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", @@ -34,6 +55,215 @@ "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", "license": "Apache-2.0" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", diff --git a/src-node/package.json b/src-node/package.json index d6bc881432..59eb1cf455 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -27,6 +27,7 @@ "mime-types": "^2.1.35", "cross-spawn": "^7.0.6", "which": "^2.0.1", - "@expo/sudo-prompt": "^9.3.2" + "@expo/sudo-prompt": "^9.3.2", + "@anthropic-ai/claude-code": "^1.0.0" } } \ No newline at end of file diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js new file mode 100644 index 0000000000..f8520c22e3 --- /dev/null +++ b/src/core-ai/AIChatPanel.js @@ -0,0 +1,735 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * AI Chat Panel — renders the chat UI in the AI sidebar tab, handles streaming + * responses from Claude Code, and manages edit application to documents. + */ +define(function (require, exports, module) { + + const SidebarTabs = require("view/SidebarTabs"), + DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + ProjectManager = require("project/ProjectManager"), + marked = require("thirdparty/marked.min"); + + let _nodeConnector = null; + let _isStreaming = false; + let _currentRequestId = null; + let _segmentText = ""; // text for the current segment only + let _autoScroll = true; + let _hasReceivedContent = false; // tracks if we've received any text/tool in current response + + // DOM references + let $panel, $messages, $status, $statusText, $textarea, $sendBtn; + + const PANEL_HTML = + '
' + + '
' + + 'AI Assistant' + + '' + + '
' + + '
' + + '
' + + '' + + 'Thinking...' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + + const UNAVAILABLE_HTML = + '
' + + '
' + + '
' + + '
Claude CLI Not Found
' + + '
' + + 'Install the Claude CLI to use AI features:
' + + 'npm install -g @anthropic-ai/claude-code

' + + 'Then run claude login to authenticate.' + + '
' + + '' + + '
' + + '
'; + + const PLACEHOLDER_HTML = + '
' + + '
' + + '
' + + '
AI Assistant
' + + '
' + + 'AI features require the Phoenix desktop app.' + + '
' + + '
' + + '
'; + + /** + * Initialize the chat panel with a NodeConnector instance. + * @param {Object} nodeConnector - NodeConnector for communicating with the node-side Claude agent. + */ + function init(nodeConnector) { + _nodeConnector = nodeConnector; + + // Wire up events from node side + _nodeConnector.on("aiTextStream", _onTextStream); + _nodeConnector.on("aiProgress", _onProgress); + _nodeConnector.on("aiToolInfo", _onToolInfo); + _nodeConnector.on("aiEditResult", _onEditResult); + _nodeConnector.on("aiError", _onError); + _nodeConnector.on("aiComplete", _onComplete); + + // Check availability and render appropriate UI + _checkAvailability(); + } + + /** + * Show placeholder UI for non-native (browser) builds. + */ + function initPlaceholder() { + const $placeholder = $(PLACEHOLDER_HTML); + SidebarTabs.addToTab("ai", $placeholder); + } + + /** + * Check if Claude CLI is available and render the appropriate UI. + */ + function _checkAvailability() { + _nodeConnector.execPeer("checkAvailability") + .then(function (result) { + if (result.available) { + _renderChatUI(); + } else { + _renderUnavailableUI(result.error); + } + }) + .catch(function (err) { + _renderUnavailableUI(err.message || String(err)); + }); + } + + /** + * Render the full chat UI. + */ + function _renderChatUI() { + $panel = $(PANEL_HTML); + $messages = $panel.find(".ai-chat-messages"); + $status = $panel.find(".ai-chat-status"); + $statusText = $panel.find(".ai-status-text"); + $textarea = $panel.find(".ai-chat-textarea"); + $sendBtn = $panel.find(".ai-send-btn"); + + // Event handlers + $sendBtn.on("click", _sendMessage); + $panel.find(".ai-new-session-btn").on("click", _newSession); + + $textarea.on("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + _sendMessage(); + } + if (e.key === "Escape") { + if (_isStreaming) { + _cancelQuery(); + } else { + $textarea.val(""); + } + } + }); + + // Auto-resize textarea + $textarea.on("input", function () { + this.style.height = "auto"; + this.style.height = Math.min(this.scrollHeight, 96) + "px"; // max ~6rem + }); + + // Track scroll position for auto-scroll + $messages.on("scroll", function () { + const el = $messages[0]; + _autoScroll = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50; + }); + + SidebarTabs.addToTab("ai", $panel); + } + + /** + * Render the unavailable UI (CLI not found). + */ + function _renderUnavailableUI(error) { + const $unavailable = $(UNAVAILABLE_HTML); + $unavailable.find(".ai-retry-btn").on("click", function () { + $unavailable.remove(); + _checkAvailability(); + }); + SidebarTabs.addToTab("ai", $unavailable); + } + + /** + * Send the current input as a message to Claude. + */ + function _sendMessage() { + const text = $textarea.val().trim(); + if (!text || _isStreaming) { + return; + } + + // Append user message + _appendUserMessage(text); + + // Clear input + $textarea.val(""); + $textarea.css("height", "auto"); + + // Set streaming state + _setStreaming(true); + + // Reset segment tracking and show thinking indicator + _segmentText = ""; + _hasReceivedContent = false; + _appendThinkingIndicator(); + + // Get project path + const projectPath = _getProjectRealPath(); + + _nodeConnector.execPeer("sendPrompt", { + prompt: text, + projectPath: projectPath, + sessionAction: "continue" + }).then(function (result) { + _currentRequestId = result.requestId; + }).catch(function (err) { + _setStreaming(false); + _appendErrorMessage("Failed to send message: " + (err.message || String(err))); + }); + } + + /** + * Cancel the current streaming query. + */ + function _cancelQuery() { + if (_nodeConnector && _isStreaming) { + _nodeConnector.execPeer("cancelQuery").catch(function () { + // ignore cancel errors + }); + } + } + + /** + * Start a new session: destroy server-side session and clear chat. + */ + function _newSession() { + if (_nodeConnector) { + _nodeConnector.execPeer("destroySession").catch(function () { + // ignore + }); + } + _currentRequestId = null; + _segmentText = ""; + _hasReceivedContent = false; + _isStreaming = false; + if ($messages) { + $messages.empty(); + } + if ($status) { + $status.removeClass("active"); + } + if ($textarea) { + $textarea.prop("disabled", false); + $textarea[0].focus({ preventScroll: true }); + } + if ($sendBtn) { + $sendBtn.prop("disabled", false); + } + } + + // --- Event handlers for node-side events --- + + function _onTextStream(_event, data) { + // Remove thinking indicator on first content + if (!_hasReceivedContent) { + _hasReceivedContent = true; + $messages.find(".ai-thinking").remove(); + } + + // If no active stream target exists, create a new text segment + if (!$messages.find(".ai-stream-target").length) { + _appendAssistantSegment(); + } + + _segmentText += data.text; + _renderAssistantStream(); + } + + // Tool type configuration: icon, color, label + const TOOL_CONFIG = { + Glob: { icon: "fa-solid fa-magnifying-glass", color: "#6b9eff", label: "Search files" }, + Grep: { icon: "fa-solid fa-magnifying-glass-location", color: "#6b9eff", label: "Search code" }, + Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b", label: "Read" }, + Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: "Edit" }, + Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: "Write" }, + Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: "Run command" } + }; + + function _onProgress(_event, data) { + if ($statusText) { + const toolName = data.toolName || ""; + const config = TOOL_CONFIG[toolName]; + $statusText.text(config ? config.label + "..." : "Thinking..."); + } + if (data.phase === "tool_use") { + _appendToolIndicator(data.toolName, data.toolId); + } + } + + function _onToolInfo(_event, data) { + _updateToolIndicator(data.toolId, data.toolName, data.toolInput); + } + + function _onEditResult(_event, data) { + if (data.edits && data.edits.length > 0) { + data.edits.forEach(function (edit) { + _appendEditCard(edit); + }); + } + } + + function _onError(_event, data) { + _appendErrorMessage(data.error); + // Don't stop streaming — the node side may continue (partial results) + } + + function _onComplete(_event, data) { + _setStreaming(false); + } + + // --- DOM helpers --- + + function _appendUserMessage(text) { + const $msg = $( + '
' + + '
You
' + + '
' + + '
' + ); + $msg.find(".ai-msg-content").text(text); + $messages.append($msg); + _scrollToBottom(); + } + + /** + * Append a thinking/typing indicator while waiting for first content. + */ + function _appendThinkingIndicator() { + const $thinking = $( + '
' + + '
Claude
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + ); + $messages.append($thinking); + _scrollToBottom(); + } + + /** + * Append a new assistant text segment. Creates a fresh content block + * that subsequent text deltas will stream into. Shows the "Claude" label + * only for the first segment in a response. + */ + function _appendAssistantSegment() { + // Check if this is a continuation (there's already assistant content or tools above) + const isFirst = !$messages.find(".ai-msg-assistant").not(".ai-thinking").length; + const $msg = $( + '
' + + (isFirst ? '
Claude
' : '') + + '
' + + '
' + ); + $messages.append($msg); + } + + /** + * Re-render the current streaming segment from accumulated segment text. + */ + function _renderAssistantStream() { + const $target = $messages.find(".ai-stream-target").last(); + if ($target.length) { + try { + $target.html(marked.parse(_segmentText, { breaks: true, gfm: true })); + } catch (e) { + $target.text(_segmentText); + } + _scrollToBottom(); + } + } + + function _appendToolIndicator(toolName, toolId) { + // Remove thinking indicator on first content + if (!_hasReceivedContent) { + _hasReceivedContent = true; + $messages.find(".ai-thinking").remove(); + } + + // Finalize the current text segment so tool appears after it, not at the end + $messages.find(".ai-stream-target").removeClass("ai-stream-target"); + _segmentText = ""; + + // Mark any previous active tool indicator as done + _finishActiveTools(); + + const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName }; + + const $tool = $( + '
' + + '
' + + '' + + '' + + '
' + + '
' + ); + $tool.find(".ai-tool-label").text(config.label + "..."); + $tool.css("--tool-color", config.color); + $tool.attr("data-tool-icon", config.icon); + $messages.append($tool); + _scrollToBottom(); + } + + /** + * Update an existing tool indicator with details once tool input is known. + */ + function _updateToolIndicator(toolId, toolName, toolInput) { + const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + toolId + '"]'); + if (!$tool.length) { + return; + } + + const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName }; + const detail = _getToolDetail(toolName, toolInput); + + // Mark as done: replace spinner with colored icon + $tool.addClass("ai-tool-done"); + $tool.find(".ai-tool-spinner").replaceWith( + '' + + '' + + '' + ); + + // Update label to include summary + $tool.find(".ai-tool-label").text(detail.summary); + + // Add expandable detail if available + if (detail.lines && detail.lines.length) { + const $detail = $('
'); + detail.lines.forEach(function (line) { + $detail.append($('
').text(line)); + }); + $tool.append($detail); + + // Make header clickable to expand + $tool.find(".ai-tool-header").on("click", function () { + $tool.toggleClass("ai-tool-expanded"); + }).css("cursor", "pointer"); + } + + _scrollToBottom(); + } + + /** + * Extract a summary and detail lines from tool input. + */ + function _getToolDetail(toolName, input) { + if (!input) { + return { summary: toolName, lines: [] }; + } + switch (toolName) { + case "Glob": + return { + summary: "Searched: " + (input.pattern || ""), + lines: input.path ? ["in " + input.path] : [] + }; + case "Grep": + return { + summary: "Grep: " + (input.pattern || ""), + lines: [input.path ? "in " + input.path : "", input.include ? "include " + input.include : ""] + .filter(Boolean) + }; + case "Read": + return { + summary: "Read " + (input.file_path || "").split("/").pop(), + lines: [input.file_path || ""] + }; + case "Edit": + return { + summary: "Edit " + (input.file_path || "").split("/").pop(), + lines: [input.file_path || ""] + }; + case "Write": + return { + summary: "Write " + (input.file_path || "").split("/").pop(), + lines: [input.file_path || ""] + }; + case "Bash": + return { + summary: "Ran command", + lines: input.command ? [input.command] : [] + }; + default: + return { summary: toolName, lines: [] }; + } + } + + /** + * Mark all active (non-done) tool indicators as finished. + */ + function _finishActiveTools() { + $messages.find(".ai-msg-tool:not(.ai-tool-done)").each(function () { + const $prev = $(this); + $prev.addClass("ai-tool-done"); + const iconClass = $prev.attr("data-tool-icon") || "fa-solid fa-check"; + const color = $prev.css("--tool-color") || "#adb9bd"; + $prev.find(".ai-tool-spinner").replaceWith( + '' + + '' + + '' + ); + }); + } + + function _appendEditCard(edit) { + const fileName = edit.file; + // Show just the filename, not full path + const displayName = fileName.split("/").pop(); + + const $card = $('
'); + const $header = $( + '
' + + '' + + '' + + '
' + ); + $header.find(".ai-edit-file").text(displayName); + + const $toggle = $(''); + const $diff = $('
'); + + // Build diff content + if (edit.oldText) { + const oldLines = edit.oldText.split("\n"); + const newLines = edit.newText.split("\n"); + oldLines.forEach(function (line) { + $diff.append($('
').text("- " + line)); + }); + newLines.forEach(function (line) { + $diff.append($('
').text("+ " + line)); + }); + } else { + // Write (new file) — show all as new + edit.newText.split("\n").forEach(function (line) { + $diff.append($('
').text("+ " + line)); + }); + } + + $toggle.on("click", function () { + $diff.toggleClass("expanded"); + $toggle.text($diff.hasClass("expanded") ? "Hide diff" : "Show diff"); + }); + + $header.find(".ai-edit-apply-btn").on("click", function () { + const $btn = $(this); + if ($btn.hasClass("applied")) { + return; + } + _applySingleEdit(edit) + .then(function () { + $btn.text("Applied").addClass("applied"); + }) + .catch(function (err) { + const $err = $('
').text(err.message || String(err)); + $card.append($err); + }); + }); + + $card.append($header); + $card.append($toggle); + $card.append($diff); + $messages.append($card); + _scrollToBottom(); + } + + function _appendErrorMessage(text) { + const $msg = $( + '
' + + '
' + + '
' + ); + $msg.find(".ai-msg-content").text(text); + $messages.append($msg); + _scrollToBottom(); + } + + function _setStreaming(streaming) { + _isStreaming = streaming; + if ($status) { + $status.toggleClass("active", streaming); + } + if ($textarea) { + $textarea.prop("disabled", streaming); + $textarea.closest(".ai-chat-input-wrap").toggleClass("disabled", streaming); + if (!streaming) { + $textarea[0].focus({ preventScroll: true }); + } + } + if ($sendBtn) { + $sendBtn.prop("disabled", streaming); + } + if (!streaming && $messages) { + // Clean up thinking indicator if still present + $messages.find(".ai-thinking").remove(); + + // Finalize: remove ai-stream-target class so future messages get their own container + $messages.find(".ai-stream-target").removeClass("ai-stream-target"); + + // Mark all active tool indicators as done + _finishActiveTools(); + } + } + + function _scrollToBottom() { + if (_autoScroll && $messages && $messages.length) { + const el = $messages[0]; + el.scrollTop = el.scrollHeight; + } + } + + function _escapeAttr(str) { + return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + } + + // --- Edit application --- + + /** + * Apply a single edit to a document. + * @param {Object} edit - {file, oldText, newText} + * @return {$.Promise} + */ + function _applySingleEdit(edit) { + const result = new $.Deferred(); + const vfsPath = _realToVfsPath(edit.file); + + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + if (edit.oldText === null) { + // Write (new file or full replacement) + doc.setText(edit.newText); + } else { + // Edit — find oldText and replace + const docText = doc.getText(); + const idx = docText.indexOf(edit.oldText); + if (idx === -1) { + result.reject(new Error("Text not found in file — it may have changed")); + return; + } + const startPos = doc._masterEditor ? + doc._masterEditor._codeMirror.posFromIndex(idx) : + _indexToPos(docText, idx); + const endPos = doc._masterEditor ? + doc._masterEditor._codeMirror.posFromIndex(idx + edit.oldText.length) : + _indexToPos(docText, idx + edit.oldText.length); + doc.replaceRange(edit.newText, startPos, endPos); + } + // Open the file in the editor + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + result.resolve(); + } catch (err) { + result.reject(err); + } + }) + .fail(function (err) { + result.reject(err || new Error("Could not open document")); + }); + + return result.promise(); + } + + /** + * Convert a character index in text to a {line, ch} position. + */ + function _indexToPos(text, index) { + let line = 0, ch = 0; + for (let i = 0; i < index; i++) { + if (text[i] === "\n") { + line++; + ch = 0; + } else { + ch++; + } + } + return { line: line, ch: ch }; + } + + // --- Path utilities --- + + /** + * Get the real filesystem path for the current project root. + */ + function _getProjectRealPath() { + const root = ProjectManager.getProjectRoot(); + if (!root) { + return "/"; + } + const fullPath = root.fullPath; + // Desktop (Tauri) paths: /tauri/real/path → /real/path + if (fullPath.startsWith("/tauri/")) { + return fullPath.replace("/tauri", ""); + } + return fullPath; + } + + /** + * Convert a real filesystem path back to a VFS path that Phoenix understands. + */ + function _realToVfsPath(realPath) { + // If it already looks like a VFS path, return as-is + if (realPath.startsWith("/tauri/") || realPath.startsWith("/mnt/")) { + return realPath; + } + // Desktop builds use /tauri/ prefix + if (Phoenix.isNativeApp) { + return "/tauri" + realPath; + } + return realPath; + } + + // Public API + exports.init = init; + exports.initPlaceholder = initPlaceholder; +}); diff --git a/src/core-ai/main.js b/src/core-ai/main.js index 0e826ee181..f998ad8e85 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -19,26 +19,28 @@ */ /** - * Registers a placeholder AI sidebar tab. This serves as a starting point for - * AI assistant integration. The tab displays a placeholder message until an AI - * provider extension is installed. + * AI sidebar tab integration. Sets up a NodeConnector to the claude-code-agent + * running in the node process and initializes the AIChatPanel UI. + * + * In non-native (browser) builds, shows a placeholder message instead. */ define(function (require, exports, module) { - var AppInit = require("utils/AppInit"), - SidebarTabs = require("view/SidebarTabs"); + var AppInit = require("utils/AppInit"), + SidebarTabs = require("view/SidebarTabs"), + NodeConnector = require("NodeConnector"), + AIChatPanel = require("core-ai/AIChatPanel"); + + var AI_CONNECTOR_ID = "ph_ai_claude"; AppInit.appReady(function () { SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); - var $content = $( - '
' + - '
' + - '
AI Assistant
' + - '
Please add an AI provider to start using AI
' + - '
' - ); - - SidebarTabs.addToTab("ai", $content); + if (Phoenix.isNativeApp) { + var nodeConnector = NodeConnector.createNodeConnector(AI_CONNECTOR_ID, exports); + AIChatPanel.init(nodeConnector); + } else { + AIChatPanel.initPlaceholder(); + } }); }); diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less new file mode 100644 index 0000000000..20c25887e5 --- /dev/null +++ b/src/styles/Extn-AIChatPanel.less @@ -0,0 +1,584 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* AI Chat Panel — sidebar chat UI for Claude Code integration */ + +.ai-chat-panel { + display: flex; + flex-direction: column; + -webkit-box-flex: 1; + min-height: 0; + overflow: hidden; + background-color: @bc-sidebar-bg; + color: @project-panel-text-1; + font-size: 12px; +} + +/* ── Header ─────────────────────────────────────────────────────────── */ +.ai-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; + + .ai-chat-title { + font-weight: 600; + font-size: 11px; + color: @project-panel-text-2; + letter-spacing: 0.3px; + } + + .ai-new-session-btn { + background: none; + border: none; + color: @project-panel-text-2; + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background-color: rgba(255, 255, 255, 0.06); + } + } +} + +/* ── Message list ───────────────────────────────────────────────────── */ +.ai-chat-messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 10px; + min-height: 0; + min-width: 0; +} + +/* ── Individual messages ────────────────────────────────────────────── */ +.ai-msg { + margin-bottom: 12px; + max-width: 100%; + + .ai-msg-label { + font-size: 10px; + color: @project-panel-text-2; + margin-bottom: 3px; + font-weight: 600; + opacity: 0.6; + } + + .ai-msg-content { + font-size: 12px; + line-height: 1.55; + white-space: normal; + word-wrap: break-word; + overflow-wrap: anywhere; + min-width: 0; + } +} + +/* ── User message ───────────────────────────────────────────────────── */ +.ai-msg-user { + display: flex; + flex-direction: column; + align-items: flex-end; + + .ai-msg-label { + margin-right: 2px; + } + + .ai-msg-content { + background-color: rgba(255, 255, 255, 0.07); + padding: 6px 10px; + border-radius: 10px 10px 2px 10px; + max-width: 88%; + text-align: left; + } +} + +/* ── Assistant message — markdown content ───────────────────────────── */ +.ai-msg-assistant { + .ai-msg-content { + padding: 2px 0; + + > *:first-child { + margin-top: 0; + } + + > *:last-child { + margin-bottom: 0; + } + + p { + margin: 0 0 8px 0; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + color: @project-panel-text-1; + font-weight: 600; + } + + code { + background-color: rgba(255, 255, 255, 0.08); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + } + + pre { + background-color: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.06); + padding: 8px 10px; + border-radius: 4px; + overflow-x: auto; + margin: 6px 0; + + code { + background: none; + padding: 0; + border-radius: 0; + font-size: 11px; + line-height: 1.5; + color: @project-panel-text-1; + } + } + + ul, ol { + margin: 4px 0 8px 0; + padding-left: 18px; + } + + li { + margin-bottom: 2px; + } + + blockquote { + border-left: 2px solid rgba(255, 255, 255, 0.12); + margin: 6px 0; + padding: 2px 10px; + color: @project-panel-text-2; + } + + h1, h2, h3, h4 { + font-weight: 600; + 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; } + + table { + width: 100%; + border-collapse: collapse; + margin: 6px 0; + font-size: 11px; + } + + th, td { + padding: 4px 8px; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + + th { + font-weight: 600; + color: @project-panel-text-2; + border-bottom-color: rgba(255, 255, 255, 0.1); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.06); + margin: 8px 0; + } + } +} + +/* ── Tool use indicator ─────────────────────────────────────────────── */ +.ai-msg-tool { + margin-bottom: 3px; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.025); + border-left: 2px solid var(--tool-color, @project-panel-text-2); + + .ai-tool-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + white-space: normal; + } + + .ai-tool-icon { + font-size: 10px; + width: 14px; + text-align: center; + flex-shrink: 0; + } + + .ai-tool-spinner { + display: inline-block; + width: 10px; + height: 10px; + border: 1.5px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--tool-color, @project-panel-text-2); + border-radius: 50%; + animation: ai-spin 0.7s linear infinite; + flex-shrink: 0; + } + + .ai-tool-label { + font-size: 11px; + color: @project-panel-text-2; + line-height: 1.3; + } + + .ai-tool-detail { + display: none; + padding: 0 8px 4px 28px; + } + + .ai-tool-detail-line { + font-size: 10px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + color: @project-panel-text-2; + opacity: 0.7; + line-height: 1.5; + white-space: normal; + word-break: break-all; + } + + &.ai-tool-done { + .ai-tool-label { + opacity: 0.75; + } + } + + &.ai-tool-expanded .ai-tool-detail { + display: block; + } + + // Expand chevron hint + &.ai-tool-done .ai-tool-header:hover .ai-tool-label { + opacity: 1; + } +} + +@keyframes ai-spin { + to { transform: rotate(360deg); } +} + +/* ── Thinking indicator ─────────────────────────────────────────────── */ +.ai-thinking-dots { + display: inline-flex; + gap: 3px; + padding: 4px 0; + + span { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background-color: @project-panel-text-2; + opacity: 0.3; + animation: ai-dot-pulse 1.2s ease-in-out infinite; + + &:nth-child(2) { animation-delay: 0.2s; } + &:nth-child(3) { animation-delay: 0.4s; } + } +} + +@keyframes ai-dot-pulse { + 0%, 60%, 100% { opacity: 0.2; transform: scale(0.8); } + 30% { opacity: 0.8; transform: scale(1); } +} + +/* ── Edit card ──────────────────────────────────────────────────────── */ +.ai-msg-edit { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + margin-bottom: 6px; + overflow: hidden; + + .ai-edit-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + background-color: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + + .ai-edit-file { + font-size: 11px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + color: @project-panel-text-2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 6px; + } + + .ai-edit-apply-btn { + background: none; + border: 1px solid rgba(100, 200, 100, 0.3); + color: rgba(140, 200, 140, 0.9); + font-size: 10px; + padding: 1px 8px; + border-radius: 3px; + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgba(100, 200, 100, 0.08); + } + + &.applied { + border-color: rgba(100, 200, 100, 0.15); + color: rgba(100, 200, 100, 0.4); + cursor: default; + } + } + } + + .ai-edit-toggle { + display: block; + width: 100%; + background: none; + border: none; + text-align: left; + font-size: 10px; + color: @project-panel-text-2; + padding: 3px 8px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + + .ai-edit-diff { + display: none; + padding: 4px 8px; + font-size: 11px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + line-height: 1.5; + overflow-x: auto; + background-color: rgba(0, 0, 0, 0.15); + + &.expanded { + display: block; + } + } + + .ai-diff-old { + color: #e88; + background-color: rgba(255, 80, 80, 0.06); + } + + .ai-diff-new { + color: #8c8; + background-color: rgba(80, 200, 80, 0.06); + } + + .ai-edit-error { + font-size: 11px; + color: #e88; + padding: 3px 8px; + } +} + +/* ── Error message ──────────────────────────────────────────────────── */ +.ai-msg-error { + .ai-msg-content { + color: #e88; + border-left: 2px solid rgba(255, 80, 80, 0.4); + padding: 4px 8px; + font-size: 11px; + background-color: rgba(255, 80, 80, 0.04); + border-radius: 0 3px 3px 0; + } +} + +/* ── Status bar ─────────────────────────────────────────────────────── */ +.ai-chat-status { + display: none; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 11px; + color: @project-panel-text-2; + flex-shrink: 0; + opacity: 0.6; + + &.active { + display: flex; + } + + .ai-status-spinner { + display: inline-block; + width: 9px; + height: 9px; + border: 1.5px solid rgba(255, 255, 255, 0.1); + border-top-color: @project-panel-text-2; + border-radius: 50%; + animation: ai-spin 0.7s linear infinite; + } +} + +/* ── Input area ─────────────────────────────────────────────────────── */ +.ai-chat-input-area { + flex-shrink: 0; + padding: 6px 8px 8px 8px; + + .ai-chat-input-wrap { + display: flex; + align-items: flex-end; + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + transition: border-color 0.15s ease; + overflow: hidden; + + &:focus-within { + border-color: rgba(255, 255, 255, 0.18); + } + + &.disabled { + opacity: 0.5; + } + } + + .ai-chat-textarea { + flex: 1; + min-width: 0; + background: none; + border: none; + color: @project-panel-text-1; + font-size: 12px; + font-family: inherit; + padding: 7px 0 7px 10px; + resize: none; + min-height: 20px; + max-height: 96px; + line-height: 1.4; + outline: none; + + &:disabled { + cursor: not-allowed; + } + } + + .ai-send-btn { + background: none; + border: none; + color: @project-panel-text-2; + width: 30px; + height: 30px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 0 7px 7px 0; + opacity: 0.5; + transition: opacity 0.15s ease, color 0.15s ease; + margin-bottom: 1px; + + &:hover { + opacity: 1; + color: @project-panel-text-1; + } + + &:disabled { + opacity: 0.2; + cursor: not-allowed; + } + + i { + font-size: 12px; + } + } +} + +/* ── Unavailable / placeholder state ────────────────────────────────── */ +.ai-unavailable { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 2rem; + text-align: center; + color: @project-panel-text-2; + + .ai-unavailable-icon { + font-size: 2rem; + margin-bottom: 1rem; + opacity: 0.4; + } + + .ai-unavailable-title { + font-size: 13px; + font-weight: 600; + color: @project-panel-text-1; + margin-bottom: 6px; + } + + .ai-unavailable-message { + font-size: 11px; + line-height: 1.5; + margin-bottom: 12px; + opacity: 0.6; + } + + .ai-retry-btn { + background: none; + border: 1px solid rgba(255, 255, 255, 0.12); + color: @project-panel-text-2; + font-size: 11px; + padding: 3px 12px; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.06); + color: @project-panel-text-1; + } + } +} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 0630566651..a0f0c46faa 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -47,6 +47,7 @@ @import "Extn-CustomSnippets.less"; @import "Extn-CollapseFolders.less"; @import "Extn-SidebarTabs.less"; +@import "Extn-AIChatPanel.less"; @import "UserProfile.less"; @import "phoenix-pro.less"; From 60c2e35a594a05f2272d7b99b7390f4bb82df4ea Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 16 Feb 2026 22:32:35 +0530 Subject: [PATCH 2/6] chore: ai panel fixes --- src-node/claude-code-agent.js | 20 ++++++++++--- src/core-ai/AIChatPanel.js | 29 +++++++++++++++--- src/styles/Extn-AIChatPanel.less | 50 +++++++++++++++++++++++++------- src/styles/Extn-SidebarTabs.less | 7 +++-- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 1a57141ec8..a6a1b49799 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -157,6 +157,8 @@ exports.cancelQuery = async function () { if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; + // Clear session so next query starts fresh instead of resuming a killed session + currentSessionId = null; return { success: true }; } return { success: false }; @@ -376,11 +378,15 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { }); } catch (err) { - if (signal.aborted) { - // Query was cancelled, not an error + const errMsg = err.message || String(err); + const isAbort = signal.aborted || /abort/i.test(errMsg); + + if (isAbort) { + // Query was cancelled — clear session so next query starts fresh + currentSessionId = null; nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: currentSessionId + sessionId: null }); return; } @@ -395,7 +401,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { nodeConnector.triggerPeer("aiError", { requestId: requestId, - error: err.message || String(err) + error: errMsg + }); + + // Always send aiComplete after aiError so the UI exits streaming state + nodeConnector.triggerPeer("aiComplete", { + requestId: requestId, + sessionId: currentSessionId }); } } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index f8520c22e3..af22b5d547 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -39,7 +39,7 @@ define(function (require, exports, module) { let _hasReceivedContent = false; // tracks if we've received any text/tool in current response // DOM references - let $panel, $messages, $status, $statusText, $textarea, $sendBtn; + let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; const PANEL_HTML = '
' + @@ -58,7 +58,10 @@ define(function (require, exports, module) { '
' + '' + '' + + '' + '
' + '
' + @@ -143,11 +146,16 @@ define(function (require, exports, module) { $statusText = $panel.find(".ai-status-text"); $textarea = $panel.find(".ai-chat-textarea"); $sendBtn = $panel.find(".ai-send-btn"); + $stopBtn = $panel.find(".ai-stop-btn"); // Event handlers $sendBtn.on("click", _sendMessage); + $stopBtn.on("click", _cancelQuery); $panel.find(".ai-new-session-btn").on("click", _newSession); + // Hide "+ New" button initially (no conversation yet) + $panel.find(".ai-new-session-btn").hide(); + $textarea.on("keydown", function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -198,6 +206,9 @@ define(function (require, exports, module) { return; } + // Show "+ New" button once a conversation starts + $panel.find(".ai-new-session-btn").show(); + // Append user message _appendUserMessage(text); @@ -255,6 +266,10 @@ define(function (require, exports, module) { if ($messages) { $messages.empty(); } + // Hide "+ New" button since we're back to empty state + if ($panel) { + $panel.find(".ai-new-session-btn").hide(); + } if ($status) { $status.removeClass("active"); } @@ -607,8 +622,14 @@ define(function (require, exports, module) { $textarea[0].focus({ preventScroll: true }); } } - if ($sendBtn) { - $sendBtn.prop("disabled", streaming); + if ($sendBtn && $stopBtn) { + if (streaming) { + $sendBtn.hide(); + $stopBtn.show(); + } else { + $stopBtn.hide(); + $sendBtn.show(); + } } if (!streaming && $messages) { // Clean up thinking indicator if still present diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 20c25887e5..14b02d2d21 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -36,23 +36,26 @@ display: flex; align-items: center; justify-content: space-between; - padding: 6px 10px; + padding: 10px 10px 9px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; .ai-chat-title { - font-weight: 600; - font-size: 11px; + font-weight: 400; + font-size: 15px; color: @project-panel-text-2; - letter-spacing: 0.3px; + line-height: 19px; } .ai-new-session-btn { + display: flex; + align-items: center; + gap: 4px; background: none; border: none; color: @project-panel-text-2; - font-size: 11px; - padding: 2px 6px; + font-size: 13px; + padding: 0 8px; border-radius: 3px; cursor: pointer; opacity: 0.7; @@ -475,7 +478,7 @@ overflow: hidden; &:focus-within { - border-color: rgba(255, 255, 255, 0.18); + border-color: @bc-btn-border-focused; } &.disabled { @@ -487,16 +490,18 @@ flex: 1; min-width: 0; background: none; - border: none; + border: none !important; color: @project-panel-text-1; font-size: 12px; font-family: inherit; padding: 7px 0 7px 10px; + margin: 0; resize: none; min-height: 20px; max-height: 96px; line-height: 1.4; - outline: none; + outline: none !important; + box-shadow: none !important; &:disabled { cursor: not-allowed; @@ -508,16 +513,15 @@ border: none; color: @project-panel-text-2; width: 30px; - height: 30px; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; + align-self: stretch; border-radius: 0 7px 7px 0; opacity: 0.5; transition: opacity 0.15s ease, color 0.15s ease; - margin-bottom: 1px; &:hover { opacity: 1; @@ -533,6 +537,30 @@ font-size: 12px; } } + + .ai-stop-btn { + background: none; + border: none; + color: #e06c75; + width: 30px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + align-self: stretch; + border-radius: 0 7px 7px 0; + opacity: 0.7; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + + i { + font-size: 12px; + } + } } /* ── Unavailable / placeholder state ────────────────────────────────── */ diff --git a/src/styles/Extn-SidebarTabs.less b/src/styles/Extn-SidebarTabs.less index 94cdb7caca..065ee17362 100644 --- a/src/styles/Extn-SidebarTabs.less +++ b/src/styles/Extn-SidebarTabs.less @@ -27,6 +27,7 @@ height: 2rem; overflow: hidden; user-select: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); &.has-tabs { display: flex; @@ -42,8 +43,7 @@ cursor: pointer; position: relative; color: @project-panel-text-2; - font-size: 0.8rem; - letter-spacing: 0.3px; + font-size: 0.85rem; transition: color 0.15s ease, background-color 0.15s ease; i { @@ -57,6 +57,7 @@ &.active { color: @project-panel-text-1; + background-color: rgba(255, 255, 255, 0.04); &::after { content: ""; @@ -65,7 +66,7 @@ left: 0; right: 0; height: 2px; - background-color: @project-panel-text-2; + background-color: @project-panel-text-1; } } } From 47bc7d3d380664e2c98bf744f7d4f12416ce53d9 Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 17 Feb 2026 08:11:49 +0530 Subject: [PATCH 3/6] fix: each tool use indicator now expands and collapses independantly --- src/core-ai/AIChatPanel.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index af22b5d547..8e4885a744 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -422,8 +422,10 @@ define(function (require, exports, module) { const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName }; + // Use requestId + toolId to ensure globally unique data-tool-id + const uniqueToolId = (_currentRequestId || "") + "-" + toolId; const $tool = $( - '
' + + '
' + '
' + '' + '' + @@ -441,7 +443,8 @@ define(function (require, exports, module) { * Update an existing tool indicator with details once tool input is known. */ function _updateToolIndicator(toolId, toolName, toolInput) { - const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + toolId + '"]'); + const uniqueToolId = (_currentRequestId || "") + "-" + toolId; + const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + uniqueToolId + '"]'); if (!$tool.length) { return; } From 2c81968529f744bac654a2e9669e29a31a182367 Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 17 Feb 2026 11:55:46 +0530 Subject: [PATCH 4/6] feat: claude code integration continued --- src-node/claude-code-agent.js | 78 ++++++- src/core-ai/AIChatPanel.js | 296 +++++++++++++++++++++--- src/core-ai/main.js | 8 + src/document/DocumentCommandHandlers.js | 2 + src/styles/Extn-AIChatPanel.less | 49 ++++ 5 files changed, 390 insertions(+), 43 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index a6a1b49799..b36cae5aa5 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -211,36 +211,89 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { hooks: [ async (input) => { console.log("[Phoenix AI] Intercepted Edit tool"); - collectedEdits.push({ + const edit = { file: input.tool_input.file_path, oldText: input.tool_input.old_string, newText: input.tool_input.new_string - }); + }; + collectedEdits.push(edit); + try { + await nodeConnector.execPeer("applyEditToBuffer", edit); + } catch (err) { + console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message); + } return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", - permissionDecisionReason: "Edit delegated to Phoenix editor" + permissionDecisionReason: "Edit applied successfully via Phoenix editor." } }; } ] }, + { + matcher: "Read", + hooks: [ + async (input) => { + const filePath = input.tool_input.file_path; + if (!filePath) { + return undefined; + } + try { + const result = await nodeConnector.execPeer("getFileContent", { filePath }); + if (result && result.isDirty && result.content !== null) { + const MAX_LINES = 2000; + const MAX_LINE_LENGTH = 2000; + const lines = result.content.split("\n"); + const offset = input.tool_input.offset || 0; + const limit = input.tool_input.limit || MAX_LINES; + const selected = lines.slice(offset, offset + limit); + let formatted = selected.map((line, i) => { + const truncated = line.length > MAX_LINE_LENGTH + ? line.slice(0, MAX_LINE_LENGTH) + "..." + : line; + return String(offset + i + 1).padStart(6) + "\t" + truncated; + }).join("\n"); + formatted = filePath + " (unsaved editor content, " + + lines.length + " lines total)\n\n" + formatted; + console.log("[Phoenix AI] Serving dirty file content for:", filePath); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: formatted + } + }; + } + } catch (err) { + console.warn("[Phoenix AI] Failed to check dirty state:", filePath, err.message); + } + return undefined; + } + ] + }, { matcher: "Write", hooks: [ async (input) => { console.log("[Phoenix AI] Intercepted Write tool"); - collectedEdits.push({ + const edit = { file: input.tool_input.file_path, oldText: null, newText: input.tool_input.content - }); + }; + collectedEdits.push(edit); + try { + await nodeConnector.execPeer("applyEditToBuffer", edit); + } catch (err) { + console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message); + } return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", - permissionDecisionReason: "Write delegated to Phoenix editor" + permissionDecisionReason: "Write applied successfully via Phoenix editor." } }; } @@ -279,6 +332,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { let activeToolIndex = null; let activeToolInputJson = ""; let toolCounter = 0; + let lastToolStreamTime = 0; for await (const message of result) { // Check abort @@ -310,11 +364,21 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { }); } - // Accumulate tool input JSON + // Accumulate tool input JSON and stream preview if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta" && event.index === activeToolIndex) { activeToolInputJson += event.delta.partial_json; + const now = Date.now(); + if (now - lastToolStreamTime >= TEXT_STREAM_THROTTLE_MS) { + lastToolStreamTime = now; + nodeConnector.triggerPeer("aiToolStream", { + requestId: requestId, + toolId: toolCounter, + toolName: activeToolName, + partialJson: activeToolInputJson + }); + } } // Tool block complete — parse input and send details diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 8e4885a744..5f7c377a91 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -29,6 +29,7 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), ProjectManager = require("project/ProjectManager"), + FileSystem = require("filesystem/FileSystem"), marked = require("thirdparty/marked.min"); let _nodeConnector = null; @@ -37,6 +38,7 @@ define(function (require, exports, module) { let _segmentText = ""; // text for the current segment only let _autoScroll = true; let _hasReceivedContent = false; // tracks if we've received any text/tool in current response + const _previousContentMap = {}; // filePath → previous content before edit, for undo support // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; @@ -103,6 +105,7 @@ define(function (require, exports, module) { _nodeConnector.on("aiTextStream", _onTextStream); _nodeConnector.on("aiProgress", _onProgress); _nodeConnector.on("aiToolInfo", _onToolInfo); + _nodeConnector.on("aiToolStream", _onToolStream); _nodeConnector.on("aiEditResult", _onEditResult); _nodeConnector.on("aiError", _onError); _nodeConnector.on("aiComplete", _onComplete); @@ -307,7 +310,8 @@ define(function (require, exports, module) { Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b", label: "Read" }, Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: "Edit" }, Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: "Write" }, - Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: "Run command" } + Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: "Run command" }, + Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: "Skill" } }; function _onProgress(_event, data) { @@ -325,6 +329,106 @@ define(function (require, exports, module) { _updateToolIndicator(data.toolId, data.toolName, data.toolInput); } + function _onToolStream(_event, data) { + const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId; + const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + uniqueToolId + '"]'); + if (!$tool.length) { + return; + } + + // Update label with filename as soon as file_path is available + if (!$tool.data("labelUpdated")) { + const filePath = _extractJsonStringValue(data.partialJson, "file_path"); + if (filePath) { + const fileName = filePath.split("/").pop(); + const config = TOOL_CONFIG[data.toolName] || {}; + $tool.find(".ai-tool-label").text((config.label || data.toolName) + " " + fileName + "..."); + $tool.data("labelUpdated", true); + } + } + + const preview = _extractToolPreview(data.toolName, data.partialJson); + if (preview) { + $tool.find(".ai-tool-preview").text(preview); + _scrollToBottom(); + } + } + + /** + * Extract a complete string value for a given key from partial JSON. + * Returns null if the key isn't found or the value isn't complete yet. + */ + function _extractJsonStringValue(partialJson, key) { + // Try both with and without space after colon: "key":"val" or "key": "val" + let pattern = '"' + key + '":"'; + let idx = partialJson.indexOf(pattern); + if (idx === -1) { + pattern = '"' + key + '": "'; + idx = partialJson.indexOf(pattern); + } + if (idx === -1) { + return null; + } + const start = idx + pattern.length; + // Find the closing quote (not escaped) + let end = start; + while (end < partialJson.length) { + if (partialJson[end] === '"' && partialJson[end - 1] !== '\\') { + return partialJson.slice(start, end).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + end++; + } + return null; // value not complete yet + } + + /** + * Extract a readable one-line preview from partial tool input JSON. + * Looks for the "interesting" key per tool type (e.g. content for Write). + */ + function _extractToolPreview(toolName, partialJson) { + if (!partialJson) { + return ""; + } + // Map tool names to the key whose value we want to preview + const interestingKey = { + Write: "content", + Edit: "new_string", + Bash: "command", + Grep: "pattern", + Glob: "pattern" + }[toolName]; + + let raw = ""; + if (interestingKey) { + // Find the interesting key and grab everything after it + const keyPattern = '"' + interestingKey + '":'; + const idx = partialJson.indexOf(keyPattern); + if (idx !== -1) { + raw = partialJson.slice(idx + keyPattern.length).slice(-120); + } + // If the interesting key hasn't appeared yet, show nothing + // rather than raw JSON noise like {"file_path":... + } else { + // No interesting key defined for this tool — use the tail + raw = partialJson.slice(-120); + } + if (!raw) { + return ""; + } + // Clean up JSON syntax noise into readable text + let preview = raw + .replace(/\\n/g, " ") + .replace(/\\t/g, " ") + .replace(/\\"/g, '"') + .replace(/\s+/g, " ") + .trim(); + // Strip leading JSON artifacts (quotes, whitespace) + preview = preview.replace(/^[\s"]+/, ""); + // Strip trailing incomplete JSON artifacts + preview = preview.replace(/["{}\[\]]*$/, "").trim(); + return preview; + } + function _onEditResult(_event, data) { if (data.edits && data.edits.length > 0) { data.edits.forEach(function (edit) { @@ -430,6 +534,7 @@ define(function (require, exports, module) { '' + '' + '
' + + '
' + '
' ); $tool.find(".ai-tool-label").text(config.label + "..."); @@ -477,6 +582,17 @@ define(function (require, exports, module) { }).css("cursor", "pointer"); } + // For file-related tools, make label clickable to open the file + if (toolInput && toolInput.file_path && + (toolName === "Read" || toolName === "Write" || toolName === "Edit")) { + const filePath = toolInput.file_path; + $tool.find(".ai-tool-label").on("click", function (e) { + e.stopPropagation(); + const vfsPath = _realToVfsPath(filePath); + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + }).css("cursor", "pointer").addClass("ai-tool-label-clickable"); + } + _scrollToBottom(); } @@ -519,6 +635,11 @@ define(function (require, exports, module) { summary: "Ran command", lines: input.command ? [input.command] : [] }; + case "Skill": + return { + summary: input.skill ? "Skill: " + input.skill : "Skill", + lines: input.args ? [input.args] : [] + }; default: return { summary: toolName, lines: [] }; } @@ -550,11 +671,17 @@ define(function (require, exports, module) { const $header = $( '
' + '' + - '' + + '' + '
' ); $header.find(".ai-edit-file").text(displayName); + // Click filename to open file in editor + $header.find(".ai-edit-file").on("click", function () { + const vfsPath = _realToVfsPath(fileName); + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + }); + const $toggle = $(''); const $diff = $('
'); @@ -580,19 +707,32 @@ define(function (require, exports, module) { $toggle.text($diff.hasClass("expanded") ? "Hide diff" : "Show diff"); }); - $header.find(".ai-edit-apply-btn").on("click", function () { + // Undo button — restores previous content + $header.find(".ai-edit-undo-btn").on("click", function () { const $btn = $(this); - if ($btn.hasClass("applied")) { + if ($btn.hasClass("undone")) { return; } - _applySingleEdit(edit) - .then(function () { - $btn.text("Applied").addClass("applied"); - }) - .catch(function (err) { - const $err = $('
').text(err.message || String(err)); - $card.append($err); - }); + if (edit.oldText === null) { + // Write (new file) — undo by restoring previous content + _undoEdit(edit.file, _previousContentMap[edit.file] || "") + .done(function () { + $btn.text("Undone").addClass("undone"); + }) + .fail(function (err) { + $card.append($('
').text(err.message || String(err))); + }); + } else { + // Edit — undo by reversing the replacement + const reverseEdit = { file: edit.file, oldText: edit.newText, newText: edit.oldText }; + _applySingleEdit(reverseEdit) + .done(function () { + $btn.text("Undone").addClass("undone"); + }) + .fail(function (err) { + $card.append($('
').text(err.message || String(err))); + }); + } }); $card.append($header); @@ -660,45 +800,93 @@ define(function (require, exports, module) { // --- Edit application --- /** - * Apply a single edit to a document. + * Apply a single edit to a document buffer (makes it a dirty tab). + * Called immediately when Claude's Write/Edit is intercepted, so + * subsequent Reads see the new content via the dirty-file hook. * @param {Object} edit - {file, oldText, newText} - * @return {$.Promise} + * @return {$.Promise} resolves with {previousContent} for undo support */ function _applySingleEdit(edit) { const result = new $.Deferred(); const vfsPath = _realToVfsPath(edit.file); + function _applyToDoc() { + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + const previousContent = doc.getText(); + if (edit.oldText === null) { + // Write (new file or full replacement) + doc.setText(edit.newText); + } else { + // Edit — find oldText and replace + const docText = doc.getText(); + const idx = docText.indexOf(edit.oldText); + if (idx === -1) { + result.reject(new Error("Text not found in file — it may have changed")); + return; + } + const startPos = doc._masterEditor ? + doc._masterEditor._codeMirror.posFromIndex(idx) : + _indexToPos(docText, idx); + const endPos = doc._masterEditor ? + doc._masterEditor._codeMirror.posFromIndex(idx + edit.oldText.length) : + _indexToPos(docText, idx + edit.oldText.length); + doc.replaceRange(edit.newText, startPos, endPos); + } + // Open the file in the editor + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + result.resolve({ previousContent: previousContent }); + } catch (err) { + result.reject(err); + } + }) + .fail(function (err) { + result.reject(err || new Error("Could not open document")); + }); + } + + if (edit.oldText === null) { + // Write — file may not exist yet. Create it on disk first so + // getDocumentForPath succeeds, then set content in the buffer. + const file = FileSystem.getFileForPath(vfsPath); + file.write("", function (err) { + if (err) { + result.reject(new Error("Could not create file: " + err)); + return; + } + _applyToDoc(); + }); + } else { + // Edit — file must already exist + _applyToDoc(); + } + + return result.promise(); + } + + /** + * Undo a previously applied edit by restoring the document content. + * For new files (previousContent is empty string), closes the document. + * @param {string} filePath - real filesystem path + * @param {string} previousContent - content to restore + * @return {$.Promise} + */ + function _undoEdit(filePath, previousContent) { + const result = new $.Deferred(); + const vfsPath = _realToVfsPath(filePath); + DocumentManager.getDocumentForPath(vfsPath) .done(function (doc) { try { - if (edit.oldText === null) { - // Write (new file or full replacement) - doc.setText(edit.newText); - } else { - // Edit — find oldText and replace - const docText = doc.getText(); - const idx = docText.indexOf(edit.oldText); - if (idx === -1) { - result.reject(new Error("Text not found in file — it may have changed")); - return; - } - const startPos = doc._masterEditor ? - doc._masterEditor._codeMirror.posFromIndex(idx) : - _indexToPos(docText, idx); - const endPos = doc._masterEditor ? - doc._masterEditor._codeMirror.posFromIndex(idx + edit.oldText.length) : - _indexToPos(docText, idx + edit.oldText.length); - doc.replaceRange(edit.newText, startPos, endPos); - } - // Open the file in the editor - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + doc.setText(previousContent); result.resolve(); } catch (err) { result.reject(err); } }) .fail(function (err) { - result.reject(err || new Error("Could not open document")); + result.reject(err || new Error("Could not open document for undo")); }); return result.promise(); @@ -753,7 +941,43 @@ define(function (require, exports, module) { return realPath; } + /** + * Check if a file has unsaved changes in the editor and return its content. + * Used by the node-side Read hook to serve dirty buffer content to Claude. + */ + function getFileContent(params) { + const vfsPath = _realToVfsPath(params.filePath); + const doc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (doc && doc.isDirty) { + return { isDirty: true, content: doc.getText() }; + } + return { isDirty: false, content: null }; + } + + /** + * Apply an edit to the editor buffer immediately (called by node-side hooks). + * The file appears as a dirty tab so subsequent Reads see the new content. + * @param {Object} params - {file, oldText, newText} + * @return {Promise<{applied: boolean, error?: string}>} + */ + function applyEditToBuffer(params) { + const deferred = new $.Deferred(); + _applySingleEdit(params) + .done(function (result) { + if (result && result.previousContent !== undefined) { + _previousContentMap[params.file] = result.previousContent; + } + deferred.resolve({ applied: true }); + }) + .fail(function (err) { + deferred.resolve({ applied: false, error: err.message || String(err) }); + }); + return deferred.promise(); + } + // Public API exports.init = init; exports.initPlaceholder = initPlaceholder; + exports.getFileContent = getFileContent; + exports.applyEditToBuffer = applyEditToBuffer; }); diff --git a/src/core-ai/main.js b/src/core-ai/main.js index f998ad8e85..01ffb04927 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -33,6 +33,14 @@ define(function (require, exports, module) { var AI_CONNECTOR_ID = "ph_ai_claude"; + exports.getFileContent = async function (params) { + return AIChatPanel.getFileContent(params); + }; + + exports.applyEditToBuffer = async function (params) { + return AIChatPanel.applyEditToBuffer(params); + }; + AppInit.appReady(function () { SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 428d0c9afe..387089dfed 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -60,6 +60,7 @@ define(function (require, exports, module) { NodeConnector = require("NodeConnector"), NodeUtils = require("utils/NodeUtils"), ChangeHelper = require("editor/EditorHelper/ChangeHelper"), + SidebarTabs = require("view/SidebarTabs"), _ = require("thirdparty/lodash"); const KernalModeTrust = window.KernalModeTrust; @@ -1955,6 +1956,7 @@ define(function (require, exports, module) { function handleShowInTree() { let activeFile = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); if(activeFile){ + SidebarTabs.setActiveTab(SidebarTabs.SIDEBAR_TAB_FILES); ProjectManager.showInTree(activeFile); } } diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 14b02d2d21..ac7b5a0672 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -98,6 +98,8 @@ word-wrap: break-word; overflow-wrap: anywhere; min-width: 0; + user-select: text; + cursor: text; } } @@ -265,6 +267,11 @@ font-size: 11px; color: @project-panel-text-2; line-height: 1.3; + + &.ai-tool-label-clickable:hover { + color: @project-panel-text-1; + text-decoration: underline; + } } .ai-tool-detail { @@ -282,7 +289,28 @@ word-break: break-all; } + .ai-tool-preview { + font-size: 10px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + color: @project-panel-text-2; + opacity: 0.5; + padding: 0 8px 4px 28px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + + &:empty { + display: none; + } + } + &.ai-tool-done { + .ai-tool-preview { + display: none; + } + .ai-tool-label { opacity: 0.75; } @@ -351,6 +379,12 @@ white-space: nowrap; flex: 1; margin-right: 6px; + cursor: pointer; + + &:hover { + color: @project-panel-text-1; + text-decoration: underline; + } } .ai-edit-apply-btn { @@ -373,6 +407,21 @@ color: rgba(100, 200, 100, 0.4); cursor: default; } + + &.ai-edit-undo-btn { + border-color: rgba(200, 160, 80, 0.3); + color: rgba(220, 180, 100, 0.9); + + &:hover { + background-color: rgba(200, 160, 80, 0.08); + } + + &.undone { + border-color: rgba(200, 160, 80, 0.15); + color: rgba(200, 160, 80, 0.4); + cursor: default; + } + } } } From 6064079581a74849eed8b95c324128575ec8f242 Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 17 Feb 2026 12:33:59 +0530 Subject: [PATCH 5/6] feat: add SidebarView resize API and auto-widen sidebar for AI tab - Add SidebarView.resize(width) and SidebarView.getWidth() APIs for programmatic sidebar resizing with proper persistence and resync - Auto-widen sidebar to 370px on first AI tab activation when narrower than the preferred width, using a one-time view state flag - Switch to Files tab when Show in File Tree is triggered from another tab - Expose SidebarView on brackets.test for integration tests - Add integration tests for resize API, Show in File Tree tab switching, and sidebar width save/restore --- src/brackets.js | 1 + src/project/SidebarView.js | 36 ++++++++++ src/view/SidebarTabs.js | 26 ++++++- test/spec/SidebarTabs-integ-test.js | 107 ++++++++++++++++++++++++---- 4 files changed, 156 insertions(+), 14 deletions(-) diff --git a/src/brackets.js b/src/brackets.js index f111a8a390..b14f4e1b99 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -294,6 +294,7 @@ define(function (require, exports, module) { SearchResultsView: require("search/SearchResultsView"), ScrollTrackMarkers: require("search/ScrollTrackMarkers"), SidebarTabs: require("view/SidebarTabs"), + SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), doneLoading: false }; diff --git a/src/project/SidebarView.js b/src/project/SidebarView.js index 0ac1c63274..ac495ed512 100644 --- a/src/project/SidebarView.js +++ b/src/project/SidebarView.js @@ -326,9 +326,45 @@ define(function (require, exports, module) { CommandManager.register(Strings.CMD_SHOW_SIDEBAR, Commands.SHOW_SIDEBAR, show); CommandManager.register(Strings.CMD_HIDE_SIDEBAR, Commands.HIDE_SIDEBAR, hide); + /** + * Programmatically resize the sidebar to the given width. Persists + * the new size so it is restored on reload, resyncs the drag handle, + * and fires `panelResizeEnd`. + * + * @param {number} width Desired sidebar width in pixels + */ + function resize(width) { + if (!$sidebar || !$sidebar.length) { + return; + } + width = Math.round(width); + $sidebar.width(width); + $(".content").css("left", width); + Resizer.resyncSizer($sidebar); + var sidebarPrefs = PreferencesManager.getViewState("sidebar") || {}; + sidebarPrefs.size = width; + PreferencesManager.setViewState("sidebar", sidebarPrefs); + $sidebar.trigger("panelResizeEnd", [width]); + } + + /** + * Get the current sidebar width in pixels. Returns the CSS width + * even if the sidebar is hidden (so the value can be restored later). + * + * @return {number} + */ + function getWidth() { + if (!$sidebar || !$sidebar.length) { + return 0; + } + return $sidebar.width(); + } + // Define public API exports.toggle = toggle; exports.show = show; exports.hide = hide; exports.isVisible = isVisible; + exports.resize = resize; + exports.getWidth = getWidth; }); diff --git a/src/view/SidebarTabs.js b/src/view/SidebarTabs.js index 993d26162d..bd44689e0b 100644 --- a/src/view/SidebarTabs.js +++ b/src/view/SidebarTabs.js @@ -37,8 +37,9 @@ */ define(function (require, exports, module) { - const AppInit = require("utils/AppInit"), - EventDispatcher = require("utils/EventDispatcher"); + const AppInit = require("utils/AppInit"), + EventDispatcher = require("utils/EventDispatcher"), + PreferencesManager = require("preferences/PreferencesManager"); // --- Constants ----------------------------------------------------------- @@ -48,6 +49,16 @@ define(function (require, exports, module) { */ const SIDEBAR_TAB_FILES = "sidebar-tab-files"; + /** + * Preferred sidebar width (px) when a non-files tab (e.g. AI) is + * first activated. Applied once if the current width is narrower. + * @const {number} + */ + const AI_TAB_GOOD_WIDTH = 370; + + /** Preference key used to track whether the initial width bump has been applied. */ + const PREF_AI_WIDTH_SET_INITIAL = "aiTabWidthSetInitial"; + // --- Events -------------------------------------------------------------- /** @@ -423,6 +434,17 @@ define(function (require, exports, module) { _applyTabVisibility(); + // One-time sidebar width bump when switching to a non-files tab + if (id !== SIDEBAR_TAB_FILES && $sidebar && $sidebar.length) { + if (!PreferencesManager.getViewState(PREF_AI_WIDTH_SET_INITIAL)) { + const SidebarView = require("project/SidebarView"); + if (SidebarView.getWidth() < AI_TAB_GOOD_WIDTH) { + SidebarView.resize(AI_TAB_GOOD_WIDTH); + } + PreferencesManager.setViewState(PREF_AI_WIDTH_SET_INITIAL, true); + } + } + if (previousTabId !== id) { exports.trigger(EVENT_TAB_CHANGED, id, previousTabId); } diff --git a/test/spec/SidebarTabs-integ-test.js b/test/spec/SidebarTabs-integ-test.js index 792898598c..0f664fadf1 100644 --- a/test/spec/SidebarTabs-integ-test.js +++ b/test/spec/SidebarTabs-integ-test.js @@ -18,16 +18,20 @@ * */ -/*global describe, it, expect, beforeAll, afterAll, beforeEach, awaitsFor */ +/*global describe, it, expect, beforeAll, afterAll, beforeEach, awaitsFor, awaitsForDone, jsPromise */ define(function (require, exports, module) { const SpecRunnerUtils = require("spec/SpecRunnerUtils"); let SidebarTabs, + SidebarView, + CommandManager, + Commands, testWindow, brackets, - _$; + _$, + originalSidebarWidth; // Test tab constants const TEST_TAB_ID = "sidebar-tab-test"; @@ -39,23 +43,22 @@ define(function (require, exports, module) { testWindow = await SpecRunnerUtils.createTestWindowAndRun(); brackets = testWindow.brackets; SidebarTabs = brackets.test.SidebarTabs; + SidebarView = brackets.test.SidebarView; + CommandManager = brackets.test.CommandManager; + Commands = brackets.test.Commands; _$ = testWindow.$; + originalSidebarWidth = SidebarView.getWidth(); }, 30000); afterAll(async function () { - // Reset to files tab + // Reset to files tab and restore original sidebar width SidebarTabs.setActiveTab(SidebarTabs.SIDEBAR_TAB_FILES); - - // Remove any test tabs that may still exist - const allTabs = SidebarTabs.getAllTabs(); - allTabs.forEach(function (tab) { - if (tab.id !== SidebarTabs.SIDEBAR_TAB_FILES) { - // Clear content first so removeTab succeeds - // (skip tabs that have content - they belong to other extensions) - } - }); + SidebarView.resize(originalSidebarWidth); SidebarTabs = null; + SidebarView = null; + CommandManager = null; + Commands = null; testWindow = null; brackets = null; _$ = null; @@ -454,5 +457,85 @@ define(function (require, exports, module) { expect($navTabBar.hasClass("has-tabs")).toBe(countAfterRemove >= 2); }); }); + + describe("Show in File Tree command", function () { + const testProjectPath = SpecRunnerUtils.getTestPath("/spec/DocumentCommandHandlers-test-files"); + + beforeAll(async function () { + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterAll(async function () { + // Close all files opened during these tests + await awaitsForDone( + CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), + "close all files" + ); + }, 30000); + + it("should switch to files tab when executed from a non-files tab", async function () { + // Open a file so handleShowInTree has an activeFile + await awaitsForDone( + SpecRunnerUtils.openProjectFiles(["test.js"]), + "open test file" + ); + + SidebarTabs.addTab(TEST_TAB_ID, "Test Tab", "fa-solid fa-flask"); + SidebarTabs.setActiveTab(TEST_TAB_ID); + expect(SidebarTabs.getActiveTab()).toBe(TEST_TAB_ID); + + await awaitsForDone( + CommandManager.execute(Commands.NAVIGATE_SHOW_IN_FILE_TREE), + "show in file tree" + ); + + expect(SidebarTabs.getActiveTab()).toBe(SidebarTabs.SIDEBAR_TAB_FILES); + }); + + it("should remain on files tab when already on files tab", async function () { + await awaitsForDone( + SpecRunnerUtils.openProjectFiles(["test.js"]), + "open test file" + ); + expect(SidebarTabs.getActiveTab()).toBe(SidebarTabs.SIDEBAR_TAB_FILES); + + await awaitsForDone( + CommandManager.execute(Commands.NAVIGATE_SHOW_IN_FILE_TREE), + "show in file tree" + ); + + expect(SidebarTabs.getActiveTab()).toBe(SidebarTabs.SIDEBAR_TAB_FILES); + }); + }); + + describe("SidebarView resize", function () { + let SidebarView, originalWidth; + + beforeAll(function () { + SidebarView = brackets.test.SidebarView; + originalWidth = SidebarView.getWidth(); + }); + + afterAll(function () { + SidebarView.resize(originalWidth); + }); + + it("should change the sidebar width", function () { + SidebarView.resize(400); + expect(SidebarView.getWidth()).toBe(400); + }); + + it("should update the .content left offset", function () { + SidebarView.resize(350); + expect(parseInt(_$(".content").css("left"), 10)).toBe(350); + }); + + it("should persist the width in view state", function () { + const PreferencesManager = testWindow.brackets.test.PreferencesManager; + SidebarView.resize(380); + const prefs = PreferencesManager.getViewState("sidebar"); + expect(prefs.size).toBe(380); + }); + }); }); }); From 72e263e39e1ce01490eff1570a75ed8e6e63f17b Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 17 Feb 2026 14:28:43 +0530 Subject: [PATCH 6/6] fix: tool streaming preview visible during active tool use - Delay adding ai-tool-done class by 1.5s so the streaming preview remains visible while the tool is active and briefly after completion - Add final flush of aiToolStream on content_block_stop to ensure browser receives latest preview data before tool completes - Update get_browser_console_logs MCP tool description to mention PhNode logs are included --- phoenix-builder-mcp/mcp-tools.js | 1 + src-node/claude-code-agent.js | 11 ++++++++++- src/core-ai/AIChatPanel.js | 11 +++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js index 14ac662981..ded3e6d45a 100644 --- a/phoenix-builder-mcp/mcp-tools.js +++ b/phoenix-builder-mcp/mcp-tools.js @@ -161,6 +161,7 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe server.tool( "get_browser_console_logs", "Get console logs from the Phoenix browser runtime. Returns last 50 entries by default. " + + "This includes both browser-side console logs and Node.js (PhNode) logs, which are prefixed with 'PhNode:'. " + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + "prefer filter + small tail to keep responses compact.", diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index b36cae5aa5..4bb66e5d34 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -381,10 +381,19 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { } } - // Tool block complete — parse input and send details + // Tool block complete — flush final stream preview and send details if (event.type === "content_block_stop" && event.index === activeToolIndex && activeToolName) { + // Final flush of tool stream (bypasses throttle) + if (activeToolInputJson) { + nodeConnector.triggerPeer("aiToolStream", { + requestId: requestId, + toolId: toolCounter, + toolName: activeToolName, + partialJson: activeToolInputJson + }); + } let toolInput = {}; try { toolInput = JSON.parse(activeToolInputJson); diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 5f7c377a91..9244ec580a 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -557,8 +557,7 @@ define(function (require, exports, module) { const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName }; const detail = _getToolDetail(toolName, toolInput); - // Mark as done: replace spinner with colored icon - $tool.addClass("ai-tool-done"); + // Replace spinner with colored icon immediately $tool.find(".ai-tool-spinner").replaceWith( '' + '' + @@ -593,6 +592,14 @@ define(function (require, exports, module) { }).css("cursor", "pointer").addClass("ai-tool-label-clickable"); } + // 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. + setTimeout(function () { + $tool.addClass("ai-tool-done"); + $tool.find(".ai-tool-preview").text(""); + }, 1500); + _scrollToBottom(); }