Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
111 changes: 104 additions & 7 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ let _questionResolve = null;
// Pending plan resolver — used by ExitPlanMode stream interception
let _planResolve = null;

// Pending bash confirmation resolver — used by Bash PreToolUse hook (Edit Mode)
let _bashConfirmResolve = null;

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

Expand Down Expand Up @@ -209,7 +212,7 @@ exports.checkAvailability = async function () {
* aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete
*/
exports.sendPrompt = async function (params) {
const { prompt, projectPath, sessionAction, model, locale, selectionContext, images, envOverrides } = params;
const { prompt, projectPath, sessionAction, model, locale, selectionContext, images, envOverrides, permissionMode } = params;
const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);

// Handle session
Expand Down Expand Up @@ -252,7 +255,7 @@ exports.sendPrompt = async function (params) {
}

// Run the query asynchronously — don't await here so we return requestId immediately
_runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images, envOverrides)
_runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images, envOverrides, permissionMode)
.catch(err => {
console.error("[Phoenix AI] Query error:", err);
});
Expand All @@ -272,6 +275,7 @@ exports.cancelQuery = async function () {
// Clear any pending question or plan
_questionResolve = null;
_planResolve = null;
_bashConfirmResolve = null;
_queuedClarification = null;
return { success: true };
}
Expand Down Expand Up @@ -302,6 +306,18 @@ exports.answerPlan = async function (params) {
return { success: true };
};

/**
* Receive the user's response to a bash confirmation prompt (Edit Mode).
* Called from browser via execPeer("answerBashConfirm", {allowed}).
*/
exports.answerBashConfirm = async function (params) {
if (_bashConfirmResolve) {
_bashConfirmResolve(params);
_bashConfirmResolve = 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 @@ -313,6 +329,7 @@ exports.resumeSession = async function (params) {
}
_questionResolve = null;
_planResolve = null;
_bashConfirmResolve = null;
_queuedClarification = null;
currentSessionId = params.sessionId;
return { success: true };
Expand Down Expand Up @@ -370,7 +387,7 @@ exports.clearClarification = async function () {
/**
* Internal: run a Claude SDK query and stream results back to the browser.
*/
async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides) {
async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides, permissionMode) {
let editCount = 0;
let toolCounter = 0;
let queryFn;
Expand Down Expand Up @@ -455,7 +472,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
}
},
mcpServers: { "phoenix-editor": editorMcpServer },
permissionMode: "acceptEdits",
permissionMode: permissionMode || "acceptEdits",
appendSystemPrompt:
"When modifying an existing file, always prefer the Edit tool " +
"(find-and-replace) instead of the Write tool. The Write tool should ONLY be used " +
Expand All @@ -470,13 +487,18 @@ 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\nYou are running inside Phoenix Code, a web-focused code editor with a built-in " +
"live preview for HTML/CSS/JS. When the user asks to create mockups, prototypes, " +
"or web pages, prefer vanilla HTML/CSS/JS so the live preview can render and " +
"edit them — unless the user specifically requests a framework. " +
"Build responsive layouts by default for web content." +
"\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." +
"\n\nFor tasks that involve creating new applications, extensive modifications, " +
"or architectural changes, enter plan mode first to propose a plan " +
"for user approval before writing code." +
"\n\nUse your best judgement for when to enter plan mode. Use it when the task " +
"involves creating new applications, extensive modifications, or architectural " +
"changes — propose a plan for user approval before writing code." +
(locale && !locale.startsWith("en")
? "\n\nThe user's display language is " + locale + ". " +
"Respond in this language unless they write in a different language."
Expand All @@ -491,6 +513,39 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
hooks: [
async (input) => {
console.log("[Phoenix AI] Intercepted Edit tool");
// Plan file edits: capture content, write to disk, skip editor
const editPath = (input.tool_input.file_path || "").replace(/\\/g, "/");
if (editPath.includes("/.claude/plans/")) {
try {
let content = "";
if (fs.existsSync(input.tool_input.file_path)) {
content = fs.readFileSync(input.tool_input.file_path, "utf8");
}
if (input.tool_input.old_string && input.tool_input.new_string) {
content = content.replace(input.tool_input.old_string, input.tool_input.new_string);
}
const dir = path.dirname(input.tool_input.file_path);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(input.tool_input.file_path, content, "utf8");
_lastPlanContent = content;
console.log("[Phoenix AI] Captured plan edit content:", content.length + "ch");
} catch (err) {
console.warn("[Phoenix AI] Failed to edit plan file:", err.message);
}
let planReason = "Plan file updated.";
if (_queuedClarification) {
planReason += CLARIFICATION_HINT;
}
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: planReason
}
};
}
const myToolId = toolCounter; // capture before any await
const edit = {
file: input.tool_input.file_path,
Expand Down Expand Up @@ -658,6 +713,48 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
}
]
},
{
matcher: "Bash",
hooks: [
async (input) => {
if (permissionMode !== "acceptEdits") {
// Plan mode: SDK handles. Full Auto: allow freely.
return {};
}
// Edit Mode: ask user confirmation before running bash
const command = input.tool_input.command || "";
console.log("[Phoenix AI] Bash confirmation requested:", command.slice(0, 80));
nodeConnector.triggerPeer("aiBashConfirm", {
requestId: requestId,
command: command,
toolId: toolCounter
});
const response = await new Promise((resolve, reject) => {
_bashConfirmResolve = resolve;
if (signal.aborted) {
_bashConfirmResolve = null;
reject(new Error("Aborted"));
return;
}
const onAbort = () => {
_bashConfirmResolve = null;
reject(new Error("Aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
if (response.allowed) {
return {};
}
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "User denied this command."
}
};
}
]
},
{
matcher: "AskUserQuestion",
hooks: [
Expand Down
1 change: 0 additions & 1 deletion src/editor/EditorManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,6 @@ define(function (require, exports, module) {
* removed. For example, after a dialog with editable text is closed.
*/
function focusEditor() {
DeprecationWarning.deprecationWarning("Use MainViewManager.focusActivePane() instead of EditorManager.focusEditor().", true);
MainViewManager.focusActivePane();
}

Expand Down
9 changes: 9 additions & 0 deletions src/nls/root/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,14 @@ define({
"AI_CHAT_PLAN_REVISE": "Revise",
"AI_CHAT_PLAN_FEEDBACK_PLACEHOLDER": "What would you like changed?",
"AI_CHAT_PLAN_REVISE_DEFAULT": "Please revise the plan.",
"AI_CHAT_MODE_PLAN": "Plan Mode",
"AI_CHAT_MODE_EDIT": "Edit Mode",
"AI_CHAT_MODE_FULL_AUTO": "Full Auto",
"AI_CHAT_BASH_CONFIRM_TITLE": "Allow command?",
"AI_CHAT_BASH_ALLOW": "Allow",
"AI_CHAT_BASH_DENY": "Deny",
"AI_CHAT_BASH_ALLOWED": "Command allowed",
"AI_CHAT_BASH_DENIED": "Command denied",
"AI_CHAT_CODE_DEFAULT_LANG": "text",
"AI_CHAT_CODE_COLLAPSE": "Collapse",
"AI_CHAT_CODE_EXPAND": "Expand",
Expand All @@ -1934,6 +1942,7 @@ define({
"AI_CHAT_PREVIEW_OPEN": "Preview",
"AI_CHAT_PREVIEW_VIEWING": "Previewing",
"AI_CHAT_QUESTION_OTHER": "Type a custom answer\u2026",
"AI_CHAT_QUESTION_SUBMIT": "Submit",
"AI_CHAT_IMAGE_LIMIT": "Maximum {0} images allowed",
"AI_CHAT_IMAGE_REMOVE": "Remove image",
"AI_CHAT_ATTACH_FILE": "Attach files",
Expand Down
135 changes: 132 additions & 3 deletions src/styles/Extn-AIChatPanel.less
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,10 @@
}

.ai-question-submit {
align-self: flex-end;
display: flex;
align-items: center;
gap: 5px;
margin-left: auto;
background: none;
border: 1px solid rgba(76, 175, 80, 0.3);
color: rgba(76, 175, 80, 0.85);
Expand Down Expand Up @@ -1088,6 +1091,7 @@
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
transition: background-color 0.15s ease, color 0.15s ease;

&:hover:not(:disabled) {
Expand Down Expand Up @@ -1408,6 +1412,92 @@
}
}

/* ── Bash confirmation card ─────────────────────────────────────────── */
.ai-bash-confirm {
border: 1px solid rgba(243, 156, 18, 0.3);
border-radius: 8px;
background: rgba(243, 156, 18, 0.06);
margin: 8px 0;
overflow: hidden;

.ai-bash-confirm-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-weight: 600;
font-size: @sidebar-content-font-size;
color: #f39c12;
border-bottom: 1px solid rgba(243, 156, 18, 0.15);
}

.ai-bash-confirm-body {
padding: 8px 12px;

pre {
margin: 0;
padding: 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.2);
color: #e0e0e0;
font-size: @sidebar-content-font-size;
white-space: pre-wrap;
word-break: break-all;
}
}

.ai-bash-confirm-actions {
display: flex;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid rgba(243, 156, 18, 0.15);
}

.ai-bash-allow-btn {
background: rgba(76, 175, 80, 0.15);
border: 1px solid rgba(76, 175, 80, 0.35);
color: #81c784;

Check warning on line 1459 in src/styles/Extn-AIChatPanel.less

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Text does not meet the minimal contrast requirement with its background.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZz696jGXj4sTZAzsZE4&open=AZz696jGXj4sTZAzsZE4&pullRequest=2745
padding: 4px 14px;
border-radius: 4px;
cursor: pointer;
font-size: @sidebar-xs-font-size;

&:hover {
background: rgba(76, 175, 80, 0.25);
}
}

.ai-bash-deny-btn {
background: rgba(231, 76, 60, 0.15);
border: 1px solid rgba(231, 76, 60, 0.35);
color: #e57373;

Check warning on line 1473 in src/styles/Extn-AIChatPanel.less

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Text does not meet the minimal contrast requirement with its background.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZz696jGXj4sTZAzsZE5&open=AZz696jGXj4sTZAzsZE5&pullRequest=2745
padding: 4px 14px;
border-radius: 4px;
cursor: pointer;
font-size: @sidebar-xs-font-size;

&:hover {
background: rgba(231, 76, 60, 0.25);
}
}

}

.ai-bash-result {
padding: 4px 10px;
border-radius: 4px;
font-size: @sidebar-xs-font-size;
margin: 4px 0;

&.ai-bash-result-allowed {
color: #81c784;
}

&.ai-bash-result-denied {
color: #e57373;
}
}

/* ── Queued clarification bubble (static, above input) ─────────────── */
.ai-queued-msg {
border: 1px dashed rgba(255, 255, 255, 0.15);
Expand Down Expand Up @@ -1620,11 +1710,50 @@
}
}

.ai-permission-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
cursor: pointer;
user-select: none;

&:hover {
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
}

.ai-permission-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;

&.mode-auto {
background-color: #e74c3c;
}

&.mode-edit {
background-color: #f39c12;
}

&.mode-plan {
background-color: #3498db;
}
}

.ai-permission-label {
font-size: @sidebar-xs-font-size;
color: @project-panel-text-2;
line-height: 1;
}
}

.ai-chat-context-bar {
display: none;
flex-wrap: wrap;
gap: 4px;
padding: 0 4px 4px 4px;
gap: 6px;
padding: 0 4px 6px 4px;

&.has-chips {
display: flex;
Expand Down
2 changes: 1 addition & 1 deletion tracking-repos.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"phoenixPro": {
"commitID": "21f226b54906adc5bd2cb0dd0d68d587dc9d22fe"
"commitID": "4ca7bc6eea7ad4c94fbf321ae2c854f533fb8bd2"
}
}
Loading