diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index cdceb87ed1..3fd48f7f55 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -26,11 +26,11 @@ * edit/write interception, and session management. */ -const { execSync, spawn } = require("child_process"); const fs = require("fs"); const os = require("os"); const path = require("path"); const { createEditorMcpServer } = require("./mcp-editor-tools"); +const CliLocator = require("./cli-locator"); const isWindows = process.platform === "win32"; @@ -602,325 +602,219 @@ async function _getAISessionTitle(sessionId, projectPath) { } /** - * Build ordered candidate paths on Windows, split into two tiers: - * - `native`: real PE binaries dropped by claude.ai/install.ps1 or the - * desktop installer. No node/cli.js shim chain to break, so file - * existence is enough confidence — we skip the `--version` validation. - * - `fallback`: PATH discovery via `where`, npm shim. Broken installs - * are common here (orphan `.cmd` whose cli.js got deleted, extensionless - * POSIX scripts Windows can't execute), so every candidate is verified - * with `claude --version` before we return it. + * Resolve the user's globally installed Claude CLI, honouring the path + * override configured in AI Settings. Kept as a local function so the SDK + * path below reads the same as it always has; the search itself now lives + * in cli-locator.js, shared with the other CLIs the panel can drive. + * Pass `{ force: true }` to invalidate the cache after a spawn failure. + * @return {Promise} absolute path, or null when not found */ -function _winClaudeCandidates() { - const userHome = process.env.USERPROFILE || process.env.HOME || ""; - const native = [ - path.join(userHome, ".local", "bin", "claude.exe"), - path.join(process.env.LOCALAPPDATA || "", "Programs", "claude", "claude.exe") - ]; - const fallback = []; - - // PATH discovery — filter to executable extensions (drop extensionless - // POSIX scripts and .ps1, both of which our spawn path can't use), - // and prefer .exe over .cmd/.bat shims when both resolve. - try { - const allPaths = execSync("where claude", { encoding: "utf8" }) - .trim() - .split("\r\n") - .filter(p => p && !p.includes("node_modules") && /\.(exe|cmd|bat)$/i.test(p)); - const exes = allPaths.filter(p => /\.exe$/i.test(p)); - const others = allPaths.filter(p => !/\.exe$/i.test(p)); - fallback.push(...exes, ...others); - } catch { /* where not on PATH or returned nothing */ } - - // Explicit npm shim in case `where` wasn't reachable. - fallback.push(path.join(process.env.APPDATA || "", "npm", "claude.cmd")); - - return { native, fallback }; +function findGlobalClaudeCli(opts) { + return CliLocator.locateCli("claude", opts).then(function (result) { + return result.path; + }); } -/** - * Build candidate nvm-installed claude paths. The previously hardcoded - * `process.version` was the Node that Phoenix ships, not the Node the user - * has selected in nvm — which mismatched in practice for ~every nvm user. - * - * Strategy: prefer the version named in `~/.nvm/alias/default` (or whatever - * `$NVM_DIR` points at). Fall back to enumerating installed versions, newest - * first, so we still find claude when the default alias is a label like - * `lts/*` or `node` that we don't expand here. - */ -function _nvmClaudeCandidates(home) { - const nvmRoot = process.env.NVM_DIR || path.join(home, ".nvm"); - const versionsDir = path.join(nvmRoot, "versions", "node"); - const candidates = []; - try { - const aliasFile = path.join(nvmRoot, "alias", "default"); - if (fs.existsSync(aliasFile)) { - const alias = fs.readFileSync(aliasFile, "utf8").trim(); - if (/^v?\d/.test(alias)) { - const v = alias.startsWith("v") ? alias : "v" + alias; - candidates.push(path.join(versionsDir, v, "bin", "claude")); - } - } - } catch { /* nvm not installed or unreadable */ } - try { - if (fs.existsSync(versionsDir)) { - const versions = fs.readdirSync(versionsDir) - .filter(v => /^v\d/.test(v)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions) { - candidates.push(path.join(versionsDir, v, "bin", "claude")); - } - } - } catch { /* ignore */ } - return candidates; -} +// Brand names for the messages below. Not translatable and never shown +// raw — the browser maps errorCode to a localized string; these only reach +// logs and metrics. +const CLI_DISPLAY_NAMES = { claude: "Claude Code CLI", codex: "Codex CLI" }; /** - * Build ordered candidate paths on macOS/Linux. See _winClaudeCandidates for - * the native/fallback rationale. + * Human-readable summary of why a CLI could not be resolved. The browser + * localizes from `errorCode`; this string is for logs, metrics, and the + * existing `_renderUnavailableUI(result.error)` path. */ -function _posixClaudeCandidates() { - const home = process.env.HOME || ""; - const native = [ - path.join(home, ".local", "bin", "claude") // claude.ai/install.sh default - ]; - const fallback = []; - - // PATH discovery. Matters most on macOS when Phoenix is launched from - // Finder/Dock — that PATH is the minimal `/usr/bin:/bin:/usr/sbin:/sbin`, - // so `which` may miss user-managed dirs and the known locations below - // are what saves us. - try { - const allPaths = execSync("which -a claude 2>/dev/null || which claude", { encoding: "utf8" }) - .trim() - .split("\n") - .filter(p => p && !p.includes("node_modules")); - fallback.push(...allPaths); - } catch { /* which not available */ } - - fallback.push( - "/usr/local/bin/claude", // System-wide / Intel Mac Homebrew - "/usr/bin/claude", // Distro package - ..._nvmClaudeCandidates(home), // npm global via nvm - "/opt/homebrew/bin/claude", // Homebrew on Apple Silicon - "/home/linuxbrew/.linuxbrew/bin/claude" // Linuxbrew - ); - - return { native, fallback }; +function _cliErrorMessage(cliId, located) { + const name = CLI_DISPLAY_NAMES[cliId] || cliId; + switch (located.errorCode) { + case CliLocator.ERROR_CODES.OVERRIDE_MISSING: + return "Configured " + name + " path not found: " + (located.override && located.override.path); + case CliLocator.ERROR_CODES.OVERRIDE_NOT_EXECUTABLE: + return "Configured " + name + " path is not executable: " + (located.override && located.override.path); + case CliLocator.ERROR_CODES.OVERRIDE_INVALID: + return "Configured " + name + " path is not a working " + name; + case CliLocator.ERROR_CODES.OVERRIDE_TIMEOUT: + return "Configured " + name + " path did not respond in time"; + case CliLocator.ERROR_CODES.OVERRIDE_REJECTED: + return "Configured " + name + " path contains unsupported characters"; + default: + return name + " not found"; + } } /** - * Existence + executability check. On Windows executability is derived from - * extension/PATHEXT not a file attribute, so existsSync is the right test; - * on posix we want the +x bit. + * Ask claude whether the user is signed in. Only claude has a + * machine-readable answer (`claude auth status` prints JSON); codex's login + * lives behind a browser/TTY flow, so its terminal shows that itself. + * @return {Promise<{loggedIn: boolean, claudePath: string|null}>} claudePath + * is re-resolved when the cached binary turns out to be gone */ -function _canAccess(p) { - if (!p) { return false; } +async function _probeClaudeLogin(claudePath) { + let loggedIn = false; + let result; try { - if (isWindows) { - return fs.existsSync(p); + result = await CliLocator.spawnCli(claudePath, ["auth", "status"], { + encoding: "utf8", + timeout: 10000 + }); + // Spawn-level failure (ENOENT/EACCES — e.g. user uninstalled + // mid-session) means the cached binary is unusable. Invalidate + // and re-discover once. Distinct from "binary ran but exited + // non-zero", which we still treat as "not logged in". + if (result.error && result.status === null) { + const relocated = await CliLocator.locateCli("claude", { force: true }); + if (!relocated.path) { + return { loggedIn: false, claudePath: null }; + } + claudePath = relocated.path; + result = await CliLocator.spawnCli(claudePath, ["auth", "status"], { + encoding: "utf8", + timeout: 10000 + }); } - fs.accessSync(p, fs.constants.X_OK); - return true; - } catch { - return false; + if (result.status === 0 && result.stdout) { + const authStatus = JSON.parse(result.stdout); + loggedIn = authStatus.loggedIn === true; + } + } catch (e) { + // auth status failed — treat as not logged in } + return { loggedIn: loggedIn, claudePath: claudePath }; } /** - * Spawn claude with argv and resolve to { stdout, stderr, status, error }. - * Async so callers don't block the event loop while claude runs — `auth - * status` can take up to 10 s, `--version` up to 3 s, and the integrated - * terminal and file watchers share this Node process. - * - * For .exe/posix binaries: shell-less spawn, paths-with-spaces and special - * chars pass through verbatim. For Windows .cmd/.bat shims: shell:true - * (Node refuses to spawn batch files without it per CVE-2024-27980 - * hardening) plus manual command-name quoting (Node intentionally does NOT - * escape the command name under shell:true). + * Whether one of the CLIs the AI panel can drive is installed and usable. + * Called from browser via execPeer("checkCliAvailability", {cli}). * - * Mimics the spawnSync result shape so callers read .status/.error/.stdout - * unchanged. `opts.timeout` (ms) kills the process with SIGKILL on expiry - * and surfaces an Error with message "timeout". + * @param {Object} opts - `{cli}` "claude" (default) or "codex"; + * `{refresh}` bypasses the cached miss — the install/login poll loops + * pass it because they are explicitly waiting on state to change; + * `{overridePath}` resolves against that path for this call only, so + * the settings UI can test a path without committing it; + * `{probeLogin}` defaults to true for claude, false for codex. + * @return {Promise} `{cli, available, path, source, version, loggedIn, + * loginProbeSupported, error, errorCode, override, searchedPaths}` */ -function _spawnClaude(claudePath, args, opts) { - return new Promise(function (resolve) { - const isCmdShim = isWindows && /\.(cmd|bat)$/i.test(claudePath); - const spawnCmd = isCmdShim ? `"${claudePath}"` : claudePath; - const spawnOpts = isCmdShim ? Object.assign({ shell: true }, opts) : opts; - const encoding = (opts && opts.encoding) || "utf8"; - const timeoutMs = (opts && opts.timeout) || 0; - let child; - try { - child = spawn(spawnCmd, args, spawnOpts); - } catch (err) { - resolve({ stdout: "", stderr: "", status: null, error: err }); - return; +exports.checkCliAvailability = async function (opts) { + const cliId = (opts && opts.cli) || "claude"; + const canProbeLogin = cliId === "claude"; + const probeLogin = (opts && opts.probeLogin !== undefined) ? !!opts.probeLogin : canProbeLogin; + try { + const locateOpts = {}; + if (opts && opts.refresh) { + locateOpts.force = true; } - let stdout = ""; - let stderr = ""; - let settled = false; - let timer = null; - function finish(result) { - if (settled) { return; } - settled = true; - if (timer) { clearTimeout(timer); } - resolve(result); + if (opts && opts.overridePath !== undefined) { + locateOpts.override = opts.overridePath; } - if (child.stdout) { - child.stdout.setEncoding(encoding); - child.stdout.on("data", function (chunk) { stdout += chunk; }); + const located = await CliLocator.locateCli(cliId, locateOpts); + const base = { + cli: cliId, + loginProbeSupported: canProbeLogin, + source: located.source, + version: located.version, + override: located.override, + searchedPaths: located.searchedPaths + }; + if (!located.path) { + return Object.assign(base, { + available: false, + path: null, + error: _cliErrorMessage(cliId, located), + errorCode: located.errorCode + }); } - if (child.stderr) { - child.stderr.setEncoding(encoding); - child.stderr.on("data", function (chunk) { stderr += chunk; }); + let cliPath = located.path; + let loggedIn; + if (probeLogin && canProbeLogin) { + const login = await _probeClaudeLogin(cliPath); + if (!login.claudePath) { + return Object.assign(base, { + available: false, + path: null, + error: _cliErrorMessage(cliId, { errorCode: CliLocator.ERROR_CODES.NOT_FOUND }), + errorCode: CliLocator.ERROR_CODES.NOT_FOUND + }); + } + cliPath = login.claudePath; + loggedIn = login.loggedIn; } - child.on("error", function (err) { - finish({ stdout, stderr, status: null, error: err }); + return Object.assign(base, { + available: true, + path: cliPath, + loggedIn: loggedIn, + error: null, + errorCode: null }); - child.on("close", function (code) { - finish({ stdout, stderr, status: code, error: null }); - }); - if (timeoutMs > 0) { - timer = setTimeout(function () { - try { child.kill("SIGKILL"); } catch { /* already exited */ } - finish({ stdout, stderr, status: null, error: new Error("timeout") }); - }, timeoutMs); - } - }); -} + } catch (err) { + return { + cli: cliId, + available: false, + path: null, + loginProbeSupported: canProbeLogin, + error: err.message, + errorCode: CliLocator.ERROR_CODES.NOT_FOUND + }; + } +}; /** - * Validate that a fallback candidate actually runs. Catches broken installs - * the existence check misses — e.g. an npm `.cmd` shim whose referenced - * cli.js was deleted by a half-completed uninstall. `claude --version` is - * fast (~200 ms healthy) and outputs a version string starting with a digit. + * Check whether Claude CLI is available. + * Called from browser via execPeer("checkAvailability"). + * + * Kept as its own peer on top of checkCliAvailability: several browser call + * sites read `claudePath` and the login state, and that legacy key belongs + * on a claude-shaped result rather than becoming a lie on a codex one. */ -async function _validateClaudeBinary(claudePath) { - try { - const result = await _spawnClaude(claudePath, ["--version"], { - encoding: "utf8", - timeout: 3000 - }); - return !result.error && result.status === 0 && /^\d/.test((result.stdout || "").trim()); - } catch { - return false; - } -} +exports.checkAvailability = async function (opts) { + const result = await exports.checkCliAvailability( + Object.assign({}, opts, { cli: "claude", probeLogin: true })); + result.claudePath = result.path; + return result; +}; -// undefined = not yet probed; null = probed, nothing works; string = resolved path -let _cachedClaudePath; -let _cachedAt = 0; -// In-flight discovery promise so concurrent callers share one walk of the -// fallback chain instead of each spawning their own --version probes. -let _inFlightDiscovery = null; -// Negative results expire so a fresh `claude` install completes during a -// session can be detected on the next checkAvailability (the install-poll -// flow depends on this). Positive results are cached indefinitely — the -// self-heal in checkAvailability handles the mid-session-uninstall case -// by passing { force: true } when a cached path stops spawning. -const NULL_CACHE_TTL_MS = 15000; - -function _setCache(p) { - _cachedClaudePath = p; - _cachedAt = Date.now(); - return p; -} +/** + * Record the CLI executable paths the user configured in AI Settings. + * Called from browser via execPeer("setCliPathOverrides", {claude, codex}). + * An empty string clears an override and restores auto-detection. + */ +exports.setCliPathOverrides = async function (params) { + const applied = CliLocator.setOverrides(params || {}); + console.log("[Phoenix AI] CLI path overrides:", JSON.stringify(applied)); + return { applied: applied }; +}; /** - * Resolve the user's globally installed Claude CLI. Walks a fallback chain: - * native candidates first (existence is enough), then PATH/known-location - * candidates, each validated by spawning `--version` so broken shims get - * skipped instead of returned. Pass `{ force: true }` to invalidate the - * cache after a runtime spawn failure. + * Test one CLI path without disturbing the cache — for the settings UI, so + * it never has to reimplement what counts as a working CLI. + * Called from browser via execPeer("validateCliPath", {cli, path}). */ -function findGlobalClaudeCli(opts) { - const force = !!(opts && opts.force); - if (!force && _cachedClaudePath !== undefined) { - const fresh = _cachedClaudePath !== null - || (Date.now() - _cachedAt) < NULL_CACHE_TTL_MS; - if (fresh) { - return Promise.resolve(_cachedClaudePath); - } - } - if (!force && _inFlightDiscovery) { - return _inFlightDiscovery; - } - const discovery = (async function () { - const { native, fallback } = isWindows ? _winClaudeCandidates() : _posixClaudeCandidates(); - for (const p of native) { - if (_canAccess(p)) { - console.log("[Phoenix AI] Found native Claude CLI at:", p); - return _setCache(p); - } - } - for (const p of fallback) { - if (_canAccess(p) && await _validateClaudeBinary(p)) { - console.log("[Phoenix AI] Validated Claude CLI at:", p); - return _setCache(p); - } - } - console.log("[Phoenix AI] Global Claude CLI not found"); - return _setCache(null); - })(); - if (!force) { - _inFlightDiscovery = discovery; - discovery.finally(function () { - if (_inFlightDiscovery === discovery) { - _inFlightDiscovery = null; - } - }); - } - return discovery; -} +exports.validateCliPath = async function (params) { + const cliId = (params && params.cli) || "claude"; + return CliLocator.validateCliPath(cliId, (params && params.path) || ""); +}; /** - * Check whether Claude CLI is available. - * Called from browser via execPeer("checkAvailability"). + * How to spawn a CLI in a PTY: availability plus the command/args the + * terminal should use. Callers must not spawn `path` directly — on Windows + * an npm-installed CLI resolves to a `.cmd` shim, which node-pty cannot + * execute (CreateProcess runs .exe/.com only), so it has to go through + * `cmd.exe /c`. + * Called from browser via execPeer("getCliSpawnProfile", {cli}). */ -exports.checkAvailability = async function (opts) { - try { - // Poll loops (install/login screens) pass { refresh: true } because - // they're explicitly waiting on state changes — the cached null - // would otherwise make detection lag by up to NULL_CACHE_TTL_MS. - const refresh = !!(opts && opts.refresh); - let claudePath = await findGlobalClaudeCli(refresh ? { force: true } : undefined); - if (!claudePath) { - return { available: false, claudePath: null, error: "Claude Code CLI not found" }; - } - // Check if user is logged in - let loggedIn = false; - let result; - try { - result = await _spawnClaude(claudePath, ["auth", "status"], { - encoding: "utf8", - timeout: 10000 - }); - // Spawn-level failure (ENOENT/EACCES — e.g. user uninstalled - // mid-session) means the cached binary is unusable. Invalidate - // and re-discover once. Distinct from "binary ran but exited - // non-zero", which we still treat as "not logged in". - if (result.error && result.status === null) { - claudePath = await findGlobalClaudeCli({ force: true }); - if (!claudePath) { - return { available: false, claudePath: null, error: "Claude Code CLI not found" }; - } - result = await _spawnClaude(claudePath, ["auth", "status"], { - encoding: "utf8", - timeout: 10000 - }); - } - if (result.status === 0 && result.stdout) { - const authStatus = JSON.parse(result.stdout); - loggedIn = authStatus.loggedIn === true; - } - } catch (e) { - // auth status failed — treat as not logged in - } - return { available: true, claudePath: claudePath, loggedIn: loggedIn }; - } catch (err) { - return { available: false, claudePath: null, error: err.message }; +exports.getCliSpawnProfile = async function (params) { + const cliId = (params && params.cli) || "claude"; + const result = await exports.checkCliAvailability({ + cli: cliId, + probeLogin: false, + overridePath: params && params.overridePath + }); + if (!result.available) { + return Object.assign({}, result, { command: null, args: [] }); } + const profile = CliLocator.getSpawnProfile(result.path); + return Object.assign({}, result, { command: profile.command, args: profile.args }); }; /** diff --git a/src-node/cli-locator.js b/src-node/cli-locator.js new file mode 100644 index 0000000000..8dd6c94e0f --- /dev/null +++ b/src-node/cli-locator.js @@ -0,0 +1,633 @@ +/* + * 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. + * + */ + +/** + * Locates the coding-agent CLIs Phoenix drives (`claude`, `codex`) on the + * user's machine. + * + * Every CLI is described by one entry in CLI_REGISTRY — binary name, the + * per-platform places its installers drop it, and how to recognise its + * `--version` output. Adding a third CLI is a registry entry, not new code. + * + * Resolution order for each CLI is: the user's configured override path (if + * any) → "native" candidates, whose installers are known to drop a real + * executable so existence alone is trusted → "fallback" candidates from PATH + * and known locations, each proved by actually running `--version`. + */ + +const { execSync, spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const isWindows = process.platform === "win32"; + +// PATH lookups (`where`/`which`) run synchronously and block this whole Node +// process — the integrated terminal, file watchers and MCP all share it. An +// unreachable network drive on PATH can wedge them, so cap the wait. +const PATH_LOOKUP_TIMEOUT_MS = 3000; + +// How long a `--version` probe may take before we give up on a candidate. +const VERSION_PROBE_TIMEOUT_MS = 3000; + +// Negative results expire so a fresh install completed during a session is +// detected on the next lookup (the install-poll flow depends on this). +// Positive results are cached indefinitely — the self-heal in +// checkAvailability handles the mid-session-uninstall case by forcing. +const NULL_CACHE_TTL_MS = 15000; + +// Characters that must never reach a user-supplied override path. Windows +// `.cmd`/`.bat` shims are spawned with shell:true (see spawnCli), where the +// command name is quoted but an embedded quote would break out of it. +const UNSAFE_OVERRIDE_CHARS = /["<>|\r\n]/; + +/** + * Why a lookup failed. The browser turns these into different advice, so + * they are part of the peer contract — do not collapse them. + */ +const ERROR_CODES = { + NOT_FOUND: "NOT_FOUND", // nothing on the chain worked + OVERRIDE_MISSING: "OVERRIDE_MISSING", // configured path does not exist + OVERRIDE_NOT_EXECUTABLE: "OVERRIDE_NOT_EXECUTABLE", // exists but has no +x bit + OVERRIDE_INVALID: "OVERRIDE_INVALID", // runs, but is not this CLI + OVERRIDE_TIMEOUT: "OVERRIDE_TIMEOUT", // probe timed out — retry, don't repath + OVERRIDE_REJECTED: "OVERRIDE_REJECTED" // unsafe characters in the path +}; + +/** + * The CLIs we know how to find. + * + * `versionPattern` recognises that CLI's own `--version` output. It cannot + * be one shared rule: `claude --version` prints "2.1.263 (Claude Code)" but + * `codex --version` prints "codex-cli 0.153.4", which does not start with a + * digit. Loosening the check to "any output" instead would make any exit-0 + * binary that happens to be named `codex` on PATH a match — and `codex` is a + * short, generic name. + */ +const CLI_REGISTRY = { + claude: { + id: "claude", + bin: "claude", + versionArgs: ["--version"], + versionPattern: /^\d/, + // claude.ai/install.sh and the desktop installer drop real binaries + // here — no node/cli.js shim chain to break, so existence is enough. + winNative: function () { + const userHome = process.env.USERPROFILE || process.env.HOME || ""; + return [ + path.join(userHome, ".local", "bin", "claude.exe"), + path.join(process.env.LOCALAPPDATA || "", "Programs", "claude", "claude.exe") + ]; + }, + winExtra: function () { + return [path.join(process.env.APPDATA || "", "npm", "claude.cmd")]; + }, + posixNative: function (home) { + return [path.join(home, ".local", "bin", "claude")]; // claude.ai/install.sh default + }, + posixExtra: function (home) { + return [ + "/usr/local/bin/claude", // System-wide / Intel Mac Homebrew + "/usr/bin/claude", // Distro package + ..._nvmCandidates(home, "claude"), // npm global via nvm + "/opt/homebrew/bin/claude", // Homebrew on Apple Silicon + "/home/linuxbrew/.linuxbrew/bin/claude" // Linuxbrew + ]; + } + }, + codex: { + id: "codex", + bin: "codex", + versionArgs: ["--version"], + versionPattern: /^codex(-cli)?\s+v?\d/i, + // Deliberately asymmetric with claude: only the standalone + // installer's location is trusted without proof. Codex's other + // Windows locations are educated guesses, and a native-tier entry + // returns an unvalidated path straight to pty.spawn. + winNative: function () { + const userHome = process.env.USERPROFILE || process.env.HOME || ""; + return [path.join(userHome, ".local", "bin", "codex.exe")]; + }, + winExtra: function () { + const userHome = process.env.USERPROFILE || process.env.HOME || ""; + return [ + path.join(process.env.APPDATA || "", "npm", "codex.cmd"), + path.join(process.env.LOCALAPPDATA || "", "Programs", "codex", "codex.exe"), + path.join(userHome, ".codex", "bin", "codex.exe") + ]; + }, + posixNative: function (home) { + return [ + path.join(home, ".local", "bin", "codex"), // chatgpt.com/codex/install.sh + // What that installer's symlink points at. Listed too so a + // shell alias shadowing ~/.local/bin still resolves. + path.join(home, ".codex", "packages", "standalone", "current", "bin", "codex") + ]; + }, + posixExtra: function (home) { + return [ + "/usr/local/bin/codex", + "/usr/bin/codex", + ..._nvmCandidates(home, "codex"), + "/opt/homebrew/bin/codex", // brew install --cask codex + "/home/linuxbrew/.linuxbrew/bin/codex" + ]; + } + } +}; + +const CLI_IDS = Object.keys(CLI_REGISTRY); + +// cliId -> { path, at, overrideSig, source, version, errorCode, override, searched } +const _cache = new Map(); +// cliId -> in-flight discovery promise, so concurrent callers share one walk +// of the fallback chain instead of each spawning their own --version probes. +const _inFlight = new Map(); +// cliId -> user-configured override path ("" when unset) +const _overrides = new Map(); + +/** + * Build candidate nvm-installed paths. The obvious `process.version` is the + * Node that Phoenix ships, not the Node the user selected in nvm — which + * mismatched in practice for ~every nvm user. + * + * Strategy: prefer the version named in `~/.nvm/alias/default` (or whatever + * `$NVM_DIR` points at). Fall back to enumerating installed versions, newest + * first, so we still find the CLI when the default alias is a label like + * `lts/*` or `node` that we don't expand here. + */ +function _nvmCandidates(home, bin) { + const nvmRoot = process.env.NVM_DIR || path.join(home, ".nvm"); + const versionsDir = path.join(nvmRoot, "versions", "node"); + const candidates = []; + try { + const aliasFile = path.join(nvmRoot, "alias", "default"); + if (fs.existsSync(aliasFile)) { + const alias = fs.readFileSync(aliasFile, "utf8").trim(); + if (/^v?\d/.test(alias)) { + const v = alias.startsWith("v") ? alias : "v" + alias; + candidates.push(path.join(versionsDir, v, "bin", bin)); + } + } + } catch { /* nvm not installed or unreadable */ } + try { + if (fs.existsSync(versionsDir)) { + const versions = fs.readdirSync(versionsDir) + .filter(v => /^v\d/.test(v)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions) { + candidates.push(path.join(versionsDir, v, "bin", bin)); + } + } + } catch { /* ignore */ } + return candidates; +} + +/** + * Drop duplicates, preserving order. Case-insensitive on Windows, where the + * same file reaches us as both `C:\Users\...` and `c:\users\...` from + * different sources and would otherwise be probed twice. + */ +function _dedupe(paths) { + const seen = new Set(); + const out = []; + for (const p of paths) { + if (!p) { continue; } + const key = isWindows ? p.toLowerCase() : p; + if (seen.has(key)) { continue; } + seen.add(key); + out.push(p); + } + return out; +} + +/** + * Ask the OS where `bin` lives. Returns [] when the lookup tool is missing, + * finds nothing, or takes too long. + */ +function _pathLookup(bin) { + try { + const cmd = isWindows + ? "where " + bin + : "which -a " + bin + " 2>/dev/null || which " + bin; + const out = execSync(cmd, { + encoding: "utf8", + timeout: PATH_LOOKUP_TIMEOUT_MS, + windowsHide: true + }).trim(); + let paths = out.split(isWindows ? "\r\n" : "\n") + .map(p => p.trim()) + .filter(p => p && !p.includes("node_modules")); + if (isWindows) { + // Filter to executable extensions — extensionless POSIX scripts + // and .ps1 both come back from `where` and neither can be run by + // our spawn path — and prefer .exe over .cmd/.bat shims. + paths = paths.filter(p => /\.(exe|cmd|bat)$/i.test(p)); + const exes = paths.filter(p => /\.exe$/i.test(p)); + const others = paths.filter(p => !/\.exe$/i.test(p)); + paths = [...exes, ...others]; + } + return paths; + } catch { + return []; + } +} + +/** + * Ordered candidate paths for a CLI, split into two tiers: + * - `native`: installers known to drop a real executable. No shim chain to + * break, so file existence is enough confidence — we skip `--version`. + * - `fallback`: PATH discovery and known locations. Broken installs are + * common here (an orphan `.cmd` whose cli.js got deleted), so every + * candidate is proved by running `--version` before we return it. + * @param {Object} cli - a CLI_REGISTRY entry + * @return {{native: Array, fallback: Array}} + */ +function _candidates(cli) { + const home = (isWindows ? process.env.USERPROFILE : process.env.HOME) || process.env.HOME || ""; + const native = isWindows ? cli.winNative() : cli.posixNative(home); + const extra = isWindows ? cli.winExtra() : cli.posixExtra(home); + const nativePaths = _dedupe(native); + // Dedupe the fallback tier against native too: `which` reports the same + // file the native tier already listed, and a user reading searchedPaths + // should not see it twice. + const seenNative = new Set(nativePaths.map(p => (isWindows ? p.toLowerCase() : p))); + const fallback = _dedupe([..._pathLookup(cli.bin), ...extra]) + .filter(p => !seenNative.has(isWindows ? p.toLowerCase() : p)); + return { native: nativePaths, fallback: fallback }; +} + +/** + * Existence + executability check. On Windows executability is derived from + * extension/PATHEXT not a file attribute, so existsSync is the right test; + * on posix we want the +x bit. + */ +function canAccess(p) { + if (!p) { return false; } + try { + if (isWindows) { + return fs.existsSync(p); + } + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Spawn a CLI with argv and resolve to { stdout, stderr, status, error }. + * Async so callers don't block the event loop while it runs — `claude auth + * status` can take up to 10 s, `--version` up to 3 s, and the integrated + * terminal and file watchers share this Node process. + * + * For .exe/posix binaries: shell-less spawn, paths-with-spaces and special + * chars pass through verbatim. For Windows .cmd/.bat shims: shell:true + * (Node refuses to spawn batch files without it per CVE-2024-27980 + * hardening) plus manual command-name quoting (Node intentionally does NOT + * escape the command name under shell:true). + * + * Mimics the spawnSync result shape so callers read .status/.error/.stdout + * unchanged. `opts.timeout` (ms) kills the process with SIGKILL on expiry + * and surfaces an Error with message "timeout". + */ +function spawnCli(cliPath, args, opts) { + return new Promise(function (resolve) { + const isCmdShim = isWindows && /\.(cmd|bat)$/i.test(cliPath); + const spawnCmd = isCmdShim ? `"${cliPath}"` : cliPath; + const spawnOpts = isCmdShim ? Object.assign({ shell: true }, opts) : opts; + const encoding = (opts && opts.encoding) || "utf8"; + const timeoutMs = (opts && opts.timeout) || 0; + let child; + try { + child = spawn(spawnCmd, args, spawnOpts); + } catch (err) { + resolve({ stdout: "", stderr: "", status: null, error: err }); + return; + } + let stdout = ""; + let stderr = ""; + let settled = false; + let timer = null; + function finish(result) { + if (settled) { return; } + settled = true; + if (timer) { clearTimeout(timer); } + resolve(result); + } + if (child.stdout) { + child.stdout.setEncoding(encoding); + child.stdout.on("data", function (chunk) { stdout += chunk; }); + } + if (child.stderr) { + child.stderr.setEncoding(encoding); + child.stderr.on("data", function (chunk) { stderr += chunk; }); + } + child.on("error", function (err) { + finish({ stdout, stderr, status: null, error: err }); + }); + child.on("close", function (code) { + finish({ stdout, stderr, status: code, error: null }); + }); + if (timeoutMs > 0) { + timer = setTimeout(function () { + try { child.kill("SIGKILL"); } catch { /* already exited */ } + finish({ stdout, stderr, status: null, error: new Error("timeout") }); + }, timeoutMs); + } + }); +} + +/** + * Whether `--version` output belongs to this CLI. The registry pattern is + * the primary rule; the generic floor below it ("names itself, then a + * version number") keeps a future output change like `codex 1.0.0` working + * without letting an unrelated binary through. + */ +function _versionOutputValid(cli, stdout) { + const out = (stdout || "").trim(); + if (!out) { return false; } + if (cli.versionPattern.test(out)) { return true; } + return out.toLowerCase().startsWith(cli.bin.toLowerCase()) && /\bv?\d+\.\d+/.test(out); +} + +/** + * Run a candidate's `--version` and report what happened. Catches broken + * installs the existence check misses — e.g. an npm `.cmd` shim whose + * referenced cli.js was deleted by a half-completed uninstall — and, for an + * override, tells apart "does not run" from "runs, but is a different tool". + * @return {Promise<{ok: boolean, version: ?string, errorCode: ?string, stderr: string}>} + */ +async function _probeVersion(cli, cliPath) { + let result; + try { + result = await spawnCli(cliPath, cli.versionArgs, { + encoding: "utf8", + timeout: VERSION_PROBE_TIMEOUT_MS + }); + } catch (err) { + return { ok: false, version: null, errorCode: ERROR_CODES.OVERRIDE_INVALID, stderr: err.message }; + } + if (result.error && /timeout/i.test(result.error.message || "")) { + return { ok: false, version: null, errorCode: ERROR_CODES.OVERRIDE_TIMEOUT, stderr: "" }; + } + const version = (result.stdout || "").trim(); + if (result.error || result.status !== 0 || !_versionOutputValid(cli, version)) { + return { + ok: false, + version: version || null, + errorCode: ERROR_CODES.OVERRIDE_INVALID, + stderr: (result.stderr || "").slice(0, 200) + }; + } + return { ok: true, version, errorCode: null, stderr: "" }; +} + +/** + * Resolve and validate a user-configured override path. + * + * Unlike a discovered candidate, an override is ALWAYS proved by running + * `--version` — even on Windows, even for a .exe. The whole point of the + * setting is to tell the user when their path has gone stale. + * @return {Promise} an override report; `.path` is set only on success + */ +async function _resolveOverride(cli, override) { + const raw = (override || "").trim(); + if (!raw) { return null; } + if (UNSAFE_OVERRIDE_CHARS.test(raw)) { + return { used: true, valid: false, path: raw, errorCode: ERROR_CODES.OVERRIDE_REJECTED }; + } + // A bare name with no separator means "find this on PATH" — the same + // affordance the Git extension's gitPath setting allows. + let resolved = raw; + if (!raw.includes("/") && !raw.includes("\\")) { + const found = _pathLookup(raw)[0]; + if (!found) { + return { used: true, valid: false, path: raw, errorCode: ERROR_CODES.OVERRIDE_MISSING, onPath: true }; + } + resolved = found; + } + if (!canAccess(resolved)) { + // Split "no such file" from "there but not executable": the latter is + // the common chmod mistake and deserves its own advice. + const code = (!isWindows && fs.existsSync(resolved)) + ? ERROR_CODES.OVERRIDE_NOT_EXECUTABLE + : ERROR_CODES.OVERRIDE_MISSING; + return { used: true, valid: false, path: resolved, errorCode: code }; + } + const probe = await _probeVersion(cli, resolved); + if (!probe.ok) { + return { + used: true, valid: false, path: resolved, + errorCode: probe.errorCode, version: probe.version, stderr: probe.stderr + }; + } + return { used: true, valid: true, path: resolved, version: probe.version }; +} + +/** The override currently configured for a CLI, or "". */ +function getOverride(cliId) { + return _overrides.get(cliId) || ""; +} + +/** + * Record the user's configured override paths. Cache entries carry the + * override they were built from, so changing one invalidates its entry on + * the next lookup without any explicit cache-clearing call — which cannot + * desync the way a separate clear step could. + * @param {Object} paths - { claude?: string, codex?: string } + * @return {Object} the applied overrides, by cli id + */ +function setOverrides(paths) { + const applied = {}; + for (const cliId of CLI_IDS) { + if (paths && Object.prototype.hasOwnProperty.call(paths, cliId)) { + _overrides.set(cliId, (paths[cliId] || "").trim()); + } + applied[cliId] = getOverride(cliId); + } + return applied; +} + +function _cacheHit(cliId, overrideSig) { + const entry = _cache.get(cliId); + if (!entry || entry.overrideSig !== overrideSig) { + return null; + } + const fresh = entry.path !== null || (Date.now() - entry.at) < NULL_CACHE_TTL_MS; + return fresh ? entry : null; +} + +function _toResult(cliId, entry) { + return { + cli: cliId, + path: entry.path, + source: entry.source || null, + version: entry.version || null, + errorCode: entry.errorCode || null, + override: entry.override || null, + searchedPaths: entry.searched || [] + }; +} + +/** + * Find a CLI's executable. + * + * @param {string} cliId - "claude" | "codex" + * @param {Object} [opts] - `{force}` bypasses the cache (and the in-flight + * share) after a runtime spawn failure; `{override}` uses that path for + * this call only instead of the stored one, so the settings UI can + * preview a path without committing it. + * @return {Promise} `{cli, path, source, version, errorCode, override, searchedPaths}` + */ +function locateCli(cliId, opts) { + const cli = CLI_REGISTRY[cliId]; + if (!cli) { + return Promise.reject(new Error("Unknown CLI: " + cliId)); + } + const force = !!(opts && opts.force); + const override = (opts && opts.override !== undefined) ? opts.override : getOverride(cliId); + const overrideSig = (override || "").trim(); + + if (!force) { + const hit = _cacheHit(cliId, overrideSig); + if (hit) { + return Promise.resolve(_toResult(cliId, hit)); + } + const pending = _inFlight.get(cliId); + if (pending) { + return pending; + } + } + + const discovery = (async function () { + const entry = { path: null, at: Date.now(), overrideSig, searched: [] }; + + if (overrideSig) { + const report = await _resolveOverride(cli, overrideSig); + entry.override = report; + if (report && report.valid) { + entry.path = report.path; + entry.source = "override"; + entry.version = report.version; + console.log("[Phoenix AI] Using configured " + cli.bin + " path:", report.path); + } else { + // Deliberately NOT falling through to auto-discovery: quietly + // running a different binary than the one the user configured + // is the worst kind of bug report. Clearing the setting is how + // you get discovery back. + entry.errorCode = report ? report.errorCode : ERROR_CODES.NOT_FOUND; + console.log("[Phoenix AI] Configured " + cli.bin + " path unusable:", entry.errorCode); + } + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + } + + const { native, fallback } = _candidates(cli); + entry.searched = [...native, ...fallback]; + for (const p of native) { + if (canAccess(p)) { + console.log("[Phoenix AI] Found native " + cli.bin + " CLI at:", p); + entry.path = p; + entry.source = "native"; + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + } + } + for (const p of fallback) { + if (!canAccess(p)) { continue; } + const probe = await _probeVersion(cli, p); + if (probe.ok) { + console.log("[Phoenix AI] Validated " + cli.bin + " CLI at:", p); + entry.path = p; + entry.source = "fallback"; + entry.version = probe.version; + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + } + } + console.log("[Phoenix AI] Global " + cli.bin + " CLI not found"); + entry.errorCode = ERROR_CODES.NOT_FOUND; + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + }()); + + if (!force) { + _inFlight.set(cliId, discovery); + discovery.finally(function () { + if (_inFlight.get(cliId) === discovery) { + _inFlight.delete(cliId); + } + }); + } + return discovery; +} + +/** + * Check one specific path without touching the cache — for a "test this + * path" affordance in settings, so the UI never reimplements validation. + * @return {Promise<{ok: boolean, version: ?string, errorCode: ?string}>} + */ +async function validateCliPath(cliId, cliPath) { + const cli = CLI_REGISTRY[cliId]; + if (!cli) { + return { ok: false, version: null, errorCode: "UNKNOWN_CLI" }; + } + const report = await _resolveOverride(cli, cliPath); + if (!report) { + return { ok: false, version: null, errorCode: ERROR_CODES.OVERRIDE_MISSING }; + } + return { + ok: !!report.valid, + version: report.version || null, + errorCode: report.valid ? null : report.errorCode, + path: report.path + }; +} + +/** + * How to hand a resolved binary to node-pty. + * + * node-pty goes through CreateProcess, which runs .exe/.com only — a + * `.cmd`/`.bat` shim (what `npm i -g` leaves on Windows) has to be run via + * `cmd.exe /c`. Passing the shim straight through as the PTY's shell fails + * to spawn, so every terminal caller must resolve through here rather than + * using the raw path. + * @return {{command: string, args: Array}} + */ +function getSpawnProfile(cliPath) { + if (isWindows && /\.(cmd|bat)$/i.test(cliPath || "")) { + return { command: process.env.COMSPEC || "cmd.exe", args: ["/c", cliPath] }; + } + return { command: cliPath, args: [] }; +} + +exports.CLI_IDS = CLI_IDS; +exports.ERROR_CODES = ERROR_CODES; +exports.canAccess = canAccess; +exports.spawnCli = spawnCli; +exports.locateCli = locateCli; +exports.validateCliPath = validateCliPath; +exports.getSpawnProfile = getSpawnProfile; +exports.setOverrides = setOverrides; +exports.getOverride = getOverride; diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 7c2b73fd70..1e668a23c5 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2593,6 +2593,7 @@ define({ "AI_CHAT_TITLE": "Claude Code", "AI_CHAT_MODE_CHAT": "Claude Code", "AI_CHAT_MODE_CLI": "Claude Code CLI", + "AI_CHAT_MODE_CODEX_CLI": "Codex CLI", "AI_CHAT_SURPRISE_ME_USER_MSG": "Surprise me!", "AI_CHAT_SURPRISE_ME_LP_HEADING_1": "Setting the stage…", "AI_CHAT_SURPRISE_ME_LP_HEADING_2": "Warming the canvas…", @@ -2611,6 +2612,17 @@ define({ "AI_CHAT_CLI_INSTALLING": "Installing…", "AI_CHAT_CLI_INSTALLING_MSG": "Installing Claude Code, please wait. This may take a while...", "AI_CHAT_CLI_RESTART_NOTE": "Restart {APP_NAME} after installation completes.", + "AI_CHAT_CLI_NOT_FOUND_TITLE": "{0} not found", + "AI_CHAT_CLI_NOT_FOUND_MSG": "{0} could not be found on this system. Install it, or enter the full path to its executable below.", + "AI_CHAT_CLI_NOT_FOUND_BAD_PATH_MSG": "The path set for {0} could not be used: {1} Enter a different path, or clear the box to detect it automatically.", + "AI_CHAT_CLI_NOT_FOUND_INSTALL_BTN": "Install {0}", + "AI_CHAT_CLI_NOT_FOUND_SAVE_BTN": "Save & Retry", + "AI_CHAT_CLI_NOT_FOUND_LEARN_MORE": "Installation instructions", + "AI_CHAT_CLI_ERR_OVERRIDE_MISSING": "No file exists at that path.", + "AI_CHAT_CLI_ERR_OVERRIDE_NOT_EXECUTABLE": "That file is not executable.", + "AI_CHAT_CLI_ERR_OVERRIDE_INVALID": "That file did not run as {0}.", + "AI_CHAT_CLI_ERR_OVERRIDE_TIMEOUT": "That file did not respond in time.", + "AI_CHAT_CLI_ERR_OVERRIDE_REJECTED": "That path contains characters that cannot be used.", "AI_CHAT_CLAUDE_LOGIN_TITLE": "Setup Claude Code", "AI_CHAT_CLAUDE_LOGIN_MSG": "Claude Code is installed but needs to be configured.", "AI_CHAT_CLAUDE_LOGIN_BTN": "Setup Claude Code", @@ -2768,7 +2780,7 @@ define({ "AI_CHAT_MODEL_DESC_SONNET": "Balanced speed and capability for everyday coding", "AI_CHAT_MODEL_DESC_HAIKU": "Fastest model for quick, simple tasks", "AI_CHAT_MODEL_SELECT_TITLE": "Choose the AI model for this chat", - "AI_CHAT_MODE_SELECT_TITLE": "Switch between the Claude Code chat and an embedded Claude Code CLI terminal", + "AI_CHAT_MODE_SELECT_TITLE": "Switch between the Claude Code chat and an embedded CLI terminal", "AI_CHAT_MODEL_SWITCHED_NOTICE": "Switched to {0}. Applies from your next message; the first response may take a moment longer while the cache rebuilds.", "AI_CHAT_INPUT_HINT": "Press {0} to send · {1} for new line", "AI_CHAT_BASH_CONFIRM_TITLE": "Allow command?", @@ -2833,14 +2845,17 @@ define({ "AI_CHAT_RESUME_WHILE_STREAMING_MSG": "AI is currently working on a task. Switching to a previous conversation will stop it. Continue?", "AI_CHAT_CLI_NEW_CONFIRM_TITLE": "End this CLI session?", "AI_CHAT_CLI_NEW_CONFIRM_MSG": "This will end the running Claude Code CLI session and start a fresh one. Continue?", + "AI_CHAT_CODEX_NEW_CONFIRM_MSG": "This will end the running Codex CLI session and start a fresh one. Continue?", "AI_CHAT_CLI_UPSELL_TITLE": "Available in Phoenix Pro", "AI_CHAT_CLI_UPSELL_MSG": "Running Claude Code inside the Phoenix AI panel is a Pro feature. It gives Claude access to Phoenix integrations such as your editor and Live Preview. You can still run Claude Code CLI for free from the Terminal, without the Phoenix AI integrations.", + "AI_CHAT_CODEX_UPSELL_MSG": "Running Codex inside the Phoenix AI panel is a Pro feature. You can still run the Codex CLI for free from the Terminal.", "AI_CHAT_CLI_UPSELL_OPEN_TERMINAL_BTN": "Open in Terminal", "AI_CHAT_CLI_UPSELL_UPGRADE_BTN": "Upgrade to Pro", "AI_CHAT_CLI_STALE_PROJECT_MSG": "This Claude Code CLI session is still running in {0} — the open project is now {1}.", + "AI_CHAT_CODEX_STALE_PROJECT_MSG": "This Codex CLI session is still running in {0} — the open project is now {1}.", "AI_CHAT_CLI_STALE_PROJECT_SWITCH_BTN": "Switch to “{0}”", "AI_CHAT_CLI_STALE_PROJECT_STAY_BTN": "Stay on “{0}”", - "AI_CHAT_SETTINGS_TITLE": "Claude Code Settings", + "AI_CHAT_SETTINGS_TITLE": "AI Settings", "AI_SETTINGS_API_KEY": "API Key", "AI_SETTINGS_BASE_URL": "Base URL", "AI_SETTINGS_API_TIMEOUT": "API Timeout (ms)", @@ -2852,6 +2867,7 @@ define({ "AI_SETTINGS_CUSTOM_ENDPOINT_NOTICE": "Using custom API endpoint: {0}", "AI_SETTINGS_ACTIVE_PROVIDER": "Active Provider", "AI_SETTINGS_DEFAULT_PROVIDER": "Default (Local Claude Code)", + "AI_SETTINGS_DEFAULT_PROVIDER_FOR": "Default (Local {0})", "AI_SETTINGS_PROVIDERS": "Providers", "AI_SETTINGS_ADD_PROVIDER": "+ Add Provider", "AI_SETTINGS_EDIT": "Edit", @@ -2864,6 +2880,9 @@ define({ "AI_SETTINGS_NAME_REQUIRED": "Provider name is required.", "AI_SETTINGS_NAME_DUPLICATE": "A provider with this name already exists.", "AI_SETTINGS_DONE": "Done", + "AI_SETTINGS_PROVIDER_TYPE_NOTE": "These providers configure {0}. The API key is passed to it as {1}.", + "AI_SETTINGS_CLI_PATHS_NOTE": "Leave blank to detect automatically.", + "AI_SETTINGS_CLI_PATH_LABEL": "Path to the {0} executable", "AI_SETTINGS_ENABLE_AI": "Enable AI features", "AI_SETTINGS_ENABLE_AI_NOTE": "Turns all AI features in {APP_NAME} on or off. Changing this restarts {APP_NAME} to apply it — you can enable it again anytime from View menu > Enable AI.", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 6edf1aa9ba..927c967cbf 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -31,6 +31,13 @@ @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 + +// Dialog scale. The AI tiers above are sized for a ~300px sidebar column; +// a modal is not that, and Phoenix's own dialogs render body text at 14px. +// Reusing the sidebar tiers in a dialog made labels 12px and notes 11px — +// noticeably smaller than every other dialog in the app. +@ai-dialog-text: @menu-item-font-size; // 14px — matches a stock dialog body +@ai-dialog-note: 13px; // one tick down, still comfortably readable @ai-line-compact: 1.4; // single-line UI elements // Warning-tone chrome (e.g. the CLI-mode "project switched" banner). Must @@ -280,6 +287,28 @@ } } +/* One slot per embedded CLI, both living inside the single + .ai-chat-body-cli so they share its --terminal-* palette by inheritance + (TerminalInstance reads those off its own container). Only the active + mode's slot is shown; the other keeps its PTY running off-screen. + + `position: relative` is load-bearing, not decoration: it anchors this + session's .terminal-instance-container (absolute; inset:0) and its + .ai-cli-stale-banner (absolute; bottom:0). Without it both sessions' + terminals and banners would position against the shared body and stack + on top of each other. */ +.ai-cli-session { + display: none; + position: relative; + flex: 1; + min-height: 0; + + &.active { + display: flex; + flex-direction: column; + } +} + /* "Project switched" banner over the embedded CLI terminal — a dark, warm-tinted card in the panel's own elevation family (see @ai-warning-bg) with a left amber stripe + icon carrying the "pay attention" cue, rather @@ -4081,9 +4110,127 @@ } /* ── AI Settings Dialog ────────────────────────────────────────────── */ +/* Label + input pair. Shared by the AI settings dialog and the CLI-not-found + dialog, which asks for a binary path with the same affordance — the rule + lives at top level rather than inside .ai-settings-dialog so both get it. */ +.ai-settings-dialog .ai-settings-field, +.ai-cli-not-found .ai-settings-field { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: @ai-dialog-text; + font-weight: 600; + color: @bc-text-medium; + + .dark & { + color: @dark-bc-text-medium; + } + } + + input { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + height: 30px; + border: 1px solid @bc-panel-border; + border-radius: @bc-border-radius; + background: @bc-input-bg; + color: @bc-text; + font-size: @ai-dialog-text; + outline: none; + + &:focus { + border-color: @bc-btn-border-focused; + } + + &::placeholder { + color: @bc-text-quiet; + opacity: 0.7; + } + + .dark & { + border-color: @dark-bc-panel-border; + background: @dark-bc-input-bg; + color: @dark-bc-text; + + &:focus { + border-color: @dark-bc-btn-border-focused; + } + + &::placeholder { + color: @dark-bc-text-quiet; + opacity: 0.7; + } + } + } +} + .ai-settings-dialog { + /* One tab per CLI. Underline rather than a boxed tab: the dialog is + already a stack of labelled sections, and a heavier control here + would read as the page's primary chrome instead of a filter over the + provider list below it. */ + .ai-settings-tabs { + display: flex; + gap: 4px; + margin-bottom: 14px; + border-bottom: 1px solid @bc-panel-separator; + + .dark & { + border-bottom-color: @dark-bc-panel-separator; + } + } + + .ai-settings-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + box-shadow: none; + padding: 6px 12px; + margin-bottom: -1px; + cursor: pointer; + font-size: @ai-text-body; + color: @bc-text-medium; + + &:hover { + color: @bc-text; + } + + &.active { + color: @bc-text; + border-bottom-color: @bc-primary-btn-bg; + font-weight: 600; + } + + .dark & { + color: @dark-bc-text-medium; + + &:hover, + &.active { + color: @dark-bc-text; + } + } + } + + /* Explanatory line under a section label or the tab strip. */ + .ai-settings-section-note, + .ai-settings-tab-note { + font-size: @ai-dialog-note; + color: @bc-text-medium; + opacity: 0.8; + margin-bottom: 10px; + line-height: @ai-line-prose; + + .dark & { + color: @dark-bc-text-medium; + } + } + .ai-settings-section-label { - font-size: @ai-text-secondary; + font-size: @ai-dialog-text; font-weight: 600; color: @bc-text-medium; margin-bottom: 6px; @@ -4139,7 +4286,7 @@ } .ai-settings-provider-name { - font-size: @ai-text-body; + font-size: @ai-dialog-text; font-weight: 600; color: @bc-text; white-space: nowrap; @@ -4152,7 +4299,7 @@ } .ai-settings-provider-url { - font-size: @ai-text-meta; + font-size: @ai-dialog-note; color: @bc-text-medium; white-space: nowrap; overflow: hidden; @@ -4191,7 +4338,7 @@ } .ai-settings-edit-error { - font-size: @ai-text-secondary; + font-size: @ai-dialog-note; color: @bc-error; min-height: 0; @@ -4206,59 +4353,6 @@ gap: 8px; } - .ai-settings-field { - display: flex; - flex-direction: column; - gap: 4px; - - label { - font-size: @ai-text-secondary; - font-weight: 600; - color: @bc-text-medium; - - .dark & { - color: @dark-bc-text-medium; - } - } - - input { - width: 100%; - box-sizing: border-box; - padding: 8px 10px; - height: 30px; - border: 1px solid @bc-panel-border; - border-radius: @bc-border-radius; - background: @bc-input-bg; - color: @bc-text; - font-size: @ai-text-body; - outline: none; - - &:focus { - border-color: @bc-btn-border-focused; - } - - &::placeholder { - color: @bc-text-quiet; - opacity: 0.7; - } - - .dark & { - border-color: @dark-bc-panel-border; - background: @dark-bc-input-bg; - color: @dark-bc-text; - - &:focus { - border-color: @dark-bc-btn-border-focused; - } - - &::placeholder { - color: @dark-bc-text-quiet; - opacity: 0.7; - } - } - } - } - .ai-settings-enable-row { display: flex; align-items: flex-start; @@ -4281,7 +4375,7 @@ } .ai-settings-enable-label { - font-size: @ai-text-body; + font-size: @ai-dialog-text; font-weight: 600; color: @bc-text; @@ -4291,7 +4385,7 @@ } .ai-settings-enable-note { - font-size: @ai-text-meta; + font-size: @ai-dialog-note; color: @bc-text-medium; .dark & { @@ -4477,3 +4571,17 @@ } } +/* "CLI not found" dialog. Reuses the settings field styling above; only the + surrounding rhythm is its own. */ +.ai-cli-not-found { + .ai-cli-not-found-msg { + margin: 0 0 12px; + line-height: @ai-line-prose; + } + + .ai-learn-more-link { + display: inline-block; + margin-top: 10px; + font-size: @ai-dialog-note; + } +} diff --git a/tracking-repos.json b/tracking-repos.json index bfb5e2bd21..adb9a3a207 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "893b06e5612ad563f7fcf32ac132a3430273fb10" + "commitID": "b73dd3515ed7b32d410ba924d58abfd4a74523ff" } }