diff --git a/electron/main.ts b/electron/main.ts index c4f2568d..8b48aea9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,9 +1,20 @@ -import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain, shell, Tray, Menu } from 'electron'; -import path from 'path'; -import { execFileSync, spawn, ChildProcess } from 'child_process'; -import fs from 'fs'; -import net from 'net'; -import os from 'os'; +import { + app, + BrowserWindow, + nativeImage, + dialog, + session, + utilityProcess, + ipcMain, + shell, + Tray, + Menu, +} from "electron"; +import path from "path"; +import { execFileSync, spawn, ChildProcess } from "child_process"; +import fs from "fs"; +import net from "net"; +import os from "os"; /** * Return a copy of process.env without __NEXT_PRIVATE_* variables. @@ -17,7 +28,7 @@ import os from 'os'; function sanitizedProcessEnv(): Record { const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { - if (!key.startsWith('__NEXT_PRIVATE_') && value !== undefined) { + if (!key.startsWith("__NEXT_PRIVATE_") && value !== undefined) { env[key] = value; } } @@ -38,19 +49,19 @@ let tray: Tray | null = null; interface InstallStep { id: string; label: string; - status: 'pending' | 'running' | 'success' | 'failed' | 'skipped'; + status: "pending" | "running" | "success" | "failed" | "skipped"; error?: string; } interface InstallState { - status: 'idle' | 'running' | 'success' | 'failed' | 'cancelled'; + status: "idle" | "running" | "success" | "failed" | "cancelled"; currentStep: string | null; steps: InstallStep[]; logs: string[]; } let installState: InstallState = { - status: 'idle', + status: "idle", currentStep: null, steps: [], logs: [], @@ -78,26 +89,30 @@ function killServer(): Promise { // Force kill — on Windows use taskkill to kill the entire process tree if (pid) { try { - if (process.platform === 'win32') { - spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' }); + if (process.platform === "win32") { + spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { + stdio: "ignore", + }); } else { - process.kill(pid, 'SIGKILL'); + process.kill(pid, "SIGKILL"); } - } catch { /* already dead */ } + } catch { + /* already dead */ + } } serverProcess = null; resolve(); }, 3000); - serverProcess.on('exit', () => { + serverProcess.on("exit", () => { clearTimeout(timeout); serverProcess = null; resolve(); }); // On Windows, SIGTERM is not supported — use taskkill to kill the tree - if (process.platform === 'win32' && pid) { - spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' }); + if (process.platform === "win32" && pid) { + spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" }); } else { serverProcess.kill(); } @@ -111,22 +126,33 @@ async function isBridgeActive(): Promise { if (!serverPort) return false; try { // eslint-disable-next-line @typescript-eslint/no-require-imports - const http = require('http'); + const http = require("http"); return await new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${serverPort}/api/bridge`, (res: { statusCode?: number; on: (event: string, cb: (data?: Buffer) => void) => void }) => { - let body = ''; - res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); - res.on('end', () => { - try { - const data = JSON.parse(body); - resolve(data.running === true); - } catch { - resolve(false); - } - }); + const req = http.get( + `http://127.0.0.1:${serverPort}/api/bridge`, + (res: { + statusCode?: number; + on: (event: string, cb: (data?: Buffer) => void) => void; + }) => { + let body = ""; + res.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on("end", () => { + try { + const data = JSON.parse(body); + resolve(data.running === true); + } catch { + resolve(false); + } + }); + }, + ); + req.on("error", () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); }); - req.on('error', () => resolve(false)); - req.setTimeout(2000, () => { req.destroy(); resolve(false); }); }); } catch { return false; @@ -140,21 +166,29 @@ async function stopBridge(): Promise { if (!serverPort) return; try { // eslint-disable-next-line @typescript-eslint/no-require-imports - const http = require('http'); + const http = require("http"); await new Promise((resolve) => { - const postData = JSON.stringify({ action: 'stop' }); - const req = http.request({ - hostname: '127.0.0.1', - port: serverPort, - path: '/api/bridge', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), + const postData = JSON.stringify({ action: "stop" }); + const req = http.request( + { + hostname: "127.0.0.1", + port: serverPort, + path: "/api/bridge", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(postData), + }, + }, + () => { + resolve(); }, - }, () => { resolve(); }); - req.on('error', () => resolve()); - req.setTimeout(3000, () => { req.destroy(); resolve(); }); + ); + req.on("error", () => resolve()); + req.setTimeout(3000, () => { + req.destroy(); + resolve(); + }); req.write(postData); req.end(); }); @@ -171,13 +205,15 @@ function createTray(): void { if (tray) return; const iconPath = getIconPath(); - const trayIcon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }); + const trayIcon = nativeImage + .createFromPath(iconPath) + .resize({ width: 16, height: 16 }); tray = new Tray(trayIcon); - tray.setToolTip('CodePilot — Bridge Active'); + tray.setToolTip("CodePilot — Bridge Active"); const contextMenu = Menu.buildFromTemplate([ { - label: 'Open CodePilot', + label: "Open CodePilot", click: () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(`http://127.0.0.1:${serverPort || 3000}`); @@ -186,14 +222,14 @@ function createTray(): void { } }, }, - { type: 'separator' }, + { type: "separator" }, { - label: 'Bridge Status: Active', + label: "Bridge Status: Active", enabled: false, }, - { type: 'separator' }, + { type: "separator" }, { - label: 'Stop Bridge & Quit', + label: "Stop Bridge & Quit", click: async () => { await stopBridge(); destroyTray(); @@ -206,7 +242,7 @@ function createTray(): void { tray.setContextMenu(contextMenu); // Double-click on tray icon opens the window (macOS/Windows) - tray.on('double-click', () => { + tray.on("double-click", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(`http://127.0.0.1:${serverPort || 3000}`); } else { @@ -231,7 +267,7 @@ function destroyTray(): void { function checkNativeModuleABI(): void { if (isDev) return; // Skip in dev mode - const standaloneDir = path.join(process.resourcesPath, 'standalone'); + const standaloneDir = path.join(process.resourcesPath, "standalone"); // Find better_sqlite3.node recursively function findNodeFile(dir: string): string | null { @@ -242,33 +278,37 @@ function checkNativeModuleABI(): void { if (entry.isDirectory()) { const found = findNodeFile(fullPath); if (found) return found; - } else if (entry.name === 'better_sqlite3.node') { + } else if (entry.name === "better_sqlite3.node") { return fullPath; } } return null; } - const nodeFile = findNodeFile(path.join(standaloneDir, 'node_modules')); + const nodeFile = findNodeFile(path.join(standaloneDir, "node_modules")); if (!nodeFile) { - console.warn('[ABI check] better_sqlite3.node not found in standalone resources'); + console.warn( + "[ABI check] better_sqlite3.node not found in standalone resources", + ); return; } try { // Attempt to load the native module to verify ABI compatibility process.dlopen({ exports: {} } as NodeModule, nodeFile); - console.log(`[ABI check] better_sqlite3.node ABI is compatible (${nodeFile})`); + console.log( + `[ABI check] better_sqlite3.node ABI is compatible (${nodeFile})`, + ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('NODE_MODULE_VERSION')) { + if (msg.includes("NODE_MODULE_VERSION")) { console.error(`[ABI check] ABI mismatch detected: ${msg}`); dialog.showErrorBox( - 'CodePilot - Native Module ABI Mismatch', + "CodePilot - Native Module ABI Mismatch", `The bundled better-sqlite3 native module was compiled for a different Node.js version.\n\n` + - `${msg}\n\n` + - `This usually means the build process did not correctly recompile native modules for Electron.\n` + - `Please rebuild the application or report this issue.` + `${msg}\n\n` + + `This usually means the build process did not correctly recompile native modules for Electron.\n` + + `Please rebuild the application or report this issue.`, ); app.quit(); } else { @@ -278,39 +318,122 @@ function checkNativeModuleABI(): void { } } +function parseEnvOutput(output: string): Record { + const env: Record = {}; + for (const line of output.split("\n")) { + const idx = line.indexOf("="); + if (idx > 0) { + env[line.slice(0, idx)] = line.slice(idx + 1); + } + } + return env; +} + +/** + * Resolve system proxy for api.anthropic.com using Electron's built-in proxy + * resolution (same engine as Chromium). Works cross-platform: macOS, Windows, + * Linux — including PAC files and WinHTTP settings. + * + * Must be called after app.whenReady() so session is available. + */ +async function getProxyEnvFromSystem(): Promise> { + try { + const result = await session.defaultSession.resolveProxy( + "https://api.anthropic.com", + ); + // result format: "PROXY host:port" | "DIRECT" | "PROXY h1:p1; PROXY h2:p2" + const match = result.match(/PROXY\s+([^;\s]+)/i); + if (match) { + const proxyUrl = `http://${match[1]}`; + console.log(`Resolved system proxy for api.anthropic.com: ${proxyUrl}`); + return { + HTTPS_PROXY: proxyUrl, + https_proxy: proxyUrl, + HTTP_PROXY: proxyUrl, + http_proxy: proxyUrl, + }; + } + } catch (err) { + console.warn("Failed to resolve system proxy:", err); + } + return {}; +} + /** * Read the user's full shell environment by running a login shell. * When Electron is launched from Dock/Finder (macOS) or desktop launcher * (Linux), process.env is very limited and won't include vars from - * .zshrc/.bashrc (e.g. API keys, nvm PATH). + * .zshrc/.bashrc (e.g. API keys, nvm PATH, HTTPS_PROXY). + * + * Uses a two-level cascade to handle slow shell startup (e.g. oh-my-zsh): + * 1. Non-interactive login shell (fast, ~1s) + * 2. Interactive login shell (slower, handles oh-my-zsh etc.) + * + * Proxy fallback is handled separately via getProxyEnvFromSystem() after + * app.whenReady() — cross-platform and more reliable than shell/scutil. */ function loadUserShellEnv(): Record { - // Windows GUI apps inherit the full user environment - if (process.platform === 'win32') { + // Windows GUI apps inherit the full user environment from the system + if (process.platform === "win32") { return {}; } + const shell = process.env.SHELL || "/bin/zsh"; + + // Method 1: non-interactive login shell (fast, sources .zprofile/.bash_profile) + let env1: Record = {}; try { - const shell = process.env.SHELL || '/bin/zsh'; - const result = execFileSync(shell, ['-ilc', 'env'], { + const result = execFileSync(shell, ["-lc", "env"], { timeout: 5000, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], }); - const env: Record = {}; - for (const line of result.split('\n')) { - const idx = line.indexOf('='); - if (idx > 0) { - const key = line.slice(0, idx); - const value = line.slice(idx + 1); - env[key] = value; - } - } - console.log(`Loaded ${Object.keys(env).length} env vars from user shell`); - return env; + env1 = parseEnvOutput(result); + console.log( + `Loaded ${Object.keys(env1).length} env vars from user shell (non-interactive)`, + ); + } catch { + /* fall through */ + } + + // Method 2: full interactive login shell (slower, also sources .zshrc) + // Always run this so we capture PATH customizations that live only in .zshrc. + let env2: Record = {}; + try { + const result = execFileSync(shell, ["-ilc", "env"], { + timeout: 10000, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + env2 = parseEnvOutput(result); + console.log( + `Loaded ${Object.keys(env2).length} env vars from user shell (interactive)`, + ); } catch (err) { - console.warn('Failed to load user shell env:', err); + console.warn( + "Failed to load user shell env from interactive login shell:", + err, + ); + } + + if (!Object.keys(env1).length && !Object.keys(env2).length) { return {}; } + + // Merge: start from env1, overlay env2, but for PATH take the union so that + // dirs only in .zshrc (env2) are not lost. + const merged = { ...env1, ...env2 }; + const path1Dirs = (env1.PATH || "").split(":").filter(Boolean); + const path2Dirs = (env2.PATH || "").split(":").filter(Boolean); + const seen = new Set(); + const mergedPath: string[] = []; + for (const d of [...path2Dirs, ...path1Dirs]) { + if (d && !seen.has(d)) { + seen.add(d); + mergedPath.push(d); + } + } + if (mergedPath.length) merged.PATH = mergedPath.join(":"); + return merged; } /** @@ -319,26 +442,31 @@ function loadUserShellEnv(): Record { */ function getExpandedShellPath(): string { const home = os.homedir(); - const shellPath = userShellEnv.PATH || process.env.PATH || ''; + const shellPath = userShellEnv.PATH || process.env.PATH || ""; const sep = path.delimiter; - if (process.platform === 'win32') { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + if (process.platform === "win32") { + const appData = + process.env.APPDATA || path.join(home, "AppData", "Roaming"); + const localAppData = + process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); const winExtra = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), + path.join(appData, "npm"), + path.join(localAppData, "npm"), + path.join(home, ".npm-global", "bin"), + path.join(home, ".local", "bin"), + path.join(home, ".claude", "bin"), ]; - const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); + const allParts = [shellPath, ...winExtra] + .join(sep) + .split(sep) + .filter(Boolean); return [...new Set(allParts)].join(sep); } else { const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; - const allParts = raw.split(':').filter(Boolean); - return [...new Set(allParts)].join(':'); + const allParts = raw.split(":").filter(Boolean); + return [...new Set(allParts)].join(":"); } } @@ -346,14 +474,14 @@ function getPort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { const addr = server.address(); - if (addr && typeof addr === 'object') { + if (addr && typeof addr === "object") { const port = addr.port; server.close(() => resolve(port)); } else { - server.close(() => reject(new Error('Failed to get port'))); + server.close(() => reject(new Error("Failed to get port"))); } }); }); @@ -361,50 +489,53 @@ function getPort(): Promise { async function waitForServer(port: number, timeout = 30000): Promise { const start = Date.now(); - let lastError = ''; + let lastError = ""; while (Date.now() - start < timeout) { // If the server process already exited, fail fast if (serverExited) { throw new Error( - `Server process exited with code ${serverExitCode}.\n\n${serverErrors.join('\n')}` + `Server process exited with code ${serverExitCode}.\n\n${serverErrors.join("\n")}`, ); } try { await new Promise((resolve, reject) => { // eslint-disable-next-line @typescript-eslint/no-require-imports - const http = require('http'); + const http = require("http"); // Use options object with family:4 to force IPv4 — avoids Windows // IPv6 resolution issues where 127.0.0.1 may fail to connect. - const req = http.get({ - hostname: '127.0.0.1', - port, - path: '/api/health', - family: 4, - timeout: 2000, - }, (res: { statusCode?: number }) => { - if (res.statusCode === 200) resolve(); - else reject(new Error(`Status ${res.statusCode}`)); - }); - req.on('error', (err: Error) => reject(err)); - req.on('timeout', () => { + const req = http.get( + { + hostname: "127.0.0.1", + port, + path: "/api/health", + family: 4, + timeout: 2000, + }, + (res: { statusCode?: number }) => { + if (res.statusCode === 200) resolve(); + else reject(new Error(`Status ${res.statusCode}`)); + }, + ); + req.on("error", (err: Error) => reject(err)); + req.on("timeout", () => { req.destroy(); - reject(new Error('request timeout')); + reject(new Error("request timeout")); }); }); return; } catch (err) { lastError = err instanceof Error ? err.message : String(err); - await new Promise(r => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 300)); } } throw new Error( - `Server startup timeout after ${timeout / 1000}s.\n\nLast health-check error: ${lastError}\n\n${serverErrors.length > 0 ? 'Server output:\n' + serverErrors.slice(-10).join('\n') : 'No server output captured.'}` + `Server startup timeout after ${timeout / 1000}s.\n\nLast health-check error: ${lastError}\n\n${serverErrors.length > 0 ? "Server output:\n" + serverErrors.slice(-10).join("\n") : "No server output captured."}`, ); } function startServer(port: number): Electron.UtilityProcess { - const standaloneDir = path.join(process.resourcesPath, 'standalone'); - const serverPath = path.join(standaloneDir, 'server.js'); + const standaloneDir = path.join(process.resourcesPath, "standalone"); + const serverPath = path.join(standaloneDir, "server.js"); console.log(`Server path: ${serverPath}`); console.log(`Standalone dir: ${standaloneDir}`); @@ -422,8 +553,8 @@ function startServer(port: number): Electron.UtilityProcess { // Ensure user shell env vars override (especially API keys) ...userShellEnv, PORT: String(port), - HOSTNAME: '127.0.0.1', - CLAUDE_GUI_DATA_DIR: path.join(home, '.codepilot'), + HOSTNAME: "127.0.0.1", + CLAUDE_GUI_DATA_DIR: path.join(home, ".codepilot"), HOME: home, USERPROFILE: home, PATH: constructedPath, @@ -434,23 +565,23 @@ function startServer(port: number): Electron.UtilityProcess { const child = utilityProcess.fork(serverPath, [], { env, cwd: standaloneDir, - stdio: 'pipe', - serviceName: 'codepilot-server', + stdio: "pipe", + serviceName: "codepilot-server", }); - child.stdout?.on('data', (data: Buffer) => { + child.stdout?.on("data", (data: Buffer) => { const msg = data.toString().trim(); console.log(`[server] ${msg}`); serverErrors.push(msg); }); - child.stderr?.on('data', (data: Buffer) => { + child.stderr?.on("data", (data: Buffer) => { const msg = data.toString().trim(); console.error(`[server:err] ${msg}`); serverErrors.push(msg); }); - child.on('exit', (code) => { + child.on("exit", (code) => { console.log(`Server process exited with code ${code}`); serverExited = true; serverExitCode = code; @@ -462,15 +593,15 @@ function startServer(port: number): Electron.UtilityProcess { function getIconPath(): string { if (isDev) { - return path.join(process.cwd(), 'build', 'icon.png'); + return path.join(process.cwd(), "build", "icon.png"); } - if (process.platform === 'win32') { - return path.join(process.resourcesPath, 'icon.ico'); + if (process.platform === "win32") { + return path.join(process.resourcesPath, "icon.ico"); } - if (process.platform === 'linux') { - return path.join(process.resourcesPath, 'icon.png'); + if (process.platform === "linux") { + return path.join(process.resourcesPath, "icon.png"); } - return path.join(process.resourcesPath, 'icon.icns'); + return path.join(process.resourcesPath, "icon.icns"); } /** Inline loading HTML shown while the server starts up */ @@ -514,19 +645,19 @@ function createWindow(url?: string) { minHeight: 600, icon: getIconPath(), webPreferences: { - preload: path.join(__dirname, 'preload.js'), + preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, }, }; - if (process.platform === 'darwin') { - windowOptions.titleBarStyle = 'hiddenInset'; - } else if (process.platform === 'win32') { - windowOptions.titleBarStyle = 'hidden'; + if (process.platform === "darwin") { + windowOptions.titleBarStyle = "hiddenInset"; + } else if (process.platform === "win32") { + windowOptions.titleBarStyle = "hidden"; windowOptions.titleBarOverlay = { - color: '#00000000', - symbolColor: '#888888', + color: "#00000000", + symbolColor: "#888888", height: 44, }; } @@ -539,7 +670,7 @@ function createWindow(url?: string) { mainWindow.webContents.openDevTools(); } - mainWindow.on('closed', () => { + mainWindow.on("closed", () => { mainWindow = null; }); } @@ -548,46 +679,94 @@ app.whenReady().then(async () => { // Load user's full shell environment (API keys, PATH, etc.) userShellEnv = loadUserShellEnv(); + // If shell env has no proxy, fall back to system proxy resolution. + // Uses Electron's built-in Chromium proxy engine — cross-platform + // (macOS scutil, Windows WinHTTP/IE settings, Linux gsettings, PAC files). + if (!userShellEnv.HTTPS_PROXY && !userShellEnv.https_proxy) { + const systemProxy = await getProxyEnvFromSystem(); + Object.assign(userShellEnv, systemProxy); + } + + // Last fallback: read proxy from ~/.claude/settings.json env field. + // This allows users to configure proxy via the CodePilot settings UI, + // persisted as HTTPS_PROXY in the CLI settings env object. + if (!userShellEnv.HTTPS_PROXY && !userShellEnv.https_proxy) { + try { + const claudeSettingsPath = path.join( + os.homedir(), + ".claude", + "settings.json", + ); + if (fs.existsSync(claudeSettingsPath)) { + const raw = fs.readFileSync(claudeSettingsPath, "utf-8"); + const parsed = JSON.parse(raw); + const envProxy = parsed?.env?.HTTPS_PROXY || parsed?.env?.https_proxy; + if (envProxy) { + userShellEnv.HTTPS_PROXY = envProxy; + userShellEnv.https_proxy = envProxy; + userShellEnv.HTTP_PROXY = envProxy; + userShellEnv.http_proxy = envProxy; + console.log(`Loaded proxy from ~/.claude/settings.json: ${envProxy}`); + } + } + } catch { + // Ignore parse errors + } + } + // Verify native module ABI compatibility before starting the server checkNativeModuleABI(); // Clear cache on version upgrade const currentVersion = app.getVersion(); - const versionFilePath = path.join(app.getPath('userData'), 'last-version.txt'); + const versionFilePath = path.join( + app.getPath("userData"), + "last-version.txt", + ); try { const lastVersion = fs.existsSync(versionFilePath) - ? fs.readFileSync(versionFilePath, 'utf-8').trim() - : ''; + ? fs.readFileSync(versionFilePath, "utf-8").trim() + : ""; if (lastVersion && lastVersion !== currentVersion) { - console.log(`Version changed from ${lastVersion} to ${currentVersion}, clearing cache...`); + console.log( + `Version changed from ${lastVersion} to ${currentVersion}, clearing cache...`, + ); await session.defaultSession.clearCache(); await session.defaultSession.clearStorageData({ - storages: ['cachestorage', 'serviceworkers'], + storages: ["cachestorage", "serviceworkers"], }); - console.log('Cache cleared successfully'); + console.log("Cache cleared successfully"); } - fs.writeFileSync(versionFilePath, currentVersion, 'utf-8'); + fs.writeFileSync(versionFilePath, currentVersion, "utf-8"); } catch (err) { - console.warn('Failed to check/clear version cache:', err); + console.warn("Failed to check/clear version cache:", err); } // Set macOS Dock icon - if (process.platform === 'darwin' && app.dock) { + if (process.platform === "darwin" && app.dock) { const iconPath = getIconPath(); app.dock.setIcon(nativeImage.createFromPath(iconPath)); } // --- Install wizard IPC handlers --- - ipcMain.handle('install:check-prerequisites', async () => { + ipcMain.handle("install:check-prerequisites", async () => { const expandedPath = getExpandedShellPath(); - const execEnv = { ...sanitizedProcessEnv(), ...userShellEnv, PATH: expandedPath }; - const execOpts = { timeout: 5000, encoding: 'utf-8' as const, env: execEnv }; + const execEnv = { + ...sanitizedProcessEnv(), + ...userShellEnv, + PATH: expandedPath, + }; + const execOpts = { + timeout: 5000, + encoding: "utf-8" as const, + env: execEnv, + }; let hasNode = false; let nodeVersion: string | undefined; try { - const result = execFileSync('node', ['--version'], execOpts); + const result = execFileSync("node", ["--version"], execOpts); nodeVersion = result.trim(); hasNode = true; } catch { @@ -597,10 +776,9 @@ app.whenReady().then(async () => { let hasClaude = false; let claudeVersion: string | undefined; try { - const claudeOpts = process.platform === 'win32' - ? { ...execOpts, shell: true } - : execOpts; - const result = execFileSync('claude', ['--version'], claudeOpts); + const claudeOpts = + process.platform === "win32" ? { ...execOpts, shell: true } : execOpts; + const result = execFileSync("claude", ["--version"], claudeOpts); claudeVersion = result.trim(); hasClaude = true; } catch { @@ -609,275 +787,352 @@ app.whenReady().then(async () => { // Check Homebrew on macOS let hasHomebrew = false; - if (process.platform === 'darwin') { - const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']; - hasHomebrew = brewPaths.some(p => fs.existsSync(p)); + if (process.platform === "darwin") { + const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]; + hasHomebrew = brewPaths.some((p) => fs.existsSync(p)); } - return { hasNode, nodeVersion, hasClaude, claudeVersion, hasHomebrew, platform: process.platform }; + return { + hasNode, + nodeVersion, + hasClaude, + claudeVersion, + hasHomebrew, + platform: process.platform, + }; }); - ipcMain.handle('install:start', (_event: Electron.IpcMainInvokeEvent, options?: { includeNode?: boolean }) => { - if (installState.status === 'running') { - throw new Error('Installation is already running'); - } - - const needsNode = options?.includeNode === true; - - // Reset state - const steps: InstallStep[] = []; - if (needsNode) { - steps.push({ id: 'install-node', label: 'Installing Node.js', status: 'pending' }); - } - steps.push( - { id: 'check-node', label: 'Checking Node.js', status: 'pending' }, - { id: 'install-claude', label: 'Installing Claude Code', status: 'pending' }, - { id: 'verify', label: 'Verifying installation', status: 'pending' }, - ); + ipcMain.handle( + "install:start", + ( + _event: Electron.IpcMainInvokeEvent, + options?: { includeNode?: boolean }, + ) => { + if (installState.status === "running") { + throw new Error("Installation is already running"); + } - installState = { - status: 'running', - currentStep: null, - steps, - logs: [], - }; + const needsNode = options?.includeNode === true; - const expandedPath = getExpandedShellPath(); - const execEnv: Record = { - ...userShellEnv, - ...sanitizedProcessEnv(), - ...userShellEnv, - PATH: expandedPath, - }; + // Reset state + const steps: InstallStep[] = []; + if (needsNode) { + steps.push({ + id: "install-node", + label: "Installing Node.js", + status: "pending", + }); + } + steps.push( + { id: "check-node", label: "Checking Node.js", status: "pending" }, + { + id: "install-claude", + label: "Installing Claude Code", + status: "pending", + }, + { id: "verify", label: "Verifying installation", status: "pending" }, + ); - function sendProgress() { - mainWindow?.webContents.send('install:progress', installState); - } + installState = { + status: "running", + currentStep: null, + steps, + logs: [], + }; + + const expandedPath = getExpandedShellPath(); + const execEnv: Record = { + ...userShellEnv, + ...sanitizedProcessEnv(), + ...userShellEnv, + PATH: expandedPath, + }; + + function sendProgress() { + mainWindow?.webContents.send("install:progress", installState); + } - function setStep(id: string, status: InstallStep['status'], error?: string) { - const step = installState.steps.find(s => s.id === id); - if (step) { - step.status = status; - step.error = error; + function setStep( + id: string, + status: InstallStep["status"], + error?: string, + ) { + const step = installState.steps.find((s) => s.id === id); + if (step) { + step.status = status; + step.error = error; + } + installState.currentStep = id; + sendProgress(); } - installState.currentStep = id; - sendProgress(); - } - function addLog(line: string) { - installState.logs.push(line); - sendProgress(); - } + function addLog(line: string) { + installState.logs.push(line); + sendProgress(); + } - // Run the installation sequence asynchronously - (async () => { - try { - // Step 0 (optional): Install Node.js via package manager - if (needsNode) { - setStep('install-node', 'running'); - - const nodeInstalled = await new Promise((resolve) => { - const isWin = process.platform === 'win32'; - const isMac = process.platform === 'darwin'; - let cmd: string; - let args: string[]; - - if (isMac) { - // macOS: Homebrew only — UI should guide the user to install Homebrew first - const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']; - const brewPath = brewPaths.find(p => fs.existsSync(p)); - if (brewPath) { - cmd = brewPath; - args = ['install', 'node']; - addLog(`Running: ${brewPath} install node`); + // Run the installation sequence asynchronously + (async () => { + try { + // Step 0 (optional): Install Node.js via package manager + if (needsNode) { + setStep("install-node", "running"); + + const nodeInstalled = await new Promise((resolve) => { + const isWin = process.platform === "win32"; + const isMac = process.platform === "darwin"; + let cmd: string; + let args: string[]; + + if (isMac) { + // macOS: Homebrew only — UI should guide the user to install Homebrew first + const brewPaths = [ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + ]; + const brewPath = brewPaths.find((p) => fs.existsSync(p)); + if (brewPath) { + cmd = brewPath; + args = ["install", "node"]; + addLog(`Running: ${brewPath} install node`); + } else { + addLog( + "Homebrew is required. Please install Homebrew first and retry.", + ); + resolve(false); + return; + } + } else if (isWin) { + cmd = "winget"; + args = [ + "install", + "-e", + "--id", + "OpenJS.NodeJS.LTS", + "--accept-source-agreements", + "--accept-package-agreements", + ]; + addLog("Running: winget install -e --id OpenJS.NodeJS.LTS"); } else { - addLog('Homebrew is required. Please install Homebrew first and retry.'); + // Linux — no universal package manager + addLog( + "Auto-install of Node.js is not supported on this platform.", + ); resolve(false); return; } - } else if (isWin) { - cmd = 'winget'; - args = ['install', '-e', '--id', 'OpenJS.NodeJS.LTS', '--accept-source-agreements', '--accept-package-agreements']; - addLog('Running: winget install -e --id OpenJS.NodeJS.LTS'); - } else { - // Linux — no universal package manager - addLog('Auto-install of Node.js is not supported on this platform.'); - resolve(false); + + const child = spawn(cmd, args, { + env: execEnv, + shell: isWin, + stdio: ["ignore", "pipe", "pipe"], + }); + + installProcess = child; + + child.stdout?.on("data", (data: Buffer) => { + for (const line of data + .toString() + .split("\n") + .filter(Boolean)) { + addLog(line); + } + }); + child.stderr?.on("data", (data: Buffer) => { + for (const line of data + .toString() + .split("\n") + .filter(Boolean)) { + addLog(line); + } + }); + child.on("error", (err) => { + addLog(`Error: ${err.message}`); + resolve(false); + }); + child.on("close", (code) => { + installProcess = null; + resolve(code === 0); + }); + }); + + if (installState.status === "cancelled") { + setStep("install-node", "failed", "Cancelled"); return; } - const child = spawn(cmd, args, { + if (!nodeInstalled) { + setStep( + "install-node", + "failed", + "Could not auto-install Node.js.", + ); + installState.status = "failed"; + sendProgress(); + return; + } + + setStep("install-node", "success"); + addLog("Node.js installation completed."); + } + + // Step 1: Check node + setStep("check-node", "running"); + try { + const nodeResult = execFileSync("node", ["--version"], { + timeout: 5000, + encoding: "utf-8", env: execEnv, - shell: isWin, - stdio: ['ignore', 'pipe', 'pipe'], }); + addLog(`Node.js found: ${nodeResult.trim()}`); + setStep("check-node", "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`Node.js not found: ${msg}`); + setStep( + "check-node", + "failed", + "Node.js is not installed. Please install Node.js first.", + ); + installState.status = "failed"; + sendProgress(); + return; + } + + // Step 2: Install Claude Code via npm + setStep("install-claude", "running"); + addLog("Running: npm install -g @anthropic-ai/claude-code"); + + const npmInstallSuccess = await new Promise((resolve) => { + const isWin = process.platform === "win32"; + const npmCmd = isWin ? "npm.cmd" : "npm"; + + const child = spawn( + npmCmd, + ["install", "-g", "@anthropic-ai/claude-code"], + { + env: execEnv, + shell: isWin, + stdio: ["ignore", "pipe", "pipe"], + }, + ); installProcess = child; - child.stdout?.on('data', (data: Buffer) => { - for (const line of data.toString().split('\n').filter(Boolean)) { + child.stdout?.on("data", (data: Buffer) => { + const lines = data.toString().split("\n").filter(Boolean); + for (const line of lines) { addLog(line); } }); - child.stderr?.on('data', (data: Buffer) => { - for (const line of data.toString().split('\n').filter(Boolean)) { + + child.stderr?.on("data", (data: Buffer) => { + const lines = data.toString().split("\n").filter(Boolean); + for (const line of lines) { addLog(line); } }); - child.on('error', (err) => { - addLog(`Error: ${err.message}`); + + child.on("error", (err) => { + addLog(`npm error: ${err.message}`); resolve(false); }); - child.on('close', (code) => { + + child.on("close", (code) => { installProcess = null; - resolve(code === 0); + if (code === 0) { + addLog("npm install completed successfully"); + resolve(true); + } else if (installState.status === "cancelled") { + addLog("Installation was cancelled"); + resolve(false); + } else { + addLog(`npm install exited with code ${code}`); + resolve(false); + } }); }); - if (installState.status === 'cancelled') { - setStep('install-node', 'failed', 'Cancelled'); + if (installState.status === "cancelled") { + setStep("install-claude", "failed", "Cancelled"); return; } - if (!nodeInstalled) { - setStep('install-node', 'failed', 'Could not auto-install Node.js.'); - installState.status = 'failed'; + if (!npmInstallSuccess) { + setStep( + "install-claude", + "failed", + "npm install failed. Check logs for details.", + ); + installState.status = "failed"; sendProgress(); return; } - setStep('install-node', 'success'); - addLog('Node.js installation completed.'); - } + setStep("install-claude", "success"); - // Step 1: Check node - setStep('check-node', 'running'); - try { - const nodeResult = execFileSync('node', ['--version'], { - timeout: 5000, - encoding: 'utf-8', - env: execEnv, - }); - addLog(`Node.js found: ${nodeResult.trim()}`); - setStep('check-node', 'success'); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - addLog(`Node.js not found: ${msg}`); - setStep('check-node', 'failed', 'Node.js is not installed. Please install Node.js first.'); - installState.status = 'failed'; - sendProgress(); - return; - } - - // Step 2: Install Claude Code via npm - setStep('install-claude', 'running'); - addLog('Running: npm install -g @anthropic-ai/claude-code'); - - const npmInstallSuccess = await new Promise((resolve) => { - const isWin = process.platform === 'win32'; - const npmCmd = isWin ? 'npm.cmd' : 'npm'; - - const child = spawn(npmCmd, ['install', '-g', '@anthropic-ai/claude-code'], { - env: execEnv, - shell: isWin, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - installProcess = child; - - child.stdout?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter(Boolean); - for (const line of lines) { - addLog(line); - } - }); - - child.stderr?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter(Boolean); - for (const line of lines) { - addLog(line); - } - }); - - child.on('error', (err) => { - addLog(`npm error: ${err.message}`); - resolve(false); - }); - - child.on('close', (code) => { - installProcess = null; - if (code === 0) { - addLog('npm install completed successfully'); - resolve(true); - } else if (installState.status === 'cancelled') { - addLog('Installation was cancelled'); - resolve(false); - } else { - addLog(`npm install exited with code ${code}`); - resolve(false); - } - }); - }); - - if (installState.status === 'cancelled') { - setStep('install-claude', 'failed', 'Cancelled'); - return; - } + // Step 3: Verify claude is available + setStep("verify", "running"); + try { + const verifyOpts = + process.platform === "win32" + ? { + timeout: 5000, + encoding: "utf-8" as const, + env: execEnv, + shell: true, + } + : { timeout: 5000, encoding: "utf-8" as const, env: execEnv }; + const claudeResult = execFileSync( + "claude", + ["--version"], + verifyOpts, + ); + addLog(`Claude Code installed: ${claudeResult.trim()}`); + setStep("verify", "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`Verification failed: ${msg}`); + setStep( + "verify", + "failed", + "Claude Code was installed but could not be verified.", + ); + installState.status = "failed"; + sendProgress(); + return; + } - if (!npmInstallSuccess) { - setStep('install-claude', 'failed', 'npm install failed. Check logs for details.'); - installState.status = 'failed'; + installState.status = "success"; + installState.currentStep = null; sendProgress(); - return; - } - - setStep('install-claude', 'success'); - - // Step 3: Verify claude is available - setStep('verify', 'running'); - try { - const verifyOpts = process.platform === 'win32' - ? { timeout: 5000, encoding: 'utf-8' as const, env: execEnv, shell: true } - : { timeout: 5000, encoding: 'utf-8' as const, env: execEnv }; - const claudeResult = execFileSync('claude', ['--version'], verifyOpts); - addLog(`Claude Code installed: ${claudeResult.trim()}`); - setStep('verify', 'success'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - addLog(`Verification failed: ${msg}`); - setStep('verify', 'failed', 'Claude Code was installed but could not be verified.'); - installState.status = 'failed'; + addLog(`Unexpected error: ${msg}`); + installState.status = "failed"; sendProgress(); - return; } + })(); + }, + ); - installState.status = 'success'; - installState.currentStep = null; - sendProgress(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - addLog(`Unexpected error: ${msg}`); - installState.status = 'failed'; - sendProgress(); - } - })(); - }); - - ipcMain.handle('install:cancel', () => { - if (installState.status !== 'running') { + ipcMain.handle("install:cancel", () => { + if (installState.status !== "running") { return; } - installState.status = 'cancelled'; - installState.logs.push('Cancelling installation...'); + installState.status = "cancelled"; + installState.logs.push("Cancelling installation..."); if (installProcess) { const pid = installProcess.pid; try { - if (process.platform === 'win32' && pid) { + if (process.platform === "win32" && pid) { // Windows: kill entire process tree (shell: true spawns cmd.exe which // spawns npm/winget — child.kill() only kills the shell, not the tree) - spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' }); + spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { + stdio: "ignore", + }); } else { installProcess.kill(); } @@ -885,38 +1140,44 @@ app.whenReady().then(async () => { // already dead } installProcess = null; - installState.logs.push('Installation process terminated.'); + installState.logs.push("Installation process terminated."); } - mainWindow?.webContents.send('install:progress', installState); + mainWindow?.webContents.send("install:progress", installState); }); - ipcMain.handle('install:get-logs', () => { + ipcMain.handle("install:get-logs", () => { return installState.logs; }); // --- End install wizard IPC handlers --- // Open a folder in the system file manager (Finder / Explorer) - ipcMain.handle('shell:open-path', async (_event: Electron.IpcMainInvokeEvent, folderPath: string) => { - return shell.openPath(folderPath); - }); + ipcMain.handle( + "shell:open-path", + async (_event: Electron.IpcMainInvokeEvent, folderPath: string) => { + return shell.openPath(folderPath); + }, + ); // Bridge status IPC - ipcMain.handle('bridge:is-active', async () => { + ipcMain.handle("bridge:is-active", async () => { return isBridgeActive(); }); // Native folder picker dialog - ipcMain.handle('dialog:open-folder', async (_event, options?: { defaultPath?: string; title?: string }) => { - if (!mainWindow) return { canceled: true, filePaths: [] }; - const result = await dialog.showOpenDialog(mainWindow, { - title: options?.title || 'Select a project folder', - defaultPath: options?.defaultPath || undefined, - properties: ['openDirectory', 'createDirectory'], - }); - return { canceled: result.canceled, filePaths: result.filePaths }; - }); + ipcMain.handle( + "dialog:open-folder", + async (_event, options?: { defaultPath?: string; title?: string }) => { + if (!mainWindow) return { canceled: true, filePaths: [] }; + const result = await dialog.showOpenDialog(mainWindow, { + title: options?.title || "Select a project folder", + defaultPath: options?.defaultPath || undefined, + properties: ["openDirectory", "createDirectory"], + }); + return { canceled: result.canceled, filePaths: result.filePaths }; + }, + ); try { let port: number; @@ -937,57 +1198,61 @@ app.whenReady().then(async () => { // Wait for server in background, then navigate to real URL await waitForServer(port); - console.log('Server is ready'); + console.log("Server is ready"); if (mainWindow) { mainWindow.loadURL(`http://127.0.0.1:${port}`); } // Trigger bridge auto-start via explicit POST (only checks setting once) // eslint-disable-next-line @typescript-eslint/no-require-imports - const http = require('http'); - const autoStartData = JSON.stringify({ action: 'auto-start' }); - const autoStartReq = http.request({ - hostname: '127.0.0.1', - port, - path: '/api/bridge', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(autoStartData), + const http = require("http"); + const autoStartData = JSON.stringify({ action: "auto-start" }); + const autoStartReq = http.request( + { + hostname: "127.0.0.1", + port, + path: "/api/bridge", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(autoStartData), + }, }, - }, () => {}); - autoStartReq.on('error', () => {}); + () => {}, + ); + autoStartReq.on("error", () => {}); autoStartReq.write(autoStartData); autoStartReq.end(); } - } catch (err) { - console.error('Failed to start:', err); + console.error("Failed to start:", err); dialog.showErrorBox( - 'CodePilot - Failed to Start', - `The internal server could not start.\n\n${err instanceof Error ? err.message : String(err)}\n\nPlease try restarting the application.` + "CodePilot - Failed to Start", + `The internal server could not start.\n\n${err instanceof Error ? err.message : String(err)}\n\nPlease try restarting the application.`, ); app.quit(); } }); -app.on('window-all-closed', async () => { +app.on("window-all-closed", async () => { // If bridge is active, keep the server running and show a tray icon const bridgeActive = await isBridgeActive(); if (bridgeActive) { - console.log('Bridge is active — keeping server alive in background with tray icon'); + console.log( + "Bridge is active — keeping server alive in background with tray icon", + ); createTray(); return; } destroyTray(); await killServer(); - if (process.platform !== 'darwin') { + if (process.platform !== "darwin") { app.quit(); } }); -app.on('activate', async () => { +app.on("activate", async () => { // If tray is active (bridge background mode), destroy it when user re-opens destroyTray(); @@ -1006,24 +1271,27 @@ app.on('activate', async () => { } else { createWindow(`http://127.0.0.1:${serverPort || 3000}`); } - } catch (err) { - console.error('Failed to restart server:', err); + console.error("Failed to restart server:", err); } } }); -app.on('before-quit', async (e) => { +app.on("before-quit", async (e) => { // Kill any running install process (tree-kill on Windows) if (installProcess) { const pid = installProcess.pid; try { - if (process.platform === 'win32' && pid) { - spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' }); + if (process.platform === "win32" && pid) { + spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { + stdio: "ignore", + }); } else { installProcess.kill(); } - } catch { /* already dead */ } + } catch { + /* already dead */ + } installProcess = null; } diff --git a/src/components/settings/CliSettingsSection.tsx b/src/components/settings/CliSettingsSection.tsx index ba2094dc..064edc03 100644 --- a/src/components/settings/CliSettingsSection.tsx +++ b/src/components/settings/CliSettingsSection.tsx @@ -55,19 +55,52 @@ export function CliSettingsSection() { const [saving, setSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [pendingSaveAction, setPendingSaveAction] = useState<"form" | "json" | null>(null); + const [pendingSaveAction, setPendingSaveAction] = useState< + "form" | "json" | null + >(null); const { t } = useTranslation(); - const knownFieldKeys: Record = { - permissions: { label: 'cli.permissions', description: 'cli.permissionsDesc' }, - env: { label: 'cli.envVars', description: 'cli.envVarsDesc' }, + const knownFieldKeys: Record< + string, + { label: TranslationKey; description: TranslationKey } + > = { + permissions: { + label: "cli.permissions", + description: "cli.permissionsDesc", + }, + env: { label: "cli.envVars", description: "cli.envVarsDesc" }, + }; + + // Helpers to read/write HTTPS_PROXY inside settings.env + const getProxyValue = (): string => { + const env = settings.env as Record | null | undefined; + return env?.HTTPS_PROXY ?? ""; + }; + + const setProxyValue = (value: string) => { + const env = + (settings.env as Record | null | undefined) ?? {}; + if (value.trim()) { + updateField("env", { + ...env, + HTTPS_PROXY: value.trim(), + https_proxy: value.trim(), + }); + } else { + // Remove both HTTPS_PROXY and https_proxy if cleared + const { HTTPS_PROXY, https_proxy, ...rest } = env; + void HTTPS_PROXY; + void https_proxy; + updateField("env", Object.keys(rest).length > 0 ? rest : undefined); + } }; // Map dynamic CLI settings keys to translation keys (for fields not in KNOWN_FIELDS) const dynamicFieldLabels: Record = { - skipDangerousModePermissionPrompt: 'cli.field.skipDangerousModePermissionPrompt', - verbose: 'cli.field.verbose', - theme: 'cli.field.theme', + skipDangerousModePermissionPrompt: + "cli.field.skipDangerousModePermissionPrompt", + verbose: "cli.field.verbose", + theme: "cli.field.theme", }; const fetchSettings = useCallback(async () => { @@ -93,7 +126,8 @@ export function CliSettingsSection() { fetchSettings(); }, [fetchSettings]); - const hasChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings); + const hasChanges = + JSON.stringify(settings) !== JSON.stringify(originalSettings); const handleSave = async (source: "form" | "json") => { let dataToSave: SettingsData; @@ -146,7 +180,7 @@ export function CliSettingsSection() { setJsonText(JSON.stringify(parsed, null, 2)); setJsonError(""); } catch { - setJsonError(t('cli.formatError')); + setJsonError(t("cli.formatError")); } }; @@ -163,7 +197,9 @@ export function CliSettingsSection() { return (
- {t('cli.loadingSettings')} + + {t("cli.loadingSettings")} +
); } @@ -174,23 +210,47 @@ export function CliSettingsSection() { - {t('cli.form')} + {t("cli.form")} - {t('cli.json')} + {t("cli.json")}
+ {/* Proxy server — dedicated input that writes to settings.env.HTTPS_PROXY */} +
+ +

+ {t("cli.proxyDesc")} +

+ setProxyValue(e.target.value)} + placeholder={t("cli.proxyPlaceholder")} + className="font-mono text-sm" + /> +
+ {KNOWN_FIELDS.map((field) => (
- -

{t(knownFieldKeys[field.key]?.description ?? field.description as TranslationKey)}

+ +

+ {t( + knownFieldKeys[field.key]?.description ?? + (field.description as TranslationKey), + )} +