diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 82a11d3f9f..22be2d86bf 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -63,6 +63,11 @@ let _planResolve = null; // Pending bash confirmation resolver — used by Bash PreToolUse hook (Edit Mode) let _bashConfirmResolve = null; +// Pending plan-mode write confirmation resolver — set when an Edit/Write +// fires in plan mode and we're awaiting the user's "Allow & Switch to Edit +// Mode" / "Stay in Plan Mode" choice from the browser. +let _planModeConfirmResolve = null; + // Stores rejection feedback when user rejects a plan let _planRejectionFeedback = null; @@ -76,8 +81,39 @@ let _planApproved = false; // Shape: { text: string, images: [{mediaType, base64Data}] } or null let _queuedClarification = null; +// Module-level "runtime" permission mode that hooks read at decision time. +// Updated on every sendPrompt and via the setPermissionMode peer when the +// user cycles the panel's permission bar mid-stream — without this, the +// Bash hook would close over the value at query start and continue +// prompting for confirmation even after the user has flipped to Full Auto. +let _runtimePermissionMode = "acceptEdits"; + const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); +/** + * Detect whether a PostToolUse `tool_response` represents an error result. + * Used to suppress diff-card painting when the SDK's native Edit/Write itself + * failed (e.g. oldText not found on disk). The shape of tool_response is + * `unknown` per the SDK types — handle the common variants defensively. + */ +function _isToolResponseError(toolResponse) { + if (!toolResponse) { return false; } + if (typeof toolResponse === "object") { + if (toolResponse.is_error === true || toolResponse.isError === true) { return true; } + if (Array.isArray(toolResponse.content)) { + for (const c of toolResponse.content) { + if (c && typeof c.text === "string" && //i.test(c.text)) { + return true; + } + } + } + } + if (typeof toolResponse === "string" && //i.test(toolResponse)) { + return true; + } + return false; +} + /** * Lazily import the ESM @anthropic-ai/claude-code module. */ @@ -276,6 +312,7 @@ exports.cancelQuery = async function () { _questionResolve = null; _planResolve = null; _bashConfirmResolve = null; + _planModeConfirmResolve = null; _queuedClarification = null; return { success: true }; } @@ -318,6 +355,34 @@ exports.answerBashConfirm = async function (params) { return { success: true }; }; +/** + * Receive the user's response to a plan-mode write confirmation prompt. + * Called from browser via execPeer("answerPlanModeWriteConfirm", {approved}). + */ +exports.answerPlanModeWriteConfirm = async function (params) { + if (_planModeConfirmResolve) { + _planModeConfirmResolve(params); + _planModeConfirmResolve = null; + } + return { success: true }; +}; + +/** + * Apply a mid-stream permission-mode change so hooks running for the rest + * of the turn use the new value. Called from the browser when the user + * cycles the permission bar (so e.g. Bash stops prompting immediately + * after switching from Edit Mode to Full Auto). The next sendPrompt also + * passes permissionMode in params, so this peer is only strictly required + * during streaming — but calling it on every cycle keeps the agent's + * tracker authoritative. + */ +exports.setPermissionMode = async function (params) { + if (params && typeof params.mode === "string") { + _runtimePermissionMode = params.mode; + } + return { success: true }; +}; + /** * Resume a previous session by setting the session ID. * The next sendPrompt call will use queryOptions.resume with this session ID. @@ -388,8 +453,20 @@ 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, permissionMode) { + // Sync the runtime mutable that hooks read for permission decisions — + // setPermissionMode (peer) updates this same variable when the user + // cycles modes mid-stream. + _runtimePermissionMode = permissionMode || "acceptEdits"; let editCount = 0; let toolCounter = 0; + // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a + // tool_result block can be mapped back to its indicator on the browser. + const _toolUseIdToCounter = {}; + // Set true once the user clicks "Allow & Switch to Edit Mode" on a + // plan-mode write confirmation. Subsequent Edit/Write attempts in the same + // turn skip the prompt and use the cached "allow" decision so a multi-edit + // turn doesn't pop a dialog before every edit. + let _planExitApprovedThisTurn = false; let queryFn; let connectionTimer = null; @@ -422,7 +499,26 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } let _lastStderrLines = []; - const MAX_STDERR_LINES = 20; + const MAX_STDERR_LINES = 50; + let _hookErrorBuffer = ""; + let _hookErrorTimer = null; + const HOOK_ERROR_FLUSH_MS = 200; + + function _flushHookError() { + if (_hookErrorBuffer) { + const trimmed = _hookErrorBuffer.trim(); + console.error("[AI hook callback error] SDK threw delivering hook payload" + + " — tool likely ran natively in acceptEdits mode:\n" + trimmed); + try { + nodeConnector.triggerPeer("aiHookError", { + requestId: requestId, + error: trimmed + }); + } catch (e) { /* peer may be gone — ignore */ } + _hookErrorBuffer = ""; + } + _hookErrorTimer = null; + } const queryOptions = { cwd: projectPath || process.cwd(), @@ -433,6 +529,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (_lastStderrLines.length > MAX_STDERR_LINES) { _lastStderrLines.shift(); } + // Collect consecutive lines belonging to a hook callback error so + // we can log the full burst as one block. The SDK fragments the + // error across multiple stderr writes which is hard to read. + if (_hookErrorBuffer || /Error in hook callback/.test(data)) { + _hookErrorBuffer += data + "\n"; + clearTimeout(_hookErrorTimer); + _hookErrorTimer = setTimeout(_flushHookError, HOOK_ERROR_FLUSH_MS); + } }, allowedTools: [ "Read", "Edit", "Write", "Glob", "Grep", "Bash", @@ -552,46 +656,106 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - const myToolId = toolCounter; // capture before any await - const edit = { - file: input.tool_input.file_path, - oldText: input.tool_input.old_string, - newText: input.tool_input.new_string, - replaceAll: input.tool_input.replace_all === true - }; - editCount++; - let editResult; + // Plan mode + user-file Edit: ask the user whether + // to switch to Edit Mode. Mirrors the Bash confirm + // pattern (matcher: "Bash"). Once approved, the + // _planExitApprovedThisTurn flag suppresses the + // prompt for subsequent edits in the same turn. + const filePath = input.tool_input.file_path; + if (permissionMode === "plan" && !_planExitApprovedThisTurn) { + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + toolName: "Edit", + filePath: filePath + }); + let response; + try { + response = await new Promise((resolve, reject) => { + _planModeConfirmResolve = resolve; + if (signal.aborted) { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Edit cancelled." + } + }; + } + if (!response.approved) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User chose to stay in Plan Mode. " + + "Use the ExitPlanMode tool to propose your changes for " + + "approval before editing." + } + }; + } + _planExitApprovedThisTurn = true; + } + // New flow: flush dirty buffer to disk so SDK reads + // the latest content, capture pre-edit content for + // snapshot tracking, then return {} (or "allow" if + // we're auto-exiting plan mode) so SDK runs native + // Edit on disk. Its mtime/read tracker stays + // consistent and the next Edit won't trip the + // "modified since read" safety check. + const oldString = input.tool_input.old_string; + let captured = { content: "" }; try { - editResult = await nodeConnector.execPeer("applyEditToBuffer", edit); + await nodeConnector.execPeer("saveBufferToDisk", { filePath }); + captured = await nodeConnector.execPeer( + "captureFileContent", { filePath }) || captured; } catch (err) { - console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message); - editResult = { applied: false, error: err.message }; + console.warn("[Phoenix AI] Edit prep failed:", filePath, err.message); } - nodeConnector.triggerPeer("aiToolEdit", { - requestId: requestId, - toolId: myToolId, - edit: edit - }); - let reason; - if (editResult && editResult.applied === false) { - reason = "Edit FAILED: " + (editResult.error || "unknown error"); - } else { - reason = "Edit applied successfully via Phoenix editor."; - if (editResult && editResult.isLivePreviewRelated) { - reason += " The edited file is part of the active live preview." + - " Reload when ready with execJsInLivePreview: `location.reload()`"; + // Pre-check: if the text to replace is no longer in + // the file (user typed/changed it since the last + // Read), deny with an informative reason instead of + // letting the SDK fail with a generic "oldText not + // found". Phoenix sees the buffer state the SDK + // can't, so this is a more useful failure. + if (oldString && (captured.content || "").indexOf(oldString) === -1) { + let reason = "Edit FAILED: the text you wanted to replace is not " + + "present in the file. It may have been modified by the user " + + "or by another tool since you last read it. Read the file again " + + "to see the current content before retrying."; + if (_queuedClarification) { + reason += CLARIFICATION_HINT; } + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; } - if (_queuedClarification) { - reason += CLARIFICATION_HINT; + editCount++; + // In plan mode, after the user approved the + // confirmation prompt, we need an explicit "allow" + // to override the SDK's default plan-mode block. + if (permissionMode === "plan") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason - } - }; + return {}; } ] }, @@ -599,44 +763,20 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Read", hooks: [ async (input) => { - if (!input || !input.tool_input) { - return {}; - } - const filePath = input.tool_input.file_path; - if (!filePath) { + if (!input || !input.tool_input || !input.tool_input.file_path) { return {}; } + // Flush dirty buffer to disk so the SDK's native + // Read sees what the user is actually looking at. + // Returning {} lets the SDK run native Read so its + // read-tracker updates — required to avoid "file + // not read yet" rejections on subsequent edits. 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 + " (" + - lines.length + " lines total)\n\n" + formatted; - console.log("[Phoenix AI] Serving dirty file content for:", filePath); - if (_queuedClarification) { - formatted += CLARIFICATION_HINT; - } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: formatted - } - }; - } + await nodeConnector.execPeer("saveBufferToDisk", + { filePath: input.tool_input.file_path }); } catch (err) { - console.warn("[Phoenix AI] Failed to check dirty state:", filePath, err.message); + console.warn("[Phoenix AI] Read prep failed:", + input.tool_input.file_path, err.message); } return {}; } @@ -678,45 +818,71 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - const myToolId = toolCounter; // capture before any await - const edit = { - file: input.tool_input.file_path, - oldText: null, - newText: input.tool_input.content - }; - editCount++; - let writeResult; + // Plan mode + user-file Write: same confirmation + // path as Edit. See Edit hook above for rationale. + const filePath = input.tool_input.file_path; + if (permissionMode === "plan" && !_planExitApprovedThisTurn) { + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + toolName: "Write", + filePath: filePath + }); + let response; + try { + response = await new Promise((resolve, reject) => { + _planModeConfirmResolve = resolve; + if (signal.aborted) { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Write cancelled." + } + }; + } + if (!response.approved) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User chose to stay in Plan Mode. " + + "Use the ExitPlanMode tool to propose your changes for " + + "approval before writing." + } + }; + } + _planExitApprovedThisTurn = true; + } + // Mirror Edit: flush dirty buffer, capture pre-write + // content, return {} (or "allow" in plan mode) so + // SDK writes natively. try { - writeResult = await nodeConnector.execPeer("applyEditToBuffer", edit); + await nodeConnector.execPeer("saveBufferToDisk", { filePath }); + await nodeConnector.execPeer("captureFileContent", { filePath }); } catch (err) { - console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message); - writeResult = { applied: false, error: err.message }; - } - nodeConnector.triggerPeer("aiToolEdit", { - requestId: requestId, - toolId: myToolId, - edit: edit - }); - let reason; - if (writeResult && writeResult.applied === false) { - reason = "Write FAILED: " + (writeResult.error || "unknown error"); - } else { - reason = "Write applied successfully via Phoenix editor."; - if (writeResult && writeResult.isLivePreviewRelated) { - reason += " The written file is part of the active live preview." + - " Reload when ready with execJsInLivePreview: `location.reload()`"; - } + console.warn("[Phoenix AI] Write prep failed:", filePath, err.message); } - if (_queuedClarification) { - reason += CLARIFICATION_HINT; + editCount++; + if (permissionMode === "plan") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason - } - }; + return {}; } ] }, @@ -724,7 +890,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Bash", hooks: [ async (input) => { - if (permissionMode !== "acceptEdits") { + // Read from the runtime mutable so mid-stream + // permission-mode flips (e.g. user switches Edit + // Mode → Full Auto while bash is in flight) take + // effect on the NEXT bash call without waiting + // for the next prompt. + if (_runtimePermissionMode !== "acceptEdits") { // Plan mode: SDK handles. Full Auto: allow freely. return {}; } @@ -804,10 +975,140 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } ] } + ], + PostToolUse: [ + { + matcher: "Edit", + hooks: [ + async (input, toolUseID) => { + const filePath = input && input.tool_input && input.tool_input.file_path; + if (!filePath) { return {}; } + // Plan files don't go through the editor + if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { + return {}; + } + // If the SDK's native Edit itself failed (e.g. + // oldText not found on disk), don't paint a diff + // card. The existing aiToolResult flow will + // classify the indicator from the tool_result. + if (_isToolResponseError(input.tool_response)) { + return {}; + } + const editPayload = { + file: filePath, + oldText: input.tool_input.old_string, + newText: input.tool_input.new_string, + replaceAll: input.tool_input.replace_all === true + }; + // 1. Prefer applying the edit directly to the open + // buffer via doc.replaceRange — preserves + // CodeMirror marks outside the edit region (live + // preview HTML element marks). Falls back to a + // full refreshDocumentFromDisk if no doc is open + // or the buffer no longer contains old_string + // (e.g. user typed since save). + let result = {}; + try { + result = await nodeConnector.execPeer( + "applyEditToOpenBufferOnly", editPayload) || {}; + } catch (err) { + console.warn("[Phoenix AI] applyEditToOpenBufferOnly failed:", filePath, err.message); + } + if (!result.applied) { + try { + result = await nodeConnector.execPeer( + "refreshDocumentFromDisk", { filePath }) || result; + } catch (err) { + console.warn("[Phoenix AI] Edit refresh fallback failed:", filePath, err.message); + } + } + // 2. Trigger aiToolEdit so the AI panel renders the + // diff card and the snapshot store records it. + const counterId = _toolUseIdToCounter[toolUseID]; + if (counterId !== undefined) { + editPayload.isLivePreviewRelated = !!result.isLivePreviewRelated; + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: counterId, + edit: editPayload + }); + } + // Catch-all PostToolUse below handles clarification. + return {}; + } + ] + }, + { + matcher: "Write", + hooks: [ + async (input, toolUseID) => { + const filePath = input && input.tool_input && input.tool_input.file_path; + if (!filePath) { return {}; } + if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { + return {}; + } + if (_isToolResponseError(input.tool_response)) { + return {}; + } + let refreshResult = {}; + try { + refreshResult = await nodeConnector.execPeer( + "refreshDocumentFromDisk", { filePath }) || {}; + } catch (err) { + console.warn("[Phoenix AI] Write refresh failed:", filePath, err.message); + } + const counterId = _toolUseIdToCounter[toolUseID]; + if (counterId !== undefined) { + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: counterId, + edit: { + file: filePath, + oldText: null, + newText: input.tool_input.content, + isLivePreviewRelated: !!refreshResult.isLivePreviewRelated + } + }); + } + // Catch-all PostToolUse below handles clarification. + return {}; + } + ] + }, + { + // Catch-all: surface a queued user follow-up after every + // tool. Edit/Write/Read have their own hooks above, but + // any tool can be a meaningful checkpoint (Bash, Grep, + // Glob, WebFetch, Task, the Phoenix MCP tools, etc.) so + // we register one matcher-less hook that just returns + // the clarification context if any is queued. Once + // getUserClarification runs and clears _queuedClarification, + // _maybeClarifyContext returns {} and this becomes a no-op. + hooks: [ + async () => { + return _maybeClarifyContext(); + } + ] + } ] } }; + // Returns a PostToolUse SyncHookJSONOutput that injects the clarification + // hint as additionalContext when the user has typed a follow-up while the + // AI is streaming. With our PreToolUse hooks now returning {} (allow), the + // old practice of appending CLARIFICATION_HINT to permissionDecisionReason + // no longer reaches Claude — PostToolUse additionalContext is the new path. + function _maybeClarifyContext() { + if (!_queuedClarification) { return {}; } + return { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: CLARIFICATION_HINT + } + }; + } + // Set Claude CLI path if found const claudePath = findGlobalClaudeCli(); if (claudePath) { @@ -1038,6 +1339,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, toolDeltaCount = 0; toolStreamSendCount = 0; lastToolStreamTime = 0; + // Map the SDK's tool_use id → our toolCounter so we can + // correlate later tool_result blocks back to the indicator. + if (event.content_block.id) { + _toolUseIdToCounter[event.content_block.id] = toolCounter; + } _log("Tool start:", activeToolName, "#" + toolCounter); nodeConnector.triggerPeer("aiProgress", { requestId: requestId, @@ -1185,6 +1491,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "isError=" + !!block.is_error, "len=" + len + "ch", preview ? ("preview=" + JSON.stringify(preview)) : ""); + // Forward the result so the browser can reflect outcome + // on the corresponding tool indicator (errored vs ran). + const counterId = _toolUseIdToCounter[block.tool_use_id]; + if (counterId !== undefined) { + nodeConnector.triggerPeer("aiToolResult", { + requestId: requestId, + toolId: counterId, + isError: !!block.is_error, + preview: preview + }); + } } } } diff --git a/src/editor/Editor.js b/src/editor/Editor.js index c19540a58f..3564a813c2 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -717,7 +717,8 @@ define(function (require, exports, module) { * @param {!string} text */ Editor.prototype._resetText = function (text) { - var currentText = this._codeMirror.getValue(); + var cm = this._codeMirror; + var currentText = cm.getValue(); // compare with ignoring line-endings, issue #11826 var textLF = text ? text.replace(/(\r\n|\r|\n)/g, "\n") : null; @@ -732,14 +733,51 @@ define(function (require, exports, module) { var cursorPos = this.getCursorPos(), scrollPos = this.getScrollPos(); - // This *will* fire a change event, but we clear the undo immediately afterward - this._codeMirror.setValue(text); - this._codeMirror.refresh(); + // First-time content load (e.g. opening a file): there's no useful + // undo state to preserve — fall back to setValue + clearHistory so + // the user can't ctrl-z into the empty doc that existed before open. + var isInitialLoad = currentText === "" && cm.historySize().undo === 0; - // Make sure we can't undo back to the empty state before setValue(), and mark - // the document clean. - this._codeMirror.clearHistory(); - this._codeMirror.markClean(); + if (isInitialLoad) { + cm.setValue(text); + cm.refresh(); + cm.clearHistory(); + } else { + // External-content reload (disk change, AI edit, git checkout, etc.): + // replace ONLY the differing middle of the document so the change + // is undoable AND CodeMirror marks outside the changed region are + // preserved. The latter matters for HTML files used in live + // preview (each rendered element holds a mark) — replacing the + // whole doc would clear every mark and force a full preview + // refresh on every edit. + // + // O(n) common prefix + suffix scan: cheap enough to apply to all + // languages, and AI edits are typically tiny relative to file size. + var prefixLen = 0; + var minLen = Math.min(currentText.length, text.length); + while (prefixLen < minLen && + currentText.charCodeAt(prefixLen) === text.charCodeAt(prefixLen)) { + prefixLen++; + } + var maxSuffix = Math.min(currentText.length - prefixLen, text.length - prefixLen); + var suffixLen = 0; + while (suffixLen < maxSuffix && + currentText.charCodeAt(currentText.length - 1 - suffixLen) === + text.charCodeAt(text.length - 1 - suffixLen)) { + suffixLen++; + } + var fromPos = cm.posFromIndex(prefixLen); + var toPos = cm.posFromIndex(currentText.length - suffixLen); + var middle = text.substring(prefixLen, text.length - suffixLen); + cm.operation(function () { + cm.replaceRange(middle, fromPos, toPos, "+disk"); + }); + cm.refresh(); + } + // markClean sets the new "saved" generation — disk == buffer right + // now, so dirty=false. Undoing past this generation will re-mark + // dirty, exactly like a manual edit. + cm.markClean(); // restore cursor and scroll positions this.setCursorPos(cursorPos); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 509a1f7e97..eacba28a80 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2199,6 +2199,8 @@ define({ "AI_CHAT_TOOL_SEARCHED": "Searched: {0}", "AI_CHAT_TOOL_GREP": "Grep: {0}", "AI_CHAT_TOOL_READ_FILE": "Read {0}", + "AI_CHAT_TOOL_READ_FILE_RANGE": "Read {0} lines {1}-{2}", + "AI_CHAT_TOOL_READ_FILE_FROM": "Read {0} from line {1}", "AI_CHAT_TOOL_EDIT_FILE": "Edit {0}", "AI_CHAT_TOOL_WRITE_FILE": "Write {0}", "AI_CHAT_TOOL_RAN_CMD": "Ran command", @@ -2214,10 +2216,19 @@ define({ "AI_CHAT_UNDO_TITLE": "Undo changes from this response", "AI_CHAT_RESTORE_TITLE": "Restore files to this point", "AI_CHAT_RESTORED": "Restored", + "AI_CHAT_TOOL_APPLIED_DIRECTLY": "Applied directly to disk · diff not available", + "AI_CHAT_TOOL_REJECTED": "Edit rejected — file not modified", + "AI_CHAT_TOOL_REJECTED_REASON": "Reason: {0}", + "AI_CHAT_FILE_NOT_FOUND_TITLE": "File not found", + "AI_CHAT_FILE_NOT_FOUND_MSG": "Could not open {0}. The file may have been moved or deleted.", "AI_CHAT_UNDO_RESTORE_WARNING_TITLE": "AI Undo & Restore", "AI_CHAT_UNDO_RESTORE_WARNING_BODY": "This will only undo changes made by the AI. Changes made outside the AI won’t be restored and may be lost. For full version history, use version control like Git.", "AI_CHAT_SHOW_DIFF": "Show diff", "AI_CHAT_HIDE_DIFF": "Hide diff", + "AI_CHAT_DIFF_MORE_TITLE": "Diff options", + "AI_CHAT_DIFF_EXPAND_ALL": "Expand all", + "AI_CHAT_DIFF_COLLAPSE_ALL": "Collapse all", + "AI_CHAT_DIFF_ALWAYS_SHOW": "Always show", "AI_CHAT_LABEL_YOU": "You", "AI_CHAT_LABEL_CLAUDE": "Claude", "AI_CHAT_SEND_ERROR": "Failed to send message: {0}", @@ -2255,6 +2266,12 @@ define({ "AI_CHAT_BASH_DENY": "Deny", "AI_CHAT_BASH_ALLOWED": "Command allowed", "AI_CHAT_BASH_DENIED": "Command denied", + "AI_CHAT_PLAN_WRITE_CONFIRM_TITLE": "Switch to Edit Mode?", + "AI_CHAT_PLAN_WRITE_CONFIRM_BODY": "Claude wants to edit {0}. You're currently in Plan Mode.", + "AI_CHAT_PLAN_WRITE_ALLOW": "Allow & Switch to Edit Mode", + "AI_CHAT_PLAN_WRITE_STAY": "Stay in Plan Mode", + "AI_CHAT_PLAN_WRITE_ALLOWED": "Switched to Edit Mode", + "AI_CHAT_PLAN_WRITE_STAYED": "Stayed in Plan Mode", "AI_CHAT_CODE_DEFAULT_LANG": "text", "AI_CHAT_CODE_COLLAPSE": "Collapse", "AI_CHAT_CODE_EXPAND": "Expand", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index e65503c6d4..d3f03e162e 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -20,6 +20,19 @@ /* AI Chat Panel — sidebar chat UI for Claude Code integration */ +// AI panel typography contract — every font-size below MUST use one of these +// (or a relative unit for headings). Centralising the scale here keeps the +// panel coherent across cards: tool indicators, diff cards, confirm dialogs, +// the proposed plan, etc. all read as one product instead of independently +// styled fragments. Body is 14px (one tick larger than Phoenix's 13px sidebar +// default) so prose breathes more — closer to what users get from Lovable +// /ChatGPT/Claude desktop. Secondary and meta stay at the smaller tiers. +@ai-text-body: @menu-item-font-size; // 14px — message content, tool labels, prose +@ai-text-secondary: @sidebar-small-font-size; // 12px — in-card buttons, mono code, file paths +@ai-text-meta: @sidebar-xs-font-size; // 11px — stats, status, permission/context info +@ai-line-prose: 1.6; // multi-line body text — generous reading rhythm +@ai-line-compact: 1.4; // single-line UI elements + .ai-tab-container { display: flex; flex-direction: column; @@ -38,7 +51,7 @@ overflow: hidden; background-color: @bc-ai-sidebar-bg; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; container-type: inline-size; } @@ -176,7 +189,7 @@ } .ai-history-item-title { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-1; white-space: nowrap; overflow: hidden; @@ -184,7 +197,7 @@ } .ai-history-item-time { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.6; } @@ -193,7 +206,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 2px 4px; cursor: pointer; opacity: 0; @@ -214,7 +227,7 @@ .ai-history-empty { padding: 16px 10px; text-align: center; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; opacity: 0.6; } @@ -223,7 +236,7 @@ display: block; padding: 6px 10px; text-align: center; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.5; cursor: pointer; @@ -242,7 +255,7 @@ justify-content: center; gap: 6px; padding: 4px 10px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.6; border-bottom: 1px solid rgba(255, 255, 255, 0.04); @@ -257,7 +270,7 @@ flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 8px 10px; + padding: 12px 14px; min-height: 0; min-width: 0; -webkit-user-select: text; @@ -268,7 +281,7 @@ /* ── Individual messages ────────────────────────────────────────────── */ .ai-msg { - margin-bottom: 12px; + margin-bottom: 16px; max-width: 100%; // Skip layout / paint for messages that are far off-screen. For a large // chat history (>1k messages, >10k DOM nodes), showing the sidebar after @@ -277,7 +290,7 @@ contain-intrinsic-size: auto 300px; .ai-msg-label { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; margin-bottom: 3px; font-weight: 600; @@ -285,8 +298,8 @@ } .ai-msg-content { - font-size: @sidebar-content-font-size; - line-height: 1.55; + font-size: @ai-text-body; + line-height: @ai-line-prose; white-space: normal; word-wrap: break-word; overflow-wrap: anywhere; @@ -309,8 +322,8 @@ .ai-msg-content { background-color: rgba(255, 255, 255, 0.07); - padding: 6px 10px; - border-radius: 10px 10px 2px 10px; + padding: 8px 12px; + border-radius: 12px 12px 2px 12px; max-width: 88%; text-align: left; } @@ -332,7 +345,7 @@ background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; - font-size: 11px; + font-size: @ai-text-meta; color: @project-panel-text-2; max-width: 180px; @@ -389,6 +402,7 @@ .ai-msg-assistant { .ai-msg-content { padding: 2px 0; + line-height: @ai-line-prose; > *:first-child { margin-top: 0; @@ -398,8 +412,10 @@ margin-bottom: 0; } + // Paragraph margin tuned to roughly match a single line of prose + // so paragraphs read as separated but still related. p { - margin: 0 0 8px 0; + margin: 0 0 14px 0; &:last-child { margin-bottom: 0; @@ -414,7 +430,7 @@ code { background-color: rgba(255, 255, 255, 0.08); color: @project-panel-text-1; - padding: 1px 4px; + padding: 1px 5px; border-radius: 3px; font-size: @sidebar-md-code-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; @@ -423,10 +439,10 @@ pre { background-color: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); - padding: 8px 10px; + padding: 10px 12px; border-radius: 4px; overflow-x: auto; - margin: 6px 0; + margin: 10px 0 12px; position: relative; &.collapsed code { @@ -440,24 +456,39 @@ padding: 0; border-radius: 0; font-size: 1em; - line-height: 1.5; + line-height: 1.55; color: @project-panel-text-1; } } + // Lists: visible vertical gap between items so they breathe but + // stay grouped. Block-level margin top/bottom matches a paragraph. ul, ol { - margin: 4px 0 8px 0; - padding-left: 18px; + margin: 8px 0 14px 0; + padding-left: 20px; } li { - margin-bottom: 2px; + margin-bottom: 6px; + // Brackets' default li rule clamps line-height to ~18px which + // makes wrapped bullet text look squished against the prose + // around it. Restore the prose rhythm explicitly. + line-height: @ai-line-prose; + + &:last-child { + margin-bottom: 0; + } + + // Nested lists shouldn't double-space + > ul, > ol { + margin: 6px 0 0 0; + } } blockquote { border-left: 2px solid rgba(255, 255, 255, 0.12); - margin: 6px 0; - padding: 2px 10px; + margin: 10px 0; + padding: 4px 12px; color: @project-panel-text-2; } @@ -484,7 +515,7 @@ overflow-x: auto; border-collapse: collapse; margin: 6px 0; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; cursor: default; } @@ -502,7 +533,7 @@ font-weight: 600; color: @project-panel-text-2; border-bottom-color: rgba(255, 255, 255, 0.1); - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; text-transform: uppercase; letter-spacing: 0.3px; } @@ -516,23 +547,32 @@ } /* ── Tool use indicator ─────────────────────────────────────────────── */ +// Tool indicators are factual breadcrumbs, not the main signal. Keep them +// visually quieter than prose so the eye lands on the assistant's text +// first. Background and border alphas tuned down from earlier values. .ai-msg-tool { - margin-bottom: 3px; + margin-bottom: 6px; border-radius: 4px; - background-color: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.04); + background-color: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.025); border-left: 2px solid var(--tool-color, @project-panel-text-2); + // Add a small breather between consecutive tool indicators so a stack + // of them doesn't read as a single dense block. + & + .ai-msg-tool { + margin-top: 4px; + } + .ai-tool-header { display: flex; align-items: center; gap: 6px; - padding: 4px 8px; + padding: 6px 10px; white-space: normal; } .ai-tool-icon { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; width: 14px; text-align: center; flex-shrink: 0; @@ -550,9 +590,9 @@ } .ai-tool-label { - font-size: @sidebar-small-font-size; + font-size: @ai-text-body; color: @project-panel-text-2; - line-height: 1.3; + line-height: @ai-line-compact; &.ai-tool-label-clickable:hover { color: @project-panel-text-1; @@ -562,7 +602,7 @@ .ai-tool-detail { display: none; - padding: 0 8px 4px 28px; + padding: 2px 10px 6px 28px; } .ai-tool-screenshot { @@ -577,22 +617,22 @@ } .ai-tool-detail-line { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; - opacity: 0.7; - line-height: 1.5; + opacity: 0.78; + line-height: @ai-line-compact; white-space: normal; word-break: break-all; } .ai-tool-preview { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; - opacity: 0.5; - padding: 0 8px 4px 28px; - line-height: 1.4; + opacity: 0.55; + padding: 0 10px 4px 28px; + line-height: @ai-line-compact; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -622,22 +662,25 @@ opacity: 1; } - // Inline edit actions (diff toggle) - .ai-tool-edit-actions { + // Inline edit actions (diff toggle) — sits at the right edge of the + // tool header so it shares a row with the "Edit " label. + .ai-tool-header .ai-tool-edit-actions { display: flex; align-items: center; gap: 6px; - padding: 2px 8px 4px 28px; + margin-left: auto; + padding: 0; } .ai-tool-diff-toggle { background: none; border: none; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; - padding: 1px 4px; + padding: 2px 8px; + line-height: @ai-line-compact; cursor: pointer; - opacity: 0.6; + opacity: 0.65; transition: opacity 0.15s ease; &:hover { @@ -645,12 +688,43 @@ } } + .ai-tool-diff-more { + background: none; + border: none; + color: @project-panel-text-2; + font-size: @ai-text-secondary; + padding: 2px 6px; + line-height: @ai-line-compact; + cursor: pointer; + opacity: 0.55; + transition: opacity 0.15s ease; + position: relative; + + // Invisible hit-area expander so users don't have to land on the + // narrow ellipsis exactly. Doesn't affect layout because it's an + // absolute pseudo-element of the button itself. left: -2px keeps + // it from encroaching on the diff-toggle to its left; the right + // side has empty header space so we expand more there. + &::after { + content: ""; + position: absolute; + top: -6px; + right: -8px; + bottom: -6px; + left: -2px; + } + + &:hover { + opacity: 1; + } + } + .ai-tool-diff { display: none; padding: 4px 8px 4px 28px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; - line-height: 1.5; + line-height: @ai-line-prose; overflow-x: auto; background-color: rgba(0, 0, 0, 0.15); @@ -658,6 +732,25 @@ display: block; } + .ai-diff-row { + white-space: pre; + } + + .ai-diff-num { + display: inline-block; + width: 3em; + text-align: right; + margin-right: 8px; + color: @project-panel-text-2; + opacity: 0.55; + user-select: none; + } + + .ai-diff-ctx { + color: @project-panel-text-2; + opacity: 0.8; + } + .ai-diff-old { color: #e88; background-color: rgba(255, 80, 80, 0.06); @@ -667,16 +760,52 @@ color: #8c8; background-color: rgba(80, 200, 80, 0.06); } + + .ai-diff-sep { + height: 0; + border-top: 1px dashed rgba(255, 255, 255, 0.12); + margin: 4px -8px 4px -28px; + } } .ai-tool-edit-error { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: #e88; padding: 3px 8px 3px 28px; } + &.ai-tool-applied-directly { + opacity: 0.78; + } + + &.ai-tool-rejected { + .ai-tool-direct-note { + color: #e88; + opacity: 0.85; + font-style: normal; + } + } + + .ai-tool-direct-note { + // Match the .ai-tool-detail-line indent so the note sits under the + // icon column the same way the file path does on expanded cards. + font-size: @ai-text-meta; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + color: @project-panel-text-2; + opacity: 0.7; + font-style: italic; + line-height: 1.5; + padding: 0 8px 4px 28px; + word-break: break-all; + + &.ai-tool-direct-note-sub { + opacity: 0.6; + padding-top: 0; + } + } + .ai-tool-elapsed { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.5; margin-left: auto; @@ -692,24 +821,38 @@ display: flex; align-items: flex-start; gap: 6px; - padding: 2px 0; - font-size: @sidebar-small-font-size; + padding: 4px 0; + font-size: @ai-text-body; } .ai-todo-icon { width: 14px; text-align: center; flex-shrink: 0; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; &.completed { color: #66bb6a; } &.in_progress { color: #e8a838; } &.pending { color: @project-panel-text-2; opacity: 0.4; } } +// Older TodoWrite blocks are dimmed and have their in-progress spinner +// swapped for a static arrow when a newer TodoWrite supersedes them. +// Only the latest block keeps live animation; everything above reads +// as historical "this was the next step at the time" rather than +// implying parallel activity. +.ai-msg-tool.ai-todo-stale { + opacity: 0.65; + + .ai-todo-icon { + color: @project-panel-text-2; + opacity: 0.6; + } +} + .ai-todo-content { color: @project-panel-text-2; - line-height: 1.4; + line-height: @ai-line-prose; white-space: normal; overflow-wrap: break-word; min-width: 0; @@ -750,7 +893,7 @@ align-items: center; gap: 3px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; } .ai-color-swatch-preview { @@ -771,16 +914,16 @@ /* ── Edit summary card ──────────────────────────────────────────────── */ .ai-msg-edit-summary { border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 4px; - margin-bottom: 6px; - padding: 6px 10px; + border-radius: 6px; + margin: 8px 0 12px 0; + padding: 10px 12px; background-color: rgba(255, 255, 255, 0.025); .ai-edit-summary-header { display: flex; align-items: center; justify-content: space-between; - font-size: @sidebar-small-font-size; + font-size: @ai-text-body; font-weight: 600; color: @project-panel-text-2; margin-bottom: 4px; @@ -790,9 +933,10 @@ background: none; border: 1px solid rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.9); - font-size: @sidebar-xs-font-size; - padding: 1px 8px; - border-radius: 3px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 12px; + border-radius: 4px; cursor: pointer; flex-shrink: 0; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; @@ -820,7 +964,7 @@ } .ai-edit-summary-name { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; overflow: hidden; @@ -838,7 +982,7 @@ .ai-edit-summary-stats { display: flex; gap: 6px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; flex-shrink: 0; } @@ -861,7 +1005,7 @@ background: rgba(102, 187, 106, 0.1); border: 1px solid rgba(102, 187, 106, 0.25); color: #66bb6a; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 4px 12px; border-radius: 4px; cursor: pointer; @@ -900,9 +1044,10 @@ background: none; border: 1px dashed rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.7); - font-size: @sidebar-xs-font-size; - padding: 2px 14px; - border-radius: 3px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 14px; + border-radius: 4px; cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; @@ -962,7 +1107,7 @@ } .ai-question-text { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-1; line-height: 1.5; margin-bottom: 8px; @@ -994,7 +1139,7 @@ overflow-wrap: anywhere; min-width: 0; - &:hover:not(:disabled) { + &:hover:not(:disabled):not(.selected) { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.18); } @@ -1012,14 +1157,14 @@ } .ai-question-option-label { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; font-weight: 500; white-space: normal; word-wrap: break-word; } .ai-question-option-desc { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; opacity: 0.65; line-height: 1.4; white-space: normal; @@ -1035,7 +1180,7 @@ background: none; border: 1px solid rgba(76, 175, 80, 0.3); color: rgba(76, 175, 80, 0.85); - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 3px 10px; border-radius: 4px; cursor: pointer; @@ -1066,7 +1211,7 @@ border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 4px; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; padding: 6px 10px !important; margin: 0 !important; line-height: 1.4; @@ -1089,7 +1234,7 @@ background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.1); color: @project-panel-text-2; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; padding: 0 10px; border-radius: 4px; cursor: pointer; @@ -1121,7 +1266,7 @@ background-color: rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px 3px 0 0; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; user-select: none; } @@ -1144,7 +1289,7 @@ cursor: pointer; padding: 1px 5px; border-radius: 3px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; line-height: 1; transition: background-color 0.15s ease, color 0.15s ease; @@ -1177,7 +1322,7 @@ border: none; color: @project-panel-text-2; cursor: pointer; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 1px 8px; border-radius: 3px; transition: background-color 0.15s ease, color 0.15s ease; @@ -1205,27 +1350,29 @@ background-color: rgba(107, 158, 255, 0.08); border-bottom: 1px solid rgba(107, 158, 255, 0.15); color: #6b9eff; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; font-weight: 600; } .ai-plan-body { - padding: 10px 12px; - font-size: @sidebar-content-font-size; + padding: 14px 16px; + font-size: @ai-text-body; color: @project-panel-text-1; - line-height: 1.5; + line-height: @ai-line-prose; white-space: normal; word-wrap: break-word; - max-height: 400px; + max-height: 460px; overflow-y: auto; p, ul, ol, pre { - margin-bottom: 8px; + margin-bottom: 14px; &:last-child { margin-bottom: 0; } } + // Heading top margin needs to be larger than paragraph margin so a + // section header reads as a section header rather than another bullet. h1, h2, h3, h4 { color: @project-panel-text-1; line-height: 1.25em; @@ -1233,16 +1380,16 @@ h1 { font-size: @sidebar-md-h1-font-size; - margin: 12px 0 4px 0; + margin: 18px 0 8px 0; padding-bottom: .3em; } h2 { font-size: @sidebar-md-h2-font-size; - margin: 10px 0 4px 0; + margin: 16px 0 8px 0; padding-bottom: .3em; } - h3 { font-size: @sidebar-md-h3-font-size; margin: 8px 0 4px 0; } - h4 { font-size: @sidebar-md-h4-font-size; margin: 6px 0 4px 0; opacity: 0.85; } + h3 { font-size: @sidebar-md-h3-font-size; margin: 14px 0 6px 0; } + h4 { font-size: @sidebar-md-h4-font-size; margin: 12px 0 6px 0; opacity: 0.85; } strong { color: @project-panel-text-1; @@ -1254,15 +1401,15 @@ blockquote { border-left: 3px solid rgba(107, 158, 255, 0.3); - margin: 6px 0; - padding: 2px 10px; + margin: 10px 0; + padding: 4px 12px; color: @project-panel-text-2; } code { background-color: rgba(255, 255, 255, 0.08); color: @project-panel-text-1; - padding: 1px 4px; + padding: 1px 5px; border-radius: 3px; font-size: @sidebar-md-code-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; @@ -1271,7 +1418,7 @@ pre { background-color: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); - padding: 8px 10px; + padding: 10px 12px; border-radius: 4px; overflow-x: auto; position: relative; @@ -1285,18 +1432,29 @@ padding: 0; border-radius: 0; font-size: 1em; - line-height: 1.5; + line-height: 1.55; color: @project-panel-text-1; } } ul, ol { - margin: 4px 0 8px 0; - padding-left: 18px; + margin: 8px 0 14px 0; + padding-left: 22px; } li { - margin-bottom: 2px; + margin-bottom: 6px; + // Same line-height fix as the assistant message — Brackets' + // default li rule clamps line-height too tight otherwise. + line-height: @ai-line-prose; + + &:last-child { + margin-bottom: 0; + } + + > ul, > ol { + margin: 6px 0 0 0; + } } } @@ -1311,8 +1469,9 @@ background: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.35); color: #4caf50; - font-size: @sidebar-content-font-size; - padding: 5px 14px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 12px; border-radius: 4px; cursor: pointer; transition: background-color 0.15s ease; @@ -1336,8 +1495,9 @@ background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: @sidebar-content-font-size; - padding: 5px 14px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 12px; border-radius: 4px; cursor: pointer; transition: background-color 0.15s ease; @@ -1373,7 +1533,7 @@ border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 4px; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; padding: 6px 10px !important; margin: 0 !important; line-height: 1.4; @@ -1432,21 +1592,22 @@ gap: 8px; padding: 8px 12px; font-weight: 600; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: #f39c12; border-bottom: 1px solid rgba(243, 156, 18, 0.15); } .ai-bash-confirm-body { - padding: 8px 12px; + padding: 10px 12px; pre { margin: 0; - padding: 8px; + padding: 8px 10px; border-radius: 4px; background: rgba(0, 0, 0, 0.2); color: #e0e0e0; - font-size: @sidebar-content-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-prose; white-space: pre-wrap; word-break: break-all; } @@ -1463,10 +1624,11 @@ background: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.35); color: #81c784; - padding: 4px 14px; + padding: 4px 12px; border-radius: 4px; cursor: pointer; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; &:hover { background: rgba(76, 175, 80, 0.25); @@ -1477,10 +1639,11 @@ background: rgba(231, 76, 60, 0.15); border: 1px solid rgba(231, 76, 60, 0.35); color: #e57373; - padding: 4px 14px; + padding: 4px 12px; border-radius: 4px; cursor: pointer; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; &:hover { background: rgba(231, 76, 60, 0.25); @@ -1492,7 +1655,7 @@ .ai-bash-result { padding: 4px 10px; border-radius: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; margin: 4px 0; &.ai-bash-result-allowed { @@ -1504,6 +1667,90 @@ } } +/* ── Plan-mode write confirmation card ─────────────────────────────── */ +/* Same structure as .ai-bash-confirm; distinct blue accent so users can + tell at a glance this is a "switch out of Plan Mode?" prompt rather + than a "run command?" prompt. Allow/deny button colours stay + green/red so the user-action affordance is consistent. */ +.ai-plan-write-confirm { + border: 1px solid rgba(79, 163, 227, 0.3); + border-radius: 8px; + background: rgba(79, 163, 227, 0.06); + margin: 8px 0; + overflow: hidden; + + .ai-plan-write-confirm-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-weight: 600; + font-size: @ai-text-body; + color: #4fa3e3; + border-bottom: 1px solid rgba(79, 163, 227, 0.15); + } + + .ai-plan-write-confirm-body { + padding: 10px 12px; + font-size: @ai-text-body; + line-height: @ai-line-prose; + color: @project-panel-text-2; + word-break: break-all; + } + + .ai-plan-write-confirm-actions { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid rgba(79, 163, 227, 0.15); + } + + .ai-plan-write-allow-btn { + background: rgba(76, 175, 80, 0.15); + border: 1px solid rgba(76, 175, 80, 0.35); + color: #81c784; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + + &:hover { + background: rgba(76, 175, 80, 0.25); + } + } + + .ai-plan-write-stay-btn { + background: rgba(231, 76, 60, 0.15); + border: 1px solid rgba(231, 76, 60, 0.35); + color: #e57373; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + + &:hover { + background: rgba(231, 76, 60, 0.25); + } + } +} + +.ai-plan-write-result { + padding: 4px 10px; + border-radius: 4px; + font-size: @ai-text-meta; + margin: 4px 0; + + &.ai-plan-write-result-allowed { + color: #81c784; + } + + &.ai-plan-write-result-stayed { + color: #e57373; + } +} + /* ── Queued clarification bubble (static, above input) ─────────────── */ .ai-queued-msg { border: 1px dashed rgba(255, 255, 255, 0.15); @@ -1520,7 +1767,7 @@ } .ai-queued-label { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; font-weight: 600; opacity: 0.6; @@ -1529,7 +1776,7 @@ .ai-queued-edit-btn { background: none; border: none; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; padding: 0 4px; cursor: pointer; @@ -1561,7 +1808,7 @@ } .ai-queued-text { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-2; line-height: 1.4; white-space: pre-wrap; @@ -1582,7 +1829,7 @@ color: #e88; border-left: 2px solid rgba(255, 80, 80, 0.4); padding: 4px 8px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; background-color: rgba(255, 80, 80, 0.04); border-radius: 0 3px 3px 0; } @@ -1601,7 +1848,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 4px 14px; border-radius: 4px; cursor: pointer; @@ -1620,7 +1867,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; flex-shrink: 0; opacity: 0.6; @@ -1651,7 +1898,7 @@ border-radius: 6px; background-color: rgba(255, 200, 50, 0.10); border: 1px solid rgba(255, 200, 50, 0.20); - font-size: 12px; + font-size: @ai-text-secondary; color: @project-panel-text-1; .ai-quota-content { @@ -1685,7 +1932,7 @@ cursor: pointer; padding: 2px 4px; margin-left: 6px; - font-size: 12px; + font-size: @ai-text-secondary; &:hover { opacity: 1; @@ -1752,7 +1999,7 @@ } .ai-permission-label { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; line-height: 1; } @@ -1760,7 +2007,7 @@ .ai-info { display: none; margin-left: auto; - font-size: 11px; + font-size: @ai-text-meta; line-height: 1; pointer-events: auto; white-space: nowrap; @@ -1772,6 +2019,8 @@ kbd { display: inline-block; padding: 1px 5px; + // Smaller than @ai-text-meta on purpose: kbd glyphs read as + // visual chips, not body text, and need to fit at a glance. font-size: 10px; font-family: inherit; line-height: 1.2; @@ -1812,7 +2061,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; line-height: 1; padding: 3px 6px; border-radius: 3px; @@ -1839,7 +2088,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; line-height: 1; padding: 0 0 0 2px; cursor: pointer; @@ -1895,7 +2144,7 @@ background: rgba(0, 0, 0, 0.6); border: 1px solid rgba(255, 255, 255, 0.3); color: #fff; - font-size: 12px; + font-size: @ai-text-secondary; line-height: 1; padding: 0; display: flex; @@ -1919,7 +2168,7 @@ background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 4px; - font-size: 11px; + font-size: @ai-text-meta; color: @project-panel-text-2; max-width: 150px; position: relative; @@ -1940,7 +2189,7 @@ background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.7); - font-size: 11px; + font-size: @ai-text-meta; padding: 0; display: none; align-items: center; @@ -1981,7 +2230,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -1991,14 +2240,14 @@ background: transparent !important; border: none !important; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; font-family: inherit; - padding: 7px 0 7px 10px; + padding: 10px 0 10px 12px; margin: 0; resize: none; - min-height: 20px; - max-height: 96px; - line-height: 1.4; + min-height: 24px; + max-height: 120px; + line-height: @ai-line-compact; outline: none !important; box-shadow: none !important; @@ -2027,7 +2276,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2057,7 +2306,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2081,7 +2330,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2099,7 +2348,7 @@ padding: 8px 12px; cursor: pointer; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; white-space: nowrap; display: flex; align-items: center; @@ -2145,7 +2394,7 @@ border-radius: 4px; padding: 4px 12px; cursor: pointer; - font-size: 12px; + font-size: @ai-text-secondary; font-family: inherit; } @@ -2196,7 +2445,7 @@ } .ai-unavailable-message { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; line-height: 1.5; margin-bottom: 12px; opacity: 0.6; @@ -2218,7 +2467,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 3px 12px; border-radius: 3px; cursor: pointer; @@ -2235,7 +2484,7 @@ border: none; color: #000; font-weight: 600; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 3px 12px; border-radius: 3px; cursor: pointer; @@ -2266,7 +2515,7 @@ } .ai-install-restart-note { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; opacity: 0.6; margin-top: 8px; @@ -2277,7 +2526,7 @@ /* ── AI Settings Dialog ────────────────────────────────────────────── */ .ai-settings-dialog { .ai-settings-section-label { - font-size: 12px; + font-size: @ai-text-secondary; font-weight: 600; color: @bc-text-medium; margin-bottom: 6px; @@ -2333,7 +2582,7 @@ } .ai-settings-provider-name { - font-size: 13px; + font-size: @ai-text-body; font-weight: 600; color: @bc-text; white-space: nowrap; @@ -2346,7 +2595,7 @@ } .ai-settings-provider-url { - font-size: 11px; + font-size: @ai-text-meta; color: @bc-text-quiet; white-space: nowrap; overflow: hidden; @@ -2385,7 +2634,7 @@ } .ai-settings-edit-error { - font-size: 12px; + font-size: @ai-text-secondary; color: @bc-error; min-height: 0; @@ -2406,7 +2655,7 @@ gap: 4px; label { - font-size: 12px; + font-size: @ai-text-secondary; font-weight: 600; color: @bc-text-medium; @@ -2424,7 +2673,7 @@ border-radius: @bc-border-radius; background: @bc-input-bg; color: @bc-text; - font-size: 13px; + font-size: @ai-text-body; outline: none; &:focus { @@ -2470,7 +2719,8 @@ background: rgba(76, 175, 80, 0.08); border: 1px solid rgba(76, 175, 80, 0.2); border-radius: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; text-align: center; } + diff --git a/test/spec/Document-integ-test.js b/test/spec/Document-integ-test.js index 5e53eb8bea..683547014e 100644 --- a/test/spec/Document-integ-test.js +++ b/test/spec/Document-integ-test.js @@ -117,7 +117,7 @@ define(function (require, exports, module) { DocumentManager.off("dirtyFlagChange", dirtyFlagListener); }); - it("should clear dirty flag AND undo when text reset", async function () { + it("should clear dirty flag but preserve undo history when text reset", async function () { let dirtyFlagListener = jasmine.createSpy(), changeListener = jasmine.createSpy(); DocumentManager.on("dirtyFlagChange", dirtyFlagListener); @@ -137,10 +137,15 @@ define(function (require, exports, module) { expect(dirtyFlagListener.calls.count()).toBe(1); expect(changeListener.calls.count()).toBe(1); - // Reset text (e.g. called by Revert command, or syncing external changes) + // Reset text (e.g. called by Revert command, or syncing external changes). + // Editor._resetText now uses replaceRange instead of setValue+clearHistory + // so the user can ctrl-z back to their pre-revert state. markClean + // still resets the dirty flag relative to the new generation. doc.refreshText("New content", Date.now()); expect(doc.isDirty).toBe(false); - expect(doc._masterEditor._codeMirror.historySize().undo).toBe(0); // undo history GONE + // Undo history is PRESERVED — the refreshText replaceRange adds a + // second entry on top of the original "Foo" edit. + expect(doc._masterEditor._codeMirror.historySize().undo).toBe(2); expect(dirtyFlagListener.calls.count()).toBe(2); expect(changeListener.calls.count()).toBe(2); @@ -165,7 +170,9 @@ define(function (require, exports, module) { doc.refreshText("New content", Date.now()); // e.g. syncing external changes expect(doc.isDirty).toBe(false); - expect(doc._masterEditor._codeMirror.historySize().undo).toBe(0); // still no undo history + // The replaceRange used by Editor._resetText records one undo entry + // so the user can ctrl-z back to the pre-reset content. + expect(doc._masterEditor._codeMirror.historySize().undo).toBe(1); expect(dirtyFlagListener.calls.count()).toBe(0); // isDirty hasn't changed expect(changeListener.calls.count()).toBe(1); // but still counts as a content change diff --git a/tracking-repos.json b/tracking-repos.json index ca92efe992..49eec234e4 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "2ab18a599da4872d5519841b6b222dea7c321ac3" + "commitID": "d175c1fb02062cd8ad27a3f942162383c132c630" } }