Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8879472
feat: improve AI install screen and header branding
abose Mar 14, 2026
c4541fc
feat: add learn more link to AI install screen
abose Mar 14, 2026
03d91a3
feat: add terminal shellCommand param and install UX improvements
abose Mar 14, 2026
e3c2f0e
feat: add restart note string and styling for install screen
abose Mar 14, 2026
636201e
feat: detect Claude login status and add setup strings
abose Mar 14, 2026
05d4df6
fix: remove SDK gate from checkAvailability and add provider btn string
abose Mar 14, 2026
5d029de
feat: add learn more link to Claude setup screen string
abose Mar 14, 2026
3587d89
fix: code block text color in AI panel for light theme
abose Mar 14, 2026
abff220
fix: windows claude code detection working
abose Mar 15, 2026
dbe1f70
fix: terminal shellCommand execution on Windows
abose Mar 15, 2026
827c227
chore: ai panel show more buttons only on hover
abose Mar 15, 2026
609cef6
fix: ai header buttons use btn-alt-quiet style and left-align title o…
abose Mar 15, 2026
0dcde45
build: update pro deps
abose Mar 15, 2026
2f70638
fix: prod build fails
abose Mar 15, 2026
96e511a
fix: prod build live preview edit fails
abose Mar 15, 2026
31eb7ad
build: update pro deps
abose Mar 15, 2026
667eec8
build: update pro deps
abose Mar 15, 2026
62f9bd4
build: update pro deps
abose Mar 15, 2026
460e876
feat(ai): gracefully handle plan mode in AI chat
abose Mar 15, 2026
81c3ecf
fix(ai): skip opening plan files in editor
abose Mar 15, 2026
1957028
feat(ai): add verification hint to system prompt for plan mode
abose Mar 15, 2026
04a3e99
fix(ai): scope verification hint to live preview HTML work
abose Mar 15, 2026
b98d16d
fix(ai): clarify takeScreenshot works globally, not just live preview
abose Mar 15, 2026
1e9df98
fix(ai): let Claude decide if verification tools are needed
abose Mar 15, 2026
d330d64
fix(ai): mention takeScreenshot can target specific panels
abose Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions gulpfile.js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -610,10 +610,9 @@ function containsRegExpExcludingEmpty(str) {
const minifyablePaths = [
'src/extensionsIntegrated/phoenix-pro/browser-context'
];

function _minifyBrowserContextFile(fileContent) {
const minified = terser.minify(fileContent, {
mangle: true,
mangle: false,
compress: {
unused: false
},
Expand Down Expand Up @@ -690,7 +689,9 @@ function inlineTextRequire(file, content, srcDir, isDevBuild = true) {
throw `Error inlining ${requireStatement} in ${file}: Regex: ${detectedRegEx}`+
"\nRegular expression of the form /*/ is not allowed for minification please use RegEx constructor";
}
content = content.replaceAll(requireStatement, `${JSON.stringify(textContent)}`);
// Escape $ in replacement to prevent special replacement patterns ($&, $1, etc.)
const safeReplacement = JSON.stringify(textContent).replaceAll("$", "$$$$");
content = content.replaceAll(requireStatement, safeReplacement);
}
}

Expand Down
214 changes: 203 additions & 11 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
*/

const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { createEditorMcpServer } = require("./mcp-editor-tools");

const isWindows = process.platform === "win32";

const CONNECTOR_ID = "ph_ai_claude";

const CLARIFICATION_HINT =
Expand All @@ -54,6 +57,18 @@ const TEXT_STREAM_THROTTLE_MS = 50;
// Pending question resolver — used by AskUserQuestion hook
let _questionResolve = null;

// Pending plan resolver — used by ExitPlanMode stream interception
let _planResolve = null;

// Stores rejection feedback when user rejects a plan
let _planRejectionFeedback = null;

// Stores the last plan content written to .claude/plans/
let _lastPlanContent = null;

// Flag set when user approves a plan
let _planApproved = false;

// Queued clarification from the user (typed while AI is streaming)
// Shape: { text: string, images: [{mediaType, base64Data}] } or null
let _queuedClarification = null;
Expand All @@ -71,9 +86,45 @@ async function getQueryFn() {
}

/**
* Find the user's globally installed Claude CLI, skipping node_modules copies.
* Find the user's globally installed Claude CLI on Windows.
*/
function findGlobalClaudeCli() {
function _findGlobalClaudeCliWin() {
const userHome = process.env.USERPROFILE || process.env.HOME || "";
const locations = [
path.join(userHome, ".local", "bin", "claude.exe"),
path.join(process.env.APPDATA || "", "npm", "claude.cmd"),
path.join(process.env.LOCALAPPDATA || "", "Programs", "claude", "claude.exe")
];

// Try 'where claude' first to find claude in PATH
try {
const allPaths = execSync("where claude", { encoding: "utf8" })
.trim()
.split("\r\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 {
// where failed, try manual locations
}

// Check common Windows locations
for (const loc of locations) {
if (loc && fs.existsSync(loc)) {
console.log("[Phoenix AI] Found global Claude CLI at:", loc);
return loc;
}
}

return null;
}

/**
* Find the user's globally installed Claude CLI on macOS/Linux.
*/
function _findGlobalClaudeCliLinuxMac() {
const locations = [
"/usr/local/bin/claude",
"/usr/bin/claude",
Expand Down Expand Up @@ -108,25 +159,43 @@ function findGlobalClaudeCli() {
}
}

console.log("[Phoenix AI] Global Claude CLI not found");
return null;
}

/**
* Find the user's globally installed Claude CLI, skipping node_modules copies.
*/
function findGlobalClaudeCli() {
const claudePath = isWindows ? _findGlobalClaudeCliWin() : _findGlobalClaudeCliLinuxMac();
if (!claudePath) {
console.log("[Phoenix AI] Global Claude CLI not found");
}
return claudePath;
}

/**
* 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 };
if (!claudePath) {
return { available: false, claudePath: null, error: "Claude Code CLI not found" };
}
// Check if user is logged in
let loggedIn = false;
try {
const authOutput = execSync(claudePath + " auth status", {
encoding: "utf8",
timeout: 10000
});
const authStatus = JSON.parse(authOutput);
loggedIn = authStatus.loggedIn === true;
} catch (e) {
// auth status failed — treat as not logged in
}
// No global CLI found — try importing SDK anyway (it might find its own)
await getQueryFn();
return { available: true, claudePath: null };
return { available: true, claudePath: claudePath, loggedIn: loggedIn };
} catch (err) {
return { available: false, claudePath: null, error: err.message };
}
Expand Down Expand Up @@ -200,8 +269,9 @@ exports.cancelQuery = async function () {
currentAbortController = null;
// Clear session so next query starts fresh instead of resuming a killed session
currentSessionId = null;
// Clear any pending question
// Clear any pending question or plan
_questionResolve = null;
_planResolve = null;
_queuedClarification = null;
return { success: true };
}
Expand All @@ -220,6 +290,18 @@ exports.answerQuestion = async function (params) {
return { success: true };
};

/**
* Receive the user's response to a proposed plan.
* Called from browser via execPeer("answerPlan", {approved, feedback}).
*/
exports.answerPlan = async function (params) {
if (_planResolve) {
_planResolve(params);
_planResolve = null;
}
return { success: true };
};

/**
* Resume a previous session by setting the session ID.
* The next sendPrompt call will use queryOptions.resume with this session ID.
Expand All @@ -230,6 +312,7 @@ exports.resumeSession = async function (params) {
currentAbortController = null;
}
_questionResolve = null;
_planResolve = null;
_queuedClarification = null;
currentSessionId = params.sessionId;
return { success: true };
Expand Down Expand Up @@ -339,6 +422,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
"AskUserQuestion", "Task",
"TodoRead", "TodoWrite",
"WebFetch", "WebSearch",
"EnterPlanMode", "ExitPlanMode",
"mcp__phoenix-editor__getEditorState",
"mcp__phoenix-editor__takeScreenshot",
"mcp__phoenix-editor__execJsInLivePreview",
Expand Down Expand Up @@ -386,6 +470,10 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
"controlEditor). Never use relative paths." +
"\n\nWhen a tool response mentions the user has typed a clarification, immediately " +
"call getUserClarification to read it and incorporate the user's feedback into your current work." +
"\n\nWhen planning, consider if verification is needed. takeScreenshot can " +
"capture the full editor, specific panels, the code area, or the live preview. " +
"For HTML/CSS/JS with live preview, execJsInLivePreview can run JS in the " +
"browser to confirm behavior." +
(locale && !locale.startsWith("en")
? "\n\nThe user's display language is " + locale + ". " +
"Respond in this language unless they write in a different language."
Expand Down Expand Up @@ -494,6 +582,31 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
hooks: [
async (input) => {
console.log("[Phoenix AI] Intercepted Write tool");
// Capture plan content when writing to .claude/plans/
// Don't open plan files in editor — shown in plan card UI
const writePath = input.tool_input.file_path || "";
if (writePath.includes("/.claude/plans/")) {
_lastPlanContent = input.tool_input.content || "";
console.log("[Phoenix AI] Captured plan content:",
_lastPlanContent.length + "ch");
if (_queuedClarification) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason:
"Plan file saved." + CLARIFICATION_HINT
}
};
}
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Plan file saved."
}
};
}
const myToolId = toolCounter; // capture before any await
const edit = {
file: input.tool_input.file_path,
Expand Down Expand Up @@ -870,6 +983,45 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
toolId: toolCounter,
toolInput: toolInput
});

// ExitPlanMode: show plan to user and wait for approval
// Plan text comes from a prior Write to .claude/plans/ (captured in hook)
if (activeToolName === "ExitPlanMode") {
const planText = toolInput.plan || _lastPlanContent || "";
_lastPlanContent = null;
if (planText) {
_log("ExitPlanMode plan detected (" + planText.length + "ch), sending to browser");
nodeConnector.triggerPeer("aiPlanProposed", {
requestId: requestId,
plan: planText
});
// Pause stream processing until user approves/rejects
const planResponse = await new Promise((resolve, reject) => {
_planResolve = resolve;
if (signal.aborted) {
_planResolve = null;
reject(new Error("Aborted"));
return;
}
const onAbort = () => {
_planResolve = null;
reject(new Error("Aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
if (!planResponse.approved) {
_log("Plan rejected by user, aborting");
currentAbortController.abort();
_planRejectionFeedback = planResponse.feedback || "";
} else {
_log("Plan approved by user, will send proceed prompt");
_planApproved = true;
}
} else {
_log("ExitPlanMode with no plan content, skipping UI");
}
}

activeToolName = null;
activeToolIndex = null;
activeToolInputJson = "";
Expand Down Expand Up @@ -908,6 +1060,32 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
_log("Complete: tools=" + toolCounter, "edits=" + editCount,
"textDeltas=" + textDeltaCount, "textSent=" + textStreamSendCount);

// Check if plan was approved — send follow-up to proceed with implementation
if (_planApproved) {
_planApproved = false;
_log("Plan approved, sending proceed prompt");
nodeConnector.triggerPeer("aiComplete", {
requestId: requestId,
sessionId: currentSessionId,
planApproved: true
});
return;
}

// Check if stream ended due to plan rejection (abort + break)
if (_planRejectionFeedback !== null) {
const feedback = _planRejectionFeedback;
_planRejectionFeedback = null;
_log("Plan rejected, sending revision request");
nodeConnector.triggerPeer("aiComplete", {
requestId: requestId,
sessionId: currentSessionId,
planRejected: true,
planFeedback: feedback
});
return;
}

// Signal completion
nodeConnector.triggerPeer("aiComplete", {
requestId: requestId,
Expand All @@ -920,6 +1098,20 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
const isAbort = signal.aborted || /abort/i.test(errMsg);

if (isAbort) {
// Check if this was a plan rejection — if so, send feedback as follow-up
if (_planRejectionFeedback !== null) {
const feedback = _planRejectionFeedback;
_planRejectionFeedback = null;
_log("Plan rejected, sending revision request");
// Don't clear session — resume with feedback
nodeConnector.triggerPeer("aiComplete", {
requestId: requestId,
sessionId: currentSessionId,
planRejected: true,
planFeedback: feedback
});
return;
}
_log("Cancelled");
// Send sessionId so browser side can save partial history for later resume
const cancelledSessionId = currentSessionId;
Expand Down
4 changes: 3 additions & 1 deletion src/command/DefaultMenus.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,9 @@ define(function (require, exports, module) {
menu.addMenuItem(Commands.TOGGLE_RULERS);
menu.addMenuDivider();
menu.addMenuItem(Commands.VIEW_TOGGLE_PROBLEMS);
menu.addMenuItem(Commands.VIEW_TERMINAL);
if (Phoenix.isNativeApp) {
menu.addMenuItem(Commands.VIEW_TERMINAL);
}
menu.addMenuItem(Commands.VIEW_TOGGLE_INSPECTION);

/*
Expand Down
10 changes: 10 additions & 0 deletions src/extensionsIntegrated/Terminal/TerminalInstance.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ define(function (require, exports, module) {
this._webglAddon = null;
this._lastDpr = null;

// Promise that resolves when the shell sends its first output (prompt ready)
this._firstDataResolve = null;
this.firstDataReceived = new Promise(function (resolve) {
this._firstDataResolve = resolve;
}.bind(this));

// Bound event handlers for cleanup
this._onTerminalData = this._onTerminalData.bind(this);
this._onTerminalExit = this._onTerminalExit.bind(this);
Expand Down Expand Up @@ -217,6 +223,10 @@ define(function (require, exports, module) {
TerminalInstance.prototype._onTerminalData = function (_event, eventData) {
if (eventData.id === this.id && this.terminal) {
this.terminal.write(eventData.data);
if (this._firstDataResolve) {
this._firstDataResolve();
this._firstDataResolve = null;
}
}
};

Expand Down
Loading
Loading