diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js
index 14ac66298..ded3e6d45 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
new file mode 100644
index 000000000..4bb66e5d3
--- /dev/null
+++ b/src-node/claude-code-agent.js
@@ -0,0 +1,486 @@
+/*
+ * 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;
+ // Clear session so next query starts fresh instead of resuming a killed session
+ currentSessionId = 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");
+ 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 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");
+ 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 applied successfully via 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;
+ let lastToolStreamTime = 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 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 — 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);
+ } 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) {
+ 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: null
+ });
+ 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: errMsg
+ });
+
+ // Always send aiComplete after aiError so the UI exits streaming state
+ nodeConnector.triggerPeer("aiComplete", {
+ requestId: requestId,
+ sessionId: currentSessionId
+ });
+ }
+}
diff --git a/src-node/index.js b/src-node/index.js
index c3087273f..95472485a 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 86b04e13a..9ba207622 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 d6bc88143..59eb1cf45 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/brackets.js b/src/brackets.js
index f111a8a39..b14f4e1b9 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/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js
new file mode 100644
index 000000000..9244ec580
--- /dev/null
+++ b/src/core-ai/AIChatPanel.js
@@ -0,0 +1,990 @@
+/*
+ * 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"),
+ FileSystem = require("filesystem/FileSystem"),
+ 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
+ const _previousContentMap = {}; // filePath → previous content before edit, for undo support
+
+ // DOM references
+ let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn;
+
+ 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("aiToolStream", _onToolStream);
+ _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");
+ $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();
+ _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;
+ }
+
+ // Show "+ New" button once a conversation starts
+ $panel.find(".ai-new-session-btn").show();
+
+ // 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();
+ }
+ // Hide "+ New" button since we're back to empty state
+ if ($panel) {
+ $panel.find(".ai-new-session-btn").hide();
+ }
+ 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" },
+ Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: "Skill" }
+ };
+
+ 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 _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) {
+ _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 };
+
+ // Use requestId + toolId to ensure globally unique data-tool-id
+ const uniqueToolId = (_currentRequestId || "") + "-" + toolId;
+ const $tool = $(
+ '